Skip to content

Add Windows named-pipe support to the API listener#5201

Merged
samuv merged 5 commits intomainfrom
socker-windows
May 7, 2026
Merged

Add Windows named-pipe support to the API listener#5201
samuv merged 5 commits intomainfrom
socker-windows

Conversation

@samuv
Copy link
Copy Markdown
Contributor

@samuv samuv commented May 6, 2026

Summary

  • toolhive-studio on Windows wants to talk to thv serve over a Windows named pipe (\\.\pipe\thv-api), the same way it uses a UNIX socket on macOS/Linux. The current --socket plumbing in pkg/api/server.go only handles AF_UNIX paths: setupUnixSocket runs os.Stat/os.Remove/os.MkdirAll/os.Chmod around net.Listen("unix", ...). None of that applies to named pipes — they are a different kernel object — so passing a pipe path made thv serve fail before the listener even opened.
  • A second silent break would have hit Windows: Server.ListenURL() writes the discovery-file URL, and pkg/server/discovery/health.go HTTPClientForURL only understood http:// and unix://. Every thv serve startup runs discovery.Discover to detect a previous instance via that URL; without an npipe:// scheme, two concurrent thv processes on Windows would not see each other and would silently overwrite the discovery file.
  • Split the listener and the discovery dialer per platform with build tags, mirroring pkg/container/docker/sdk/client_{unix,windows}.go. On Windows, a \\.\pipe\ prefix routes to winio.ListenPipe (skipping stat/remove/mkdir/chmod) on the listener side and to winio.DialPipeContext on the health-check side. AF_UNIX fallback paths still work but drop os.Chmod because POSIX modes are meaningless on Windows. ListenURL emits npipe://<name> and discovery.HTTPClientForURL parses it via a new ParseNamedPipeURL validator. Non-Windows builds reject pipe addresses up front rather than creating a literal-backslash file.
  • github.com/Microsoft/go-winio v0.6.2 is already a direct dependency (used by pkg/container/docker/sdk/client_windows.go), so no go.mod change is needed. The existing --socket flag works for both transports — the studio passes the right address per platform.
  • Four independent commits — discovery production, listener production, discovery tests, listener tests — so each can be reviewed / reverted in isolation.

Type of change

  • New feature

Test plan

  • Unit tests (task test) — full repo green; new TestParseNamedPipeURL, TestSocketURL_Unix, TestIsNamedPipeAddress, and TestCheckHealth_NamedPipe_Unsupported_OnNonWindows all pass on macOS.
  • Linting (task lint-fix — 0 issues).
  • Cross-compile (GOOS=windows go vet ./pkg/api/... ./pkg/server/discovery/... and GOOS=windows go test -c -o /dev/null for both packages) — both clean, confirming socket_windows.go, socket_windows_test.go, pipe_windows.go, and health_windows_test.go build correctly.
  • Manual testing on Windows — pending; happy to defer to a reviewer with a Windows host. The Windows-only e2e in pkg/server/discovery/health_windows_test.go does the full production round trip: winio.ListenPipehttp.Server.ServeCheckHealth("npipe://<name>", nonce) asserts 204 with matching nonce.

Changes

File Change
pkg/api/server.go Remove setupUnixSocket / cleanupUnixSocket; add cross-platform namedPipePrefix and isNamedPipeAddress; route ListenURL through socketURL; tighten createListener (label "Windows named pipe"; reject pipe addresses on non-Windows). Stdlib runtime aliased to stdruntime to avoid collision with the existing pkg/container/runtime import.
pkg/api/socket_other.go New, //go:build !windows. Original POSIX setupUnixSocket / cleanupUnixSocket; socketURL always emits unix://.
pkg/api/socket_windows.go New, //go:build windows. Pipe-aware setupUnixSocket (winio.ListenPipe, 64 KiB buffers, byte-stream mode), cleanupUnixSocket no-op for pipes, AF_UNIX fallback without chmod, socketURL emits npipe://<name>.
pkg/server/discovery/health.go Add npipe:// arm in HTTPClientForURL (transport DialContext calls dialNamedPipe); add ParseNamedPipeURL validator (rejects empty, wrong scheme, path separators, ..); update doc comments.
pkg/server/discovery/pipe_windows.go New, //go:build windows. dialNamedPipe via winio.DialPipeContext.
pkg/server/discovery/pipe_other.go New, //go:build !windows. Stub returning "named pipes are only supported on Windows".
pkg/api/socket_other_test.go New, //go:build !windows. TestSocketURL_Unix, TestIsNamedPipeAddress.
pkg/api/socket_windows_test.go New, //go:build windows. TestSocketURL_Windows, TestSetupUnixSocket_NamedPipe (real winio.ListenPipe + dial round trip), TestCleanupUnixSocket_NamedPipe_NoOp.
pkg/server/discovery/health_test.go Add TestParseNamedPipeURL (valid + 6 rejection branches) and TestCheckHealth_NamedPipe_Unsupported_OnNonWindows.
pkg/server/discovery/health_windows_test.go New, //go:build windows. TestCheckHealth_NamedPipe_Success (end-to-end listener + serve + check) and TestCheckHealth_NamedPipe_NotFound.

Does this introduce a user-facing change?

Yes — on Windows, thv serve --socket \\.\pipe\thv-api now works, which unblocks toolhive-studio talking to thv over a named pipe. macOS/Linux behaviour is unchanged: passing a pipe-prefixed address there fails fast with named pipe addresses are only supported on Windows. The --socket flag 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.md here)

Special notes for reviewers

  • URL scheme: npipe://<name> rather than the Docker-style npipe:////./pipe/<name> because we control both writer (socketURL) and reader (HTTPClientForURL), so the simpler form parses cleanly with net/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.
  • MessageMode is left at false (byte stream) explicitly in the winio.PipeConfig literal as a documentation cue — HTTP requires byte framing. The zero value is already false, so the assignment is redundant but informative.
  • Buffer sizes (64 KiB in/out) match what Docker, containerd, and Podman use for similar same-host control-plane pipes; well above any single HTTP header chunk so we never block on the kernel-side ring buffer.
  • Commit ordering: the discovery-side dialer lands first so the API listener's npipe:// emission in commit 2 is immediately consumable by discovery.Discover. No intermediate commit leaves the codebase emitting URLs it cannot dial.

@samuv samuv requested review from JAORMX and amirejaz as code owners May 6, 2026 10:59
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label May 6, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 6, 2026

Codecov Report

❌ Patch coverage is 54.23729% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.81%. Comparing base (182a025) to head (785ddc3).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
pkg/api/socket_unix.go 15.78% 16 Missing ⚠️
pkg/api/server.go 47.05% 8 Missing and 1 partial ⚠️
pkg/server/discovery/health.go 90.47% 1 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@samuv samuv self-assigned this May 6, 2026
@samuv samuv force-pushed the socker-windows branch from 2822942 to efbe6f1 Compare May 6, 2026 17:02
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels May 6, 2026
samuv added 4 commits May 7, 2026 08:34
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.
@samuv samuv force-pushed the socker-windows branch from efbe6f1 to 261fcce Compare May 7, 2026 06:34
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels May 7, 2026
Copy link
Copy Markdown
Member

@aponcedeleonch aponcedeleonch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --socket flag help text in cmd/thv/app/server.go still says "UNIX socket path." On Windows it now also accepts \\.\pipe\<name>. Update and run task docs.
  • Discovery file trust boundary on Windows. xdg.StateHome lands under %LOCALAPPDATA%, which is per-user by default, but the POSIX modes passed to MkdirAll in pkg/server/discovery/discovery.go are advisory on Windows. If a local attacker can write npipe://attacker-pipe into server.json, health checks dial the attacker's pipe over plaintext HTTP. Worth setting an explicit DACL on the discovery dir at create time via windows.SetNamedSecurityInfo.

Nothing blocking on functionality. The security and net/url items are the most important; the rest are tightening.

Comment thread pkg/api/socket_windows.go
Comment thread pkg/api/socket_windows.go
Comment thread pkg/server/discovery/health.go
Comment thread pkg/server/discovery/health.go
Comment thread pkg/api/socket_windows.go
Comment thread pkg/api/socket_windows.go
Comment thread pkg/api/socket_unix_test.go
Comment thread pkg/server/discovery/health_test.go
Comment thread pkg/api/socket_windows_test.go
Comment thread pkg/server/discovery/health_windows_test.go
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>
@samuv
Copy link
Copy Markdown
Contributor Author

samuv commented May 7, 2026

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 main on merge).

Landed in this PR (commit 785ddc34)

  • Updated --socket flag help text to mention named pipes; ran task docs.
  • Promoted namedPipePrefix to discovery.NamedPipePrefix as the canonical definition; pkg/api re-aliases it locally so listener and dialer cannot drift. (#3201085425)
  • Made isNamedPipeAddress case-insensitive via strings.EqualFold. (#3201085392)
  • Collapsed os.Stat -> os.Remove into a single os.Remove that tolerates fs.ErrNotExist on both POSIX and the Windows AF_UNIX fallback. (#3201085418)
  • Closed the AF_UNIX listener and removed the socket file when os.Chmod fails. (#3201085412)
  • Replaced the one-off stdruntime.GOOS check with a per-platform supportsNamedPipe() helper, dropping the runtime-package alias and its collision with pkg/container/runtime. (#3201085428)
  • Renamed socket_other.{go,_test.go} -> socket_unix.{go,_test.go} to match the client_unix/client_windows convention. (#3201085430)
  • Added TestCreateListener_NamedPipe_Unsupported to round out the listener-side reject coverage on non-Windows. (#3201085438)

Split into stacked follow-ups

  • Restrict Windows named-pipe DACL to creating user #5214 - Restrict Windows named-pipe DACL to creating user. Sets explicit SDDL D:P(A;;GA;;;OW)(A;;GA;;;SY) on the pipe (current user + SYSTEM only, no inheritance), softens the related comments, marks the AF_UNIX fallback path as best-effort since NTFS inheritance still applies, and adds http.Server.IdleTimeout cross-platform as defense in depth around winio.MaxInstances. Addresses #3201085355, #3201085359, and #3201085433.
  • Move pipe and socket URL handling to net/url #5215 - Move pipe and socket URL handling to net/url. Migrates socketURL, ParseUnixSocketPath, ParseNamedPipeURL, and the HTTPClientForURL dispatcher to net/url.Parse. Includes a real bug fix for the unparseable Windows AF_UNIX URL form (unix://C:\path\thv.sock -> unix:///C:%5Cpath%5Cthv.sock), plus the length cap, reserved-name rejection, positive charset, and case normalisation you suggested. Addresses #3201085366, #3201085375, and #3201085383.
  • Pin npipe round-trip and pipe lifecycle invariants #5216 - Pin npipe round-trip and pipe lifecycle invariants. Adds the WriteServerInfo -> ReadServerInfo round-trip test for npipe:// URLs, the FILE_FLAG_FIRST_PIPE_INSTANCE first-wins test, and the hung-peer-context-cancels test for CheckHealth. Addresses #3201085442, #3201085446, and #3201085449.

Filed as separate issue

Happy to fold any of the stacked PRs back in here if you'd prefer one batch over four staged reviews.

@samuv samuv merged commit 4aa2801 into main May 7, 2026
78 of 79 checks passed
@samuv samuv deleted the socker-windows branch May 7, 2026 13:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants