Skip to content

Commit 257e874

Browse files
authored
[api] Fix named pipe / UDS creation (#64818)
1 parent 76c4f50 commit 257e874

11 files changed

Lines changed: 285 additions & 30 deletions

File tree

‎packages/typescript/src/api/syncChannel.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type ChildProcess,
1515
spawn,
1616
} from "node:child_process";
17+
import { randomBytes } from "node:crypto";
1718
import {
1819
closeSync,
1920
openSync,
@@ -86,6 +87,10 @@ process.on("exit", () => {
8687
liveChildren.clear();
8788
});
8889

90+
export function createPipePath(): string {
91+
return `\\\\.\\pipe\\tsgo-sync-${process.pid}-${randomBytes(16).toString("hex")}`;
92+
}
93+
8994
/**
9095
* SyncRpcChannel – drop-in replacement for the native libsyncrpc class.
9196
*
@@ -147,7 +152,7 @@ export class SyncRpcChannel {
147152
// we create a Windows named pipe path, pass it to the child
148153
// via --pipe, and open it with fs.openSync which returns a
149154
// real C-runtime fd backed by a proper HANDLE.
150-
const pipePath = `\\\\.\\pipe\\tsgo-sync-${process.pid}-${Date.now()}`;
155+
const pipePath = createPipePath();
151156
this.child = spawn(exe, [...args, "--pipe", pipePath], {
152157
stdio: ["ignore", "ignore", "inherit"],
153158
});

‎tools/scripts/lsp/generate.mts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ const customStructures: Structure[] = [
407407
name: "pipe",
408408
type: { kind: "base", name: "string" },
409409
optional: true,
410-
documentation: "Optional path to use for the named pipe or Unix domain socket. If not provided, a unique path will be generated.",
410+
documentation: "Optional named pipe path on Windows. On Unix, the path must not exist and will be created as a private directory containing a socket named 'socket'. If not provided, a unique path will be generated.",
411411
},
412412
],
413413
documentation: "Parameters for the initializeAPISession request.",

‎tsc/cmd/tsc/api.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func parseAPIFlags(args []string) (apiFlags, error) {
3030
flags := flag.NewFlagSet("api", flag.ContinueOnError)
3131
result := apiFlags{}
3232
flags.StringVar(&result.cwd, "cwd", core.Must(os.Getwd()), "current working directory")
33-
flags.StringVar(&result.pipePath, "pipe", "", "use named pipe or Unix domain socket for communication instead of stdio")
33+
flags.StringVar(&result.pipePath, "pipe", "", "use a named pipe (Windows) or create a private Unix socket at <path>/socket, where <path> must not exist")
3434
flags.StringVar(&result.callbacks, "callbacks", "", "comma-separated list of FS callbacks and defaults to enable")
3535
flags.BoolVar(&result.caseSensitive, "useCaseSensitiveFileNames", osvfs.FS().CaseSensitivity().IsCaseSensitive(), "treat filesystem paths as case-sensitive")
3636
flags.BoolVar(&result.async, "async", false, "use JSON-RPC protocol instead of MessagePack (for async API)")

‎tsc/internal/api/server.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ type StdioServerOptions struct {
2121
Err io.Writer
2222
Cwd tspath.RootedDirectoryPath
2323
DefaultLibraryPath tspath.RootedDirectoryPath
24-
// PipePath, if set, listens on a named pipe (Windows) or Unix domain
25-
// socket instead of using In/Out for communication.
24+
// PipePath, if set, is the named pipe path on Windows. On Unix, it must
25+
// not exist and is created as a private directory containing "socket".
2626
PipePath string
2727
// Callbacks specifies which filesystem operations should be delegated
2828
// to the client (e.g., "readFile", "fileExists"). Empty means no callbacks.

‎tsc/internal/ipc/transport.go‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ipc
22

33
import (
4+
"errors"
45
"io"
56
"net"
67
)
@@ -16,16 +17,33 @@ type Transport interface {
1617
// PipeTransport accepts connections on a Unix domain socket or Windows named pipe.
1718
type PipeTransport struct {
1819
listener net.Listener
20+
cleanup func() error
1921
}
2022

21-
// NewPipeTransport creates a new transport listening on the given path.
22-
// On Unix, this creates a Unix domain socket. On Windows, this creates a named pipe.
23+
// NewPipeTransport creates a new pipe transport.
24+
// On Unix, path must not exist and is created as a private directory containing "socket".
25+
// On Windows, path is the named pipe path.
2326
func NewPipeTransport(path string) (*PipeTransport, error) {
24-
listener, err := newPipeListener(path)
27+
listener, cleanup, err := newPipeListener(path)
2528
if err != nil {
2629
return nil, err
2730
}
28-
return &PipeTransport{listener: listener}, nil
31+
return &PipeTransport{
32+
listener: listener,
33+
cleanup: cleanup,
34+
}, nil
35+
}
36+
37+
// NewPrivatePipeTransport creates a transport at a private, generated path.
38+
func NewPrivatePipeTransport(name string) (*PipeTransport, error) {
39+
listener, cleanup, err := newPrivatePipeListener(name)
40+
if err != nil {
41+
return nil, err
42+
}
43+
return &PipeTransport{
44+
listener: listener,
45+
cleanup: cleanup,
46+
}, nil
2947
}
3048

3149
// Accept implements Transport.
@@ -35,7 +53,11 @@ func (t *PipeTransport) Accept() (io.ReadWriteCloser, error) {
3553

3654
// Close implements Transport.
3755
func (t *PipeTransport) Close() error {
38-
return t.listener.Close()
56+
closeErr := t.listener.Close()
57+
if t.cleanup == nil || errors.Is(closeErr, net.ErrClosed) {
58+
return closeErr
59+
}
60+
return errors.Join(closeErr, t.cleanup())
3961
}
4062

4163
// Path returns the path of the pipe/socket.

‎tsc/internal/ipc/transport_unix.go‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,59 @@
33
package ipc
44

55
import (
6+
"errors"
7+
"fmt"
8+
"io/fs"
69
"net"
710
"os"
8-
"path"
11+
"path/filepath"
912
)
1013

11-
// newPipeListener creates a Unix domain socket listener.
12-
func newPipeListener(path string) (net.Listener, error) {
13-
// Remove any existing socket file
14-
_ = os.Remove(path) //nolint:forbidigo
15-
return net.Listen("unix", path)
14+
func newPipeListener(dir string) (net.Listener, func() error, error) {
15+
if err := os.Mkdir(dir, 0o700); err != nil { //nolint:forbidigo
16+
return nil, nil, fmt.Errorf("create private socket directory: %w", err)
17+
}
18+
return listenInPrivatePipeDirectory(dir)
19+
}
20+
21+
func newPrivatePipeListener(_ string) (net.Listener, func() error, error) {
22+
dir, err := os.MkdirTemp("", "tsgo-api-") //nolint:forbidigo
23+
if err != nil {
24+
return nil, nil, fmt.Errorf("create private socket directory: %w", err)
25+
}
26+
return listenInPrivatePipeDirectory(dir)
27+
}
28+
29+
func listenInPrivatePipeDirectory(dir string) (net.Listener, func() error, error) {
30+
socketPath := filepath.Join(dir, "socket") //nolint:forbidigo
31+
cleanup := func() error {
32+
socketErr := os.Remove(socketPath) //nolint:forbidigo
33+
if errors.Is(socketErr, fs.ErrNotExist) {
34+
socketErr = nil
35+
}
36+
dirErr := os.Remove(dir) //nolint:forbidigo
37+
if errors.Is(dirErr, fs.ErrNotExist) {
38+
dirErr = nil
39+
}
40+
return errors.Join(socketErr, dirErr)
41+
}
42+
43+
if err := os.Chmod(dir, 0o700); err != nil { //nolint:forbidigo
44+
return nil, nil, errors.Join(fmt.Errorf("set private socket directory permissions: %w", err), cleanup())
45+
}
46+
47+
listener, err := net.Listen("unix", socketPath)
48+
if err != nil {
49+
return nil, nil, errors.Join(fmt.Errorf("listen on private socket: %w", err), cleanup())
50+
}
51+
if err := os.Chmod(socketPath, 0o600); err != nil { //nolint:forbidigo
52+
return nil, nil, errors.Join(fmt.Errorf("set private socket permissions: %w", err), listener.Close(), cleanup())
53+
}
54+
return listener, cleanup, nil
1655
}
1756

1857
// GeneratePipePath returns a platform-appropriate pipe path for the given name.
1958
func GeneratePipePath(name string) string {
2059
//nolint:forbidigo
21-
return path.Join(os.TempDir(), name)
60+
return filepath.Join(os.TempDir(), name)
2261
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//go:build !windows
2+
3+
package ipc
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"net"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"syscall"
13+
"testing"
14+
"time"
15+
)
16+
17+
const privatePipeHelperEnv = "TSGO_PRIVATE_PIPE_HELPER"
18+
19+
func TestPipeTransportClosePreservesReplacement(t *testing.T) {
20+
t.Parallel()
21+
22+
transport, err := NewPrivatePipeTransport("tsgo-api-test")
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
socketPath := transport.Path()
27+
if closeErr := transport.Close(); closeErr != nil {
28+
t.Fatal(closeErr)
29+
}
30+
31+
replacement, err := NewPipeTransport(filepath.Dir(socketPath))
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
defer replacement.Close()
36+
37+
if closeErr := transport.Close(); !errors.Is(closeErr, net.ErrClosed) {
38+
t.Fatalf("second Close returned %v, want net.ErrClosed", closeErr)
39+
}
40+
conn, err := net.DialTimeout("unix", socketPath, time.Second)
41+
if err != nil {
42+
t.Fatalf("replacement socket is unreachable: %v", err)
43+
}
44+
defer conn.Close()
45+
}
46+
47+
func TestPipeTransportPermissions(t *testing.T) {
48+
t.Parallel()
49+
50+
if os.Getenv(privatePipeHelperEnv) == "1" {
51+
testPipeTransportPermissions(t)
52+
return
53+
}
54+
55+
cmd := exec.Command(os.Args[0], "-test.run=^TestPipeTransportPermissions$")
56+
cmd.Env = append(os.Environ(), privatePipeHelperEnv+"=1")
57+
if output, err := cmd.CombinedOutput(); err != nil {
58+
t.Fatalf("helper process failed: %v\n%s", err, output)
59+
}
60+
}
61+
62+
func testPipeTransportPermissions(t *testing.T) {
63+
syscall.Umask(0o002)
64+
65+
transport, err := NewPrivatePipeTransport("tsgo-api-test")
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
defer transport.Close()
70+
71+
socketPath := transport.Path()
72+
assertMode(t, filepath.Dir(socketPath), 0o700)
73+
assertMode(t, socketPath, 0o600)
74+
75+
explicitDir := filepath.Join(os.TempDir(), fmt.Sprintf("tsgo-api-%d", os.Getpid()))
76+
explicitTransport, err := NewPipeTransport(explicitDir)
77+
if err != nil {
78+
t.Fatal(err)
79+
}
80+
assertMode(t, explicitDir, 0o700)
81+
assertMode(t, filepath.Join(explicitDir, "socket"), 0o600)
82+
83+
if _, err := NewPipeTransport(explicitDir); err == nil {
84+
t.Fatal("NewPipeTransport succeeded with an existing directory")
85+
}
86+
if err := explicitTransport.Close(); err != nil {
87+
t.Fatal(err)
88+
}
89+
if _, err := os.Stat(explicitDir); !os.IsNotExist(err) {
90+
t.Fatalf("socket directory still exists after close: %v", err)
91+
}
92+
}
93+
94+
func assertMode(t *testing.T, path string, want os.FileMode) {
95+
t.Helper()
96+
info, err := os.Stat(path)
97+
if err != nil {
98+
t.Fatal(err)
99+
}
100+
if got := info.Mode().Perm(); got != want {
101+
t.Fatalf("%s mode = %04o, want %04o", path, got, want)
102+
}
103+
}

‎tsc/internal/ipc/transport_windows.go‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,27 @@
33
package ipc
44

55
import (
6+
"fmt"
67
"net"
78

89
"github.com/Microsoft/go-winio"
10+
"golang.org/x/sys/windows"
911
)
1012

1113
// newPipeListener creates a Windows named pipe listener.
12-
func newPipeListener(path string) (net.Listener, error) {
13-
return winio.ListenPipe(path, nil)
14+
func newPipeListener(path string) (net.Listener, func() error, error) {
15+
user, err := windows.GetCurrentProcessToken().GetTokenUser()
16+
if err != nil {
17+
return nil, nil, fmt.Errorf("get pipe owner SID: %w", err)
18+
}
19+
listener, err := winio.ListenPipe(path, &winio.PipeConfig{
20+
SecurityDescriptor: "D:P(A;;GA;;;" + user.User.Sid.String() + ")",
21+
})
22+
return listener, nil, err
23+
}
24+
25+
func newPrivatePipeListener(name string) (net.Listener, func() error, error) {
26+
return newPipeListener(GeneratePipePath(name))
1427
}
1528

1629
// GeneratePipePath returns a platform-appropriate pipe path for the given name.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//go:build windows
2+
3+
package ipc
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"testing"
9+
"time"
10+
11+
"github.com/Microsoft/go-winio"
12+
"golang.org/x/sys/windows"
13+
)
14+
15+
func TestPipeTransportPermissions(t *testing.T) {
16+
t.Parallel()
17+
18+
transport, err := NewPrivatePipeTransport(fmt.Sprintf("tsgo-api-test-%d", os.Getpid()))
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
defer transport.Close()
23+
24+
accepted := make(chan error, 1)
25+
done := make(chan struct{})
26+
defer close(done)
27+
go func() {
28+
server, acceptErr := transport.Accept()
29+
if acceptErr == nil {
30+
defer server.Close()
31+
}
32+
accepted <- acceptErr
33+
<-done
34+
}()
35+
36+
timeout := time.Second
37+
conn, err := winio.DialPipe(transport.Path(), &timeout)
38+
if err != nil {
39+
t.Fatalf("current user cannot connect: %v", err)
40+
}
41+
defer conn.Close()
42+
43+
select {
44+
case acceptErr := <-accepted:
45+
if acceptErr != nil {
46+
t.Fatalf("failed to accept current user connection: %v", acceptErr)
47+
}
48+
case <-time.After(timeout):
49+
t.Fatal("timed out accepting current user connection")
50+
}
51+
52+
handle := windows.Handle(conn.(interface{ Fd() uintptr }).Fd())
53+
sd, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
dacl, _, err := sd.DACL()
58+
if err != nil {
59+
t.Fatal(err)
60+
}
61+
if dacl == nil || dacl.AceCount != 1 {
62+
t.Fatalf("expected exactly one access-control entry: %s", sd.String())
63+
}
64+
user, err := windows.GetCurrentProcessToken().GetTokenUser()
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
// Windows maps generic all access to file all access and may abbreviate well-known SIDs.
69+
expected, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;" + user.User.Sid.String() + ")")
70+
if err != nil {
71+
t.Fatal(err)
72+
}
73+
if got, want := sd.String(), expected.String(); got != want {
74+
t.Fatalf("pipe security descriptor = %s, want %s", got, want)
75+
}
76+
}

0 commit comments

Comments
 (0)