Windows 10 1803 and later can serve a Unix-domain stream socket at a filesystem path, so porting CGraph's local daemon to Windows x64 did not need named pipes or a second transport. The daemon, graphd, keeps one framing, one serve loop and one client on Linux, macOS and Windows, and every Winsock difference sits behind a single seam. The port, CGraph PR #203, merged on 2026-10-17. This post covers the choice, the four places Windows behaved differently, a determinism bug the third platform exposed, and what is still skipped or untested.
Why AF_UNIX and not named pipes
graphd holds a code graph warm in memory and answers queries from the MCP server and the CLI over a local socket. On Linux and macOS that socket has always been AF_UNIX. Before the port, the Windows branches were stubs. The OpenSpec proposal behind the PR describes the starting point: the daemon IPC was "a Unix-domain socket whose Windows branches are stubs that print "not implemented"", and graphd did not link.
The obvious Windows answer is a named pipe, and the code already had two named-pipe stubs, windows_named_pipe_path and configure_windows_named_pipe_security. The proposal rejects that route in one sentence: named pipes "would need a second transport (overlapped I/O, different accept and liveness semantics) behind the same frames."
The alternative is that Winsock has supported AF_UNIX SOCK_STREAM at a filesystem path since Windows 10 1803 (build 17063), and the proposal notes that "every supported Windows and the windows-latest runner have it." So the PR deleted both named-pipe stubs and put the platform differences in one place, src/engine/local_socket.cpp. Its header says what lives there:
// Local stream-socket seam for the daemon endpoint. Both platforms speak
// AF_UNIX SOCK_STREAM at a filesystem path: BSD sockets on POSIX, Winsock with
// <afunix.h> on Windows (10 1803 and later). Every Winsock/BSD difference
// (handle type, error codes, close, non-blocking mode, poll, timeouts,
// SIGPIPE, socket-file removal) lives behind these functions, so the daemon
// server and client share one framing and serve-loop code path.
Concretely, that is WSAStartup, SOCKET and closesocket, WSAGetLastError, WSAPoll for poll, ioctlsocket for non-blocking mode, DWORD millisecond timeouts, and DeleteFileW to remove the socket file. The wire protocol, the length cap and the socket timeouts from hardening the local daemon are shared code, so Windows gets them without a port of its own.
Socket files are reparse points
The first surprise is in the socket file itself. On Windows, an AF_UNIX socket file is a reparse point, and std::filesystem status calls fail on it. Code that checks exists() before connecting, or before deleting a stale socket, breaks.
The seam never asks. Liveness is a connection attempt, as the comment in daemon_endpoint.cpp puts it: "No std::filesystem status query first: on Windows an AF_UNIX socket file is a reparse point it cannot read." Removal goes straight to the OS. On Windows that is DeleteFileW, which treats ERROR_FILE_NOT_FOUND and ERROR_PATH_NOT_FOUND as already gone. On POSIX it is unlink, which treats ENOENT the same way.
Where the socket lives, and who can open it
POSIX puts sockets in $XDG_RUNTIME_DIR/graphd or a per-user temp directory. Windows has no runtime directory, so the endpoint is:
%LOCALAPPDATA%\graphd\graphd-<hash>.sock
On POSIX, access control is a mode-0700 directory. The Windows form is a protected DACL. restrict_directory_to_owner in src/engine/platform.cpp reads the current user's SID from the process token and writes one access entry, full control for that SID, inherited by everything created inside. The PROTECTED flag drops the entries the profile directory would otherwise pass down, so the socket file inherits an ACL that names only its owner.
The <hash> is the per-repository identity, and two details changed. The hash now reads the path as UTF-8, so POSIX bytes and hashes are unchanged and Windows cannot throw on a character the ANSI code page cannot map. On Windows it also folds ASCII case, because C:\Repo and c:\repo are one directory and must reach one daemon.
Replacing a file someone is reading
CGraph persists its graph by writing a temp file and renaming it over the old one. POSIX rename replaces a file even while another process has it open. Windows does not: MoveFileExW(MOVEFILE_REPLACE_EXISTING) fails while any reader holds the target open without FILE_SHARE_DELETE, which is how std::ifstream and most readers open a file.
The comment in atomic_write.cpp describes such a reader as momentary, "graph.json read by a client, say", so the replace retries. It backs off from 1 ms up to 50 ms and gives up after a 10-second window. It retries only on ERROR_SHARING_VIOLATION, or on ERROR_ACCESS_DENIED when the target is a file, because the comment notes that "A directory in the way also reports ERROR_ACCESS_DENIED, and no wait will clear it." The rule from the hardening post still holds: a failed replace leaves the last good file in place.
Two writes in one timestamp tick
The file watcher decides a file changed by comparing a token across polls. On Windows the identity half of that token comes from the volume serial number, the file index and ChangeTime, which stand in for device, inode and ctime.
The port also added size and mtime to the token, and that fixed a bug that was not Windows-only. Timestamps advance in ticks, "a few ms on Linux, coarser on NTFS" in the header's words. Two writes inside one change-time tick produced identical ctime-only tokens, so the second write was invisible. The proposal records that file_watcher_test failed that way on NTFS in CI and on a Linux 6.12 tmpfs. With size and mtime in the token, it passes.
The best bug: std::hash is not portable
Adding a third platform also showed that CGraph's output depended on the C++ standard library.
Dedup picks fuzzy-merge candidates with MinHash signatures, and those signatures hashed text shingles with std::hash<std::string>. The standard leaves that function implementation-defined. MSVC uses FNV-1a, libc++ uses CityHash, and libstdc++ uses a MurmurHash2 variant. The candidate bands differed, so Linux, macOS and Windows merged different nodes from the same source. The proposal gives the case that surfaced it: on Windows, dedup_test's PaymentService / Payment Service pair "was never compared."
The fix, minhash_shingle_hash in src/engine/dedup.cpp, writes out libstdc++'s 64-bit _Hash_bytes (seed 0xc70f6907) with explicit little-endian loads. Linux output is unchanged, and macOS and Windows now match it. The PR checked it against std::hash on 12,800 random strings of lengths 0 to 63 on a Linux machine. The test now in the suite pins five known values on every platform and, when built against libstdc++, also checks them against std::hash:
#if defined(__GLIBCXX__)
// On libstdc++ it is std::hash<std::string>, so Linux output is unchanged.
if (cgraph::minhash_shingle_hash(text) != std::hash<std::string>{}(text)) {
Because macOS graphs can change, the PR bumped the persisted index key from logic-24 to logic-25, and the daemon will not load a persisted index whose key does not match, so an index built before the change is not reused. If your tool promises that the same tree yields the same graph, search it for std::hash on anything that reaches the output.
The rest of the platform seam
Everything else that was POSIX-specific moved into src/engine/platform.cpp: the process id, the executable path through GetModuleFileNameW, the .exe suffix, HOME with a USERPROFILE fallback, USERNAME, owner-only directories, and binary stdio for cgraph-mcp. Auto-spawning the daemon uses CreateProcessW with no inherited handles, no console, a new process group and Windows argument quoting. The build adds windows-default and windows-release presets on MSVC and Ninja, /utf-8, links ws2_32 and advapi32, and sets an 8 MB stack reserve because the proposal notes that Windows' 1 MB default "overflowed the extractor's bounded AST walk in Debug."
How it was verified
CI run 38821729887 on the port branch passed all 11 jobs, including windows-latest / windows-release and windows-latest / windows-default. Both Windows jobs logged:
100% tests passed out of 102
cgraph_cli_main_test is reported as skipped in that count. The release job also ran an end-to-end smoke test through the plugin launcher, cgraph-mcp.exe, and a graphd.exe the client spawned on its own over AF_UNIX. Its last lines were:
mcp_smoke: graph_query answered:
mcp_smoke: graph_shutdown answered: {"shutdown":true}
Windows CI was slow to configure until it cached vcpkg's binaries. The PR puts configure at about 27 minutes before the cache, and the run's log shows the cached step:
Restored 16 package(s) from D:\a\_temp/vcpkg-bin in 2.1 s.
What is skipped on Windows
Some tests need POSIX facilities Windows does not have. Each one skips that case and prints why:
RLIMIT_FSIZE.cgraph_cli_main_testexits 77, which ctest reports as a skip: "the full-disk cases need RLIMIT_FSIZE, which Windows does not have." The short-write cases inatomic_write_testand the full-disk persist case indaemon_lifecycle_testskip for the same reason.- FIFOs. Cases in
daemon_server_test,client_runtime_test,semantic_ingest_testandsemantic_cache_testpark work on a FIFO, "and Windows has no filesystem FIFOs." SIGSTOP. The hook-deadline case inclient_runtime_test"freezes a live graphd with SIGSTOP/SIGCONT, which Windows has no equivalent of."wait4. The bounded-memory case infile_cache_testcompares forked children's peak RSS fromwait4().
So full-disk handling, a frozen daemon and peak memory are not tested on Windows. Two other gaps are features rather than tests. cgraph daemon and cgraph drain use launchd or systemd, so the always-on supervisor from cgraph daemon install is not available on Windows. Peer-credential checks are not ported either: current_user_id() returns 0 on Windows, and access is enforced by the directory DACL.
Installing on Windows today
The bin-v0.9.6 release includes cgraph-windows-x64.tar.gz. It contains cgraph.exe, cgraph-mcp.exe, cgraph-client.exe and graphd.exe. The CI smoke test unpacked its Windows archive with the tar.exe in System32:
C:\Windows\System32\tar.exe -xzf cgraph-windows-x64.tar.gz
That download is the whole install path for now. The README's install table lists Linux and macOS archives only, and the Claude Code plugin's launcher pins assets in integrations/launcher/release.json, which has no windows-x64 entry for bin-v0.9.6. On Windows the launcher stops with release bin-v0.9.6 has no windows-x64 build. The PR names the launcher's download, checksum and tar steps on Windows as untested, so pinning the asset is a follow-up, not a formality.
Try it
If you work on Windows, download the archive, run cgraph.exe against a repository, and point your agent's MCP config at cgraph-mcp.exe. Anything that differs from Linux or macOS output is a bug, and after this port the CGraph issue tracker wants to hear about it.