Skip to content

Commit 9eeceab

Browse files
committed
Add Windows named-pipe support to API listener
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.
1 parent ba34215 commit 9eeceab

3 files changed

Lines changed: 171 additions & 44 deletions

File tree

pkg/api/server.go

Lines changed: 30 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"net/http"
2828
"os"
2929
"path/filepath"
30+
stdruntime "runtime"
3031
"strings"
3132
"time"
3233

@@ -366,41 +367,20 @@ func (b *ServerBuilder) setupDefaultRoutes(r *chi.Mux) {
366367
}
367368
}
368369

369-
func setupTCPListener(address string) (net.Listener, error) {
370-
return net.Listen("tcp", address)
371-
}
372-
373-
func setupUnixSocket(address string) (net.Listener, error) {
374-
// Remove the socket file if it already exists
375-
if _, err := os.Stat(address); err == nil {
376-
if err := os.Remove(address); err != nil {
377-
return nil, fmt.Errorf("failed to remove existing socket: %w", err)
378-
}
379-
}
380-
381-
// Create the directory for the socket file if it doesn't exist
382-
if err := os.MkdirAll(filepath.Dir(address), 0750); err != nil {
383-
return nil, fmt.Errorf("failed to create socket directory: %w", err)
384-
}
385-
386-
// Create UNIX socket listener
387-
listener, err := net.Listen("unix", address)
388-
if err != nil {
389-
return nil, fmt.Errorf("failed to create UNIX socket listener: %w", err)
390-
}
370+
// namedPipePrefix is the Windows named-pipe namespace prefix. Both per-platform
371+
// socket files use it, and ListenURL/createListener inspect the address prefix
372+
// to choose between AF_UNIX and named-pipe semantics.
373+
const namedPipePrefix = `\\.\pipe\`
391374

392-
// Set file permissions on the socket to allow other local processes to connect
393-
if err := os.Chmod(address, socketPermissions); err != nil {
394-
return nil, fmt.Errorf("failed to set socket permissions: %w", err)
395-
}
396-
397-
return listener, nil
375+
// isNamedPipeAddress reports whether address is a Windows named-pipe path.
376+
// The check is platform-agnostic so callers on non-Windows can fail fast with
377+
// a clear error before reaching the listener code.
378+
func isNamedPipeAddress(address string) bool {
379+
return strings.HasPrefix(address, namedPipePrefix)
398380
}
399381

400-
func cleanupUnixSocket(address string) {
401-
if err := os.Remove(address); err != nil && !os.IsNotExist(err) {
402-
slog.Warn("failed to remove socket file", "error", err)
403-
}
382+
func setupTCPListener(address string) (net.Listener, error) {
383+
return net.Listen("tcp", address)
404384
}
405385

406386
func headersMiddleware(next http.Handler) http.Handler {
@@ -592,7 +572,7 @@ func NewServer(ctx context.Context, builder *ServerBuilder) (*Server, error) {
592572
// bound address from the listener (important when binding to port 0).
593573
func (s *Server) ListenURL() string {
594574
if s.isUnixSocket {
595-
return fmt.Sprintf("unix://%s", s.address)
575+
return socketURL(s.address)
596576
}
597577
return fmt.Sprintf("http://%s", s.listener.Addr().String())
598578
}
@@ -715,24 +695,30 @@ func (s *Server) cleanup() {
715695
}
716696
}
717697

718-
// createListener creates the appropriate listener based on the configuration
698+
// createListener creates the appropriate listener based on the configuration.
699+
// Named-pipe addresses are only supported on Windows; other platforms reject
700+
// them up front rather than creating a literal-backslash file via AF_UNIX.
719701
func createListener(address string, isUnixSocket bool) (net.Listener, string, error) {
720-
var listener net.Listener
721-
var addrType string
722-
var err error
702+
if !isUnixSocket {
703+
listener, err := setupTCPListener(address)
704+
if err != nil {
705+
return nil, "", err
706+
}
707+
return listener, "HTTP", nil
708+
}
723709

724-
if isUnixSocket {
725-
listener, err = setupUnixSocket(address)
726-
addrType = "UNIX socket"
727-
} else {
728-
listener, err = setupTCPListener(address)
729-
addrType = "HTTP"
710+
addrType := "UNIX socket"
711+
if isNamedPipeAddress(address) {
712+
if stdruntime.GOOS != "windows" {
713+
return nil, "", fmt.Errorf("named pipe addresses are only supported on Windows: %s", address)
714+
}
715+
addrType = "Windows named pipe"
730716
}
731717

718+
listener, err := setupUnixSocket(address)
732719
if err != nil {
733720
return nil, "", err
734721
}
735-
736722
return listener, addrType, nil
737723
}
738724

pkg/api/socket_other.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build !windows
5+
6+
package api
7+
8+
import (
9+
"fmt"
10+
"log/slog"
11+
"net"
12+
"os"
13+
"path/filepath"
14+
)
15+
16+
// setupUnixSocket creates a UNIX domain socket listener at the given path.
17+
// On non-Windows platforms named-pipe addresses are not supported; callers
18+
// guard against that in createListener.
19+
func setupUnixSocket(address string) (net.Listener, error) {
20+
if _, err := os.Stat(address); err == nil {
21+
if err := os.Remove(address); err != nil {
22+
return nil, fmt.Errorf("failed to remove existing socket: %w", err)
23+
}
24+
}
25+
26+
if err := os.MkdirAll(filepath.Dir(address), 0750); err != nil {
27+
return nil, fmt.Errorf("failed to create socket directory: %w", err)
28+
}
29+
30+
listener, err := net.Listen("unix", address)
31+
if err != nil {
32+
return nil, fmt.Errorf("failed to create UNIX socket listener: %w", err)
33+
}
34+
35+
if err := os.Chmod(address, socketPermissions); err != nil {
36+
return nil, fmt.Errorf("failed to set socket permissions: %w", err)
37+
}
38+
39+
return listener, nil
40+
}
41+
42+
// cleanupUnixSocket removes the socket file at address. Missing files are not
43+
// an error since cleanup may run after a partial startup.
44+
func cleanupUnixSocket(address string) {
45+
if err := os.Remove(address); err != nil && !os.IsNotExist(err) {
46+
slog.Warn("failed to remove socket file", "error", err)
47+
}
48+
}
49+
50+
// socketURL returns the URL form of a Unix-socket address for the discovery
51+
// file. Non-Windows platforms only ever produce unix:// URLs.
52+
func socketURL(address string) string {
53+
return "unix://" + address
54+
}

pkg/api/socket_windows.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build windows
5+
6+
package api
7+
8+
import (
9+
"fmt"
10+
"log/slog"
11+
"net"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
16+
"github.com/Microsoft/go-winio"
17+
)
18+
19+
// namedPipeBufferSize is the size of the input/output buffers winio allocates
20+
// per pipe instance. 64 KiB matches what go-winio uses in similar consumers
21+
// (Docker, containerd, Podman) and is well above any single HTTP header chunk.
22+
const namedPipeBufferSize = 64 * 1024
23+
24+
// setupUnixSocket creates either a Windows named-pipe listener (when address
25+
// has the \\.\pipe\ prefix) or an AF_UNIX listener at a filesystem path.
26+
//
27+
// Named pipes are kernel objects rather than files, so the os.Stat / os.Remove
28+
// precheck, os.MkdirAll, and os.Chmod steps are skipped: the pipe namespace
29+
// has no parent directory, and access control is governed by the security
30+
// descriptor on the listener (winio's default restricts access to the
31+
// creating user, which matches the toolhive-studio same-user use case).
32+
//
33+
// AF_UNIX is supported on Windows 10 1803+. The chmod step is dropped on this
34+
// path because POSIX file modes do not apply on Windows.
35+
func setupUnixSocket(address string) (net.Listener, error) {
36+
if strings.HasPrefix(address, namedPipePrefix) {
37+
// MessageMode is left at false (byte stream) explicitly because HTTP
38+
// requires byte-oriented framing.
39+
listener, err := winio.ListenPipe(address, &winio.PipeConfig{
40+
MessageMode: false,
41+
InputBufferSize: namedPipeBufferSize,
42+
OutputBufferSize: namedPipeBufferSize,
43+
})
44+
if err != nil {
45+
return nil, fmt.Errorf("failed to create named pipe listener: %w", err)
46+
}
47+
return listener, nil
48+
}
49+
50+
if _, err := os.Stat(address); err == nil {
51+
if err := os.Remove(address); err != nil {
52+
return nil, fmt.Errorf("failed to remove existing socket: %w", err)
53+
}
54+
}
55+
56+
if err := os.MkdirAll(filepath.Dir(address), 0750); err != nil {
57+
return nil, fmt.Errorf("failed to create socket directory: %w", err)
58+
}
59+
60+
listener, err := net.Listen("unix", address)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to create UNIX socket listener: %w", err)
63+
}
64+
65+
return listener, nil
66+
}
67+
68+
// cleanupUnixSocket removes the AF_UNIX socket file at address, or no-ops for
69+
// named pipes (the pipe is destroyed when the listener closes).
70+
func cleanupUnixSocket(address string) {
71+
if strings.HasPrefix(address, namedPipePrefix) {
72+
return
73+
}
74+
if err := os.Remove(address); err != nil && !os.IsNotExist(err) {
75+
slog.Warn("failed to remove socket file", "error", err)
76+
}
77+
}
78+
79+
// socketURL returns the URL form of a Unix-socket or named-pipe address for
80+
// the discovery file. Named pipes are emitted as npipe://<name> where <name>
81+
// is everything after the \\.\pipe\ prefix.
82+
func socketURL(address string) string {
83+
if strings.HasPrefix(address, namedPipePrefix) {
84+
return "npipe://" + strings.TrimPrefix(address, namedPipePrefix)
85+
}
86+
return "unix://" + address
87+
}

0 commit comments

Comments
 (0)