mirror of
https://github.com/microsoft/TypeScript.git
synced 2026-10-28 16:36:04 +08:00
[api] Fix named pipe / UDS creation (#64818)
This commit is contained in:
1 parent
76c4f5078b
commit
257e87406c
11 files changed
+285
-30
No files matched your search
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user