Add Windows named-pipe support to the API listener#5201
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5201 +/- ##
==========================================
+ Coverage 67.74% 67.81% +0.06%
==========================================
Files 608 610 +2
Lines 62212 62256 +44
==========================================
+ Hits 42148 42216 +68
+ Misses 16891 16867 -24
Partials 3173 3173 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
discovery.HTTPClientForURL knew http:// and unix:// only, so a discovery file URL written for a Windows named pipe would be unparseable. Every thv serve startup runs discovery.Discover to detect a previous instance via the discovery URL, which routes through HTTPClientForURL, so without an npipe arm two concurrent thv processes on Windows could not see each other and would silently overwrite the discovery file. Add an npipe:// arm whose transport DialContext goes through winio.DialPipeContext, plus a ParseNamedPipeURL helper that strips the scheme and validates the remaining segment (non-empty, no path separators, no ".."). Split the actual dialer into pipe_windows.go (winio-backed) and pipe_other.go (returns a clear "only supported on Windows" error), so non-Windows builds do not import winio and a misconfigured discovery URL surfaces a useful message instead of an obscure dial syscall failure. go-winio is already a direct dependency at v0.6.2, so no go.mod change is required.
setupUnixSocket assumed the address was a filesystem path -- it
ran os.Stat, os.Remove, os.MkdirAll, and os.Chmod around
net.Listen("unix", ...). None of that applies to a Windows named
pipe (\.\pipe\thv-api), and net.Listen("unix", ...) is not a
substitute since named pipes are a different kernel object. As a
result, toolhive-studio could not run thv with a pipe address on
Windows.
Split setupUnixSocket and cleanupUnixSocket into per-platform
files via build tags, mirroring pkg/container/docker/sdk. On
Windows, branch on the \.\pipe\ prefix and use winio.ListenPipe
with InputBufferSize and OutputBufferSize at 64 KiB; MessageMode
stays at byte-stream because HTTP requires byte framing. Skip
stat/remove/mkdir/chmod for pipes since they are not files;
keep stat/remove/mkdir for the AF_UNIX fallback (Win10 1803+)
but drop chmod because POSIX modes do not apply on Windows.
ListenURL emits npipe://<name> for pipe addresses so the
discovery file URL stays unambiguous. createListener labels the
listener as "Windows named pipe" and rejects pipe addresses on
non-Windows up front, rather than creating a literal-backslash
file via AF_UNIX.
TestParseNamedPipeURL covers the success case plus every
rejection branch (missing scheme, wrong scheme, empty name,
forward slash, backslash, ".."). A non-Windows guard test
asserts CheckHealth("npipe://...") surfaces a clear "Windows"
error rather than a misleading dial syscall failure.
The Windows-only health_windows_test.go spins up a winio
listener with an http.Server.Serve goroutine and asserts
CheckHealth("npipe://<name>", expectedNonce) succeeds with a
matching nonce, plus a not-found case that expects "health
check failed". An atomic counter disambiguates parallel pipe
names, since the Windows pipe namespace is global.
Cover the per-platform refactor of setupUnixSocket. On all platforms, assert socketURL emits unix:// for filesystem addresses and that isNamedPipeAddress detects \.\pipe\ prefixes. On Windows, additionally assert socketURL emits npipe://<name>, that setupUnixSocket against a unique pipe path returns a winio listener that accepts a winio dial within a 2 s timeout (proving the listener is wired to the named-pipe namespace, not AF_UNIX), and that cleanupUnixSocket no-ops for pipe addresses without panicking. Tests are split via build tags so the Windows test file is only compiled on GOOS=windows and pulls in winio there.
aponcedeleonch
left a comment
There was a problem hiding this comment.
Review of the Windows named-pipe support. Most comments target net/url adoption (parser, dispatcher, and producer side) so the npipe scheme is parsed and emitted via the same standard-library tool. Two items below don't have a target line in this PR's diff and live here in the summary instead:
- The
--socketflag help text incmd/thv/app/server.gostill says "UNIX socket path." On Windows it now also accepts\\.\pipe\<name>. Update and runtask docs. - Discovery file trust boundary on Windows.
xdg.StateHomelands under%LOCALAPPDATA%, which is per-user by default, but the POSIX modes passed toMkdirAllinpkg/server/discovery/discovery.goare advisory on Windows. If a local attacker can writenpipe://attacker-pipeintoserver.json, health checks dial the attacker's pipe over plaintext HTTP. Worth setting an explicit DACL on the discovery dir at create time viawindows.SetNamedSecurityInfo.
Nothing blocking on functionality. The security and net/url items are the most important; the rest are tightening.
Address aponcedeleonch's review on PR #5201: - Update --socket help to mention named pipes and regen CLI docs. - Promote namedPipePrefix to discovery.NamedPipePrefix as the canonical definition; pkg/api re-aliases it locally so listener and dialer cannot drift. - Make isNamedPipeAddress case-insensitive via strings.EqualFold; the Windows pipe namespace is case-insensitive at the kernel layer, so \\.\Pipe\foo must not silently fall through to AF_UNIX. - Collapse stat-then-remove into a single os.Remove that tolerates fs.ErrNotExist on both POSIX and the Windows AF_UNIX fallback. - Close the listener and remove the socket file when Chmod fails, rather than leaking a bound AF_UNIX listener. - Replace stdruntime.GOOS with a per-platform supportsNamedPipe() helper, dropping the runtime-package alias and its collision with pkg/container/runtime. - Rename socket_other.{go,_test.go} to socket_unix.{go,_test.go} to match the client_unix/client_windows convention used by pkg/container/docker/sdk. - Add TestCreateListener_NamedPipe_Unsupported to round out the listener-side reject coverage on non-Windows builds. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks @aponcedeleonch for the thorough review. Disposition below; the fixes that fit the size envelope landed here, the rest are split into focused follow-ups (stacked on this branch so they'll auto-rebase onto Landed in this PR (commit
|
Summary
thv serveover a Windows named pipe (\\.\pipe\thv-api), the same way it uses a UNIX socket on macOS/Linux. The current--socketplumbing in pkg/api/server.go only handles AF_UNIX paths:setupUnixSocketrunsos.Stat/os.Remove/os.MkdirAll/os.Chmodaroundnet.Listen("unix", ...). None of that applies to named pipes — they are a different kernel object — so passing a pipe path madethv servefail before the listener even opened.Server.ListenURL()writes the discovery-file URL, and pkg/server/discovery/health.goHTTPClientForURLonly understoodhttp://andunix://. Everythv servestartup runsdiscovery.Discoverto detect a previous instance via that URL; without annpipe://scheme, two concurrentthvprocesses on Windows would not see each other and would silently overwrite the discovery file.pkg/container/docker/sdk/client_{unix,windows}.go. On Windows, a\\.\pipe\prefix routes towinio.ListenPipe(skipping stat/remove/mkdir/chmod) on the listener side and towinio.DialPipeContexton the health-check side. AF_UNIX fallback paths still work but dropos.Chmodbecause POSIX modes are meaningless on Windows.ListenURLemitsnpipe://<name>anddiscovery.HTTPClientForURLparses it via a newParseNamedPipeURLvalidator. Non-Windows builds reject pipe addresses up front rather than creating a literal-backslash file.github.com/Microsoft/go-winio v0.6.2is already a direct dependency (used bypkg/container/docker/sdk/client_windows.go), so nogo.modchange is needed. The existing--socketflag works for both transports — the studio passes the right address per platform.Type of change
Test plan
task test) — full repo green; newTestParseNamedPipeURL,TestSocketURL_Unix,TestIsNamedPipeAddress, andTestCheckHealth_NamedPipe_Unsupported_OnNonWindowsall pass on macOS.task lint-fix— 0 issues).GOOS=windows go vet ./pkg/api/... ./pkg/server/discovery/...andGOOS=windows go test -c -o /dev/nullfor both packages) — both clean, confirmingsocket_windows.go,socket_windows_test.go,pipe_windows.go, andhealth_windows_test.gobuild correctly.winio.ListenPipe→http.Server.Serve→CheckHealth("npipe://<name>", nonce)asserts 204 with matching nonce.Changes
pkg/api/server.gosetupUnixSocket/cleanupUnixSocket; add cross-platformnamedPipePrefixandisNamedPipeAddress; routeListenURLthroughsocketURL; tightencreateListener(label"Windows named pipe"; reject pipe addresses on non-Windows). Stdlibruntimealiased tostdruntimeto avoid collision with the existingpkg/container/runtimeimport.pkg/api/socket_other.go//go:build !windows. Original POSIXsetupUnixSocket/cleanupUnixSocket;socketURLalways emitsunix://.pkg/api/socket_windows.go//go:build windows. Pipe-awaresetupUnixSocket(winio.ListenPipe, 64 KiB buffers, byte-stream mode),cleanupUnixSocketno-op for pipes, AF_UNIX fallback without chmod,socketURLemitsnpipe://<name>.pkg/server/discovery/health.gonpipe://arm inHTTPClientForURL(transportDialContextcallsdialNamedPipe); addParseNamedPipeURLvalidator (rejects empty, wrong scheme, path separators,..); update doc comments.pkg/server/discovery/pipe_windows.go//go:build windows.dialNamedPipeviawinio.DialPipeContext.pkg/server/discovery/pipe_other.go//go:build !windows. Stub returning"named pipes are only supported on Windows".pkg/api/socket_other_test.go//go:build !windows.TestSocketURL_Unix,TestIsNamedPipeAddress.pkg/api/socket_windows_test.go//go:build windows.TestSocketURL_Windows,TestSetupUnixSocket_NamedPipe(realwinio.ListenPipe+ dial round trip),TestCleanupUnixSocket_NamedPipe_NoOp.pkg/server/discovery/health_test.goTestParseNamedPipeURL(valid + 6 rejection branches) andTestCheckHealth_NamedPipe_Unsupported_OnNonWindows.pkg/server/discovery/health_windows_test.go//go:build windows.TestCheckHealth_NamedPipe_Success(end-to-end listener + serve + check) andTestCheckHealth_NamedPipe_NotFound.Does this introduce a user-facing change?
Yes — on Windows,
thv serve --socket \\.\pipe\thv-apinow works, which unblocks toolhive-studio talking tothvover a named pipe. macOS/Linux behaviour is unchanged: passing a pipe-prefixed address there fails fast withnamed pipe addresses are only supported on Windows. The--socketflag itself is unchanged and accepts both AF_UNIX paths and\\.\pipe\…addresses depending on platform.Implementation plan
Approved implementation plan
(paste the contents of
.cursor/plans/windows-named-pipe-api-listener_87ed9c7e.plan.mdhere)Special notes for reviewers
npipe://<name>rather than the Docker-stylenpipe:////./pipe/<name>because we control both writer (socketURL) and reader (HTTPClientForURL), so the simpler form parses cleanly withnet/url— no embedded backslashes. The Docker SDK code in pkg/container/docker/sdk/client_windows.go uses Docker's convention because Docker's client demands it; that constraint does not apply here.false(byte stream) explicitly in thewinio.PipeConfigliteral as a documentation cue — HTTP requires byte framing. The zero value is alreadyfalse, so the assignment is redundant but informative.npipe://emission in commit 2 is immediately consumable bydiscovery.Discover. No intermediate commit leaves the codebase emitting URLs it cannot dial.