[api] Fix named pipe / UDS creation (#64818)

This commit is contained in:
Andrew Branch authored and GitHub committed 2026-10-14 22:32:31 +00:00
1 parent 76c4f5078b
commit 257e87406c
11 files changed
+285 -30

No files matched your search

+6 -1
View File
@@ -14,6 +14,7 @@ import {
type ChildProcess,
spawn,
} from "node:child_process";
import { randomBytes } from "node:crypto";
import {
closeSync,
openSync,
@@ -86,6 +87,10 @@ process.on("exit", () => {
liveChildren.clear();
});
export function createPipePath(): string {
return `\\\\.\\pipe\\tsgo-sync-${process.pid}-${randomBytes(16).toString("hex")}`;
}
/**
* SyncRpcChannel – drop-in replacement for the native libsyncrpc class.
*
@@ -147,7 +152,7 @@ export class SyncRpcChannel {
// we create a Windows named pipe path, pass it to the child
// via --pipe, and open it with fs.openSync which returns a
// real C-runtime fd backed by a proper HANDLE.
const pipePath = `\\\\.\\pipe\\tsgo-sync-${process.pid}-${Date.now()}`;
const pipePath = createPipePath();
this.child = spawn(exe, [...args, "--pipe", pipePath], {
stdio: ["ignore", "ignore", "inherit"],
});
+1 -1
View File
@@ -407,7 +407,7 @@ const customStructures: Structure[] = [
name: "pipe",
type: { kind: "base", name: "string" },
optional: true,
documentation: "Optional path to use for the named pipe or Unix domain socket. If not provided, a unique path will be generated.",
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.",
},
],
documentation: "Parameters for the initializeAPISession request.",
+1 -1
View File
@@ -30,7 +30,7 @@ func parseAPIFlags(args []string) (apiFlags, error) {
flags := flag.NewFlagSet("api", flag.ContinueOnError)
result := apiFlags{}
flags.StringVar(&result.cwd, "cwd", core.Must(os.Getwd()), "current working directory")
flags.StringVar(&result.pipePath, "pipe", "", "use named pipe or Unix domain socket for communication instead of stdio")
flags.StringVar(&result.pipePath, "pipe", "", "use a named pipe (Windows) or create a private Unix socket at <path>/socket, where <path> must not exist")
flags.StringVar(&result.callbacks, "callbacks", "", "comma-separated list of FS callbacks and defaults to enable")
flags.BoolVar(&result.caseSensitive, "useCaseSensitiveFileNames", osvfs.FS().CaseSensitivity().IsCaseSensitive(), "treat filesystem paths as case-sensitive")
flags.BoolVar(&result.async, "async", false, "use JSON-RPC protocol instead of MessagePack (for async API)")
+2 -2
View File
@@ -21,8 +21,8 @@ type StdioServerOptions struct {
Err io.Writer
Cwd tspath.RootedDirectoryPath
DefaultLibraryPath tspath.RootedDirectoryPath
// PipePath, if set, listens on a named pipe (Windows) or Unix domain
// socket instead of using In/Out for communication.
// PipePath, if set, is the named pipe path on Windows. On Unix, it must
// not exist and is created as a private directory containing "socket".
PipePath string
// Callbacks specifies which filesystem operations should be delegated
// to the client (e.g., "readFile", "fileExists"). Empty means no callbacks.
+27 -5
View File
@@ -1,6 +1,7 @@
package ipc
import (
"errors"
"io"
"net"
)
@@ -16,16 +17,33 @@ type Transport interface {
// PipeTransport accepts connections on a Unix domain socket or Windows named pipe.
type PipeTransport struct {
listener net.Listener
cleanup func() error
}
// NewPipeTransport creates a new transport listening on the given path.
// On Unix, this creates a Unix domain socket. On Windows, this creates a named pipe.
// NewPipeTransport creates a new pipe transport.
// On Unix, path must not exist and is created as a private directory containing "socket".
// On Windows, path is the named pipe path.
func NewPipeTransport(path string) (*PipeTransport, error) {
listener, err := newPipeListener(path)
listener, cleanup, err := newPipeListener(path)
if err != nil {
return nil, err
}
return &PipeTransport{listener: listener}, nil
return &PipeTransport{
listener: listener,
cleanup: cleanup,
}, nil
}
// NewPrivatePipeTransport creates a transport at a private, generated path.
func NewPrivatePipeTransport(name string) (*PipeTransport, error) {
listener, cleanup, err := newPrivatePipeListener(name)
if err != nil {
return nil, err
}
return &PipeTransport{
listener: listener,
cleanup: cleanup,
}, nil
}
// Accept implements Transport.
@@ -35,7 +53,11 @@ func (t *PipeTransport) Accept() (io.ReadWriteCloser, error) {
// Close implements Transport.
func (t *PipeTransport) Close() error {
return t.listener.Close()
closeErr := t.listener.Close()
if t.cleanup == nil || errors.Is(closeErr, net.ErrClosed) {
return closeErr
}
return errors.Join(closeErr, t.cleanup())
}
// Path returns the path of the pipe/socket.
+46 -7
View File
@@ -3,20 +3,59 @@
package ipc
import (
"errors"
"fmt"
"io/fs"
"net"
"os"
"path"
"path/filepath"
)
// newPipeListener creates a Unix domain socket listener.
func newPipeListener(path string) (net.Listener, error) {
// Remove any existing socket file
_ = os.Remove(path) //nolint:forbidigo
return net.Listen("unix", path)
func newPipeListener(dir string) (net.Listener, func() error, error) {
if err := os.Mkdir(dir, 0o700); err != nil { //nolint:forbidigo
return nil, nil, fmt.Errorf("create private socket directory: %w", err)
}
return listenInPrivatePipeDirectory(dir)
}
func newPrivatePipeListener(_ string) (net.Listener, func() error, error) {
dir, err := os.MkdirTemp("", "tsgo-api-") //nolint:forbidigo
if err != nil {
return nil, nil, fmt.Errorf("create private socket directory: %w", err)
}
return listenInPrivatePipeDirectory(dir)
}
func listenInPrivatePipeDirectory(dir string) (net.Listener, func() error, error) {
socketPath := filepath.Join(dir, "socket") //nolint:forbidigo
cleanup := func() error {
socketErr := os.Remove(socketPath) //nolint:forbidigo
if errors.Is(socketErr, fs.ErrNotExist) {
socketErr = nil
}
dirErr := os.Remove(dir) //nolint:forbidigo
if errors.Is(dirErr, fs.ErrNotExist) {
dirErr = nil
}
return errors.Join(socketErr, dirErr)
}
if err := os.Chmod(dir, 0o700); err != nil { //nolint:forbidigo
return nil, nil, errors.Join(fmt.Errorf("set private socket directory permissions: %w", err), cleanup())
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, nil, errors.Join(fmt.Errorf("listen on private socket: %w", err), cleanup())
}
if err := os.Chmod(socketPath, 0o600); err != nil { //nolint:forbidigo
return nil, nil, errors.Join(fmt.Errorf("set private socket permissions: %w", err), listener.Close(), cleanup())
}
return listener, cleanup, nil
}
// GeneratePipePath returns a platform-appropriate pipe path for the given name.
func GeneratePipePath(name string) string {
//nolint:forbidigo
return path.Join(os.TempDir(), name)
return filepath.Join(os.TempDir(), name)
}
+103
View File
@@ -0,0 +1,103 @@
//go:build !windows
package ipc
import (
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
)
const privatePipeHelperEnv = "TSGO_PRIVATE_PIPE_HELPER"
func TestPipeTransportClosePreservesReplacement(t *testing.T) {
t.Parallel()
transport, err := NewPrivatePipeTransport("tsgo-api-test")
if err != nil {
t.Fatal(err)
}
socketPath := transport.Path()
if closeErr := transport.Close(); closeErr != nil {
t.Fatal(closeErr)
}
replacement, err := NewPipeTransport(filepath.Dir(socketPath))
if err != nil {
t.Fatal(err)
}
defer replacement.Close()
if closeErr := transport.Close(); !errors.Is(closeErr, net.ErrClosed) {
t.Fatalf("second Close returned %v, want net.ErrClosed", closeErr)
}
conn, err := net.DialTimeout("unix", socketPath, time.Second)
if err != nil {
t.Fatalf("replacement socket is unreachable: %v", err)
}
defer conn.Close()
}
func TestPipeTransportPermissions(t *testing.T) {
t.Parallel()
if os.Getenv(privatePipeHelperEnv) == "1" {
testPipeTransportPermissions(t)
return
}
cmd := exec.Command(os.Args[0], "-test.run=^TestPipeTransportPermissions$")
cmd.Env = append(os.Environ(), privatePipeHelperEnv+"=1")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("helper process failed: %v\n%s", err, output)
}
}
func testPipeTransportPermissions(t *testing.T) {
syscall.Umask(0o002)
transport, err := NewPrivatePipeTransport("tsgo-api-test")
if err != nil {
t.Fatal(err)
}
defer transport.Close()
socketPath := transport.Path()
assertMode(t, filepath.Dir(socketPath), 0o700)
assertMode(t, socketPath, 0o600)
explicitDir := filepath.Join(os.TempDir(), fmt.Sprintf("tsgo-api-%d", os.Getpid()))
explicitTransport, err := NewPipeTransport(explicitDir)
if err != nil {
t.Fatal(err)
}
assertMode(t, explicitDir, 0o700)
assertMode(t, filepath.Join(explicitDir, "socket"), 0o600)
if _, err := NewPipeTransport(explicitDir); err == nil {
t.Fatal("NewPipeTransport succeeded with an existing directory")
}
if err := explicitTransport.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(explicitDir); !os.IsNotExist(err) {
t.Fatalf("socket directory still exists after close: %v", err)
}
}
func assertMode(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("%s mode = %04o, want %04o", path, got, want)
}
}
+15 -2
View File
@@ -3,14 +3,27 @@
package ipc
import (
"fmt"
"net"
"github.com/Microsoft/go-winio"
"golang.org/x/sys/windows"
)
// newPipeListener creates a Windows named pipe listener.
func newPipeListener(path string) (net.Listener, error) {
return winio.ListenPipe(path, nil)
func newPipeListener(path string) (net.Listener, func() error, error) {
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
return nil, nil, fmt.Errorf("get pipe owner SID: %w", err)
}
listener, err := winio.ListenPipe(path, &winio.PipeConfig{
SecurityDescriptor: "D:P(A;;GA;;;" + user.User.Sid.String() + ")",
})
return listener, nil, err
}
func newPrivatePipeListener(name string) (net.Listener, func() error, error) {
return newPipeListener(GeneratePipePath(name))
}
// GeneratePipePath returns a platform-appropriate pipe path for the given name.
@@ -0,0 +1,76 @@
//go:build windows
package ipc
import (
"fmt"
"os"
"testing"
"time"
"github.com/Microsoft/go-winio"
"golang.org/x/sys/windows"
)
func TestPipeTransportPermissions(t *testing.T) {
t.Parallel()
transport, err := NewPrivatePipeTransport(fmt.Sprintf("tsgo-api-test-%d", os.Getpid()))
if err != nil {
t.Fatal(err)
}
defer transport.Close()
accepted := make(chan error, 1)
done := make(chan struct{})
defer close(done)
go func() {
server, acceptErr := transport.Accept()
if acceptErr == nil {
defer server.Close()
}
accepted <- acceptErr
<-done
}()
timeout := time.Second
conn, err := winio.DialPipe(transport.Path(), &timeout)
if err != nil {
t.Fatalf("current user cannot connect: %v", err)
}
defer conn.Close()
select {
case acceptErr := <-accepted:
if acceptErr != nil {
t.Fatalf("failed to accept current user connection: %v", acceptErr)
}
case <-time.After(timeout):
t.Fatal("timed out accepting current user connection")
}
handle := windows.Handle(conn.(interface{ Fd() uintptr }).Fd())
sd, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Fatal(err)
}
dacl, _, err := sd.DACL()
if err != nil {
t.Fatal(err)
}
if dacl == nil || dacl.AceCount != 1 {
t.Fatalf("expected exactly one access-control entry: %s", sd.String())
}
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
t.Fatal(err)
}
// Windows maps generic all access to file all access and may abbreviate well-known SIDs.
expected, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;" + user.User.Sid.String() + ")")
if err != nil {
t.Fatal(err)
}
if got, want := sd.String(), expected.String(); got != want {
t.Fatalf("pipe security descriptor = %s, want %s", got, want)
}
}
+1 -1
View File
@@ -9057,7 +9057,7 @@ func (s *ProfileResult) UnmarshalJSONFrom(dec *json.Decoder) error {
// Parameters for the initializeAPISession request.
type InitializeAPISessionParams struct {
// Optional path to use for the named pipe or Unix domain socket. If not provided, a unique path will be generated.
// 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.
Pipe *string `json:"pipe,omitzero"`
}
+7 -10
View File
@@ -2341,15 +2341,13 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto
apiSession := api.NewLSPSession(s.session, nil)
// Use provided pipe path or generate a unique one
var pipePath string
var transport *ipc.PipeTransport
var err error
if params.Pipe != nil && *params.Pipe != "" {
pipePath = *params.Pipe
transport, err = ipc.NewPipeTransport(*params.Pipe)
} else {
pipePath = s.generateAPIPipePath()
transport, err = ipc.NewPrivatePipeTransport(s.generateAPIPipeName())
}
transport, err := ipc.NewPipeTransport(pipePath)
if err != nil {
return nil, fmt.Errorf("failed to create API transport: %w", err)
}
@@ -2404,15 +2402,14 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto
return &lsproto.InitializeAPISessionResult{
SessionId: apiSession.ID(),
Pipe: pipePath,
Pipe: transport.Path(),
}, nil
}
func (s *Server) generateAPIPipePath() string {
// Generate a high-entropy path using time and random source
func (s *Server) generateAPIPipeName() string {
now := time.Now().UnixNano()
rnd := rand.Uint64()
return ipc.GeneratePipePath(fmt.Sprintf("tsgo-api-%x-%x", now, rnd))
return fmt.Sprintf("tsgo-api-%x-%x", now, rnd)
}
func (s *Server) removeAPISession(id string) {