diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 208313dac3502..8697a886f8d88 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -245,7 +245,8 @@ export class API implements FormatDiagnosticsHo /** * Create an API instance from an existing LSP connection's API session. - * Use this when connecting to an API pipe provided by an LSP server via custom/initializeAPISession. + * Use this with the pipe returned by custom/initializeAPISession. The + * requested session transport must match the async or sync API variant. */ static async fromLSPConnection(options: LSPConnectionOptions): Promise> { const api = new API(options); diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 7e67f739a686c..e323092c7b64f 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -6,7 +6,12 @@ import getExePath from "#getExePath"; import type { FileSystem } from "./fs.ts"; export interface ClientSocketOptions { - /** Path to the Unix domain socket or Windows named pipe for API communication */ + /** + * Path returned by custom/initializeAPISession. + * + * The async API connects to a Unix domain socket or Windows named pipe. + * The sync API connects to a FIFO pair on Unix or a named pipe on Windows. + */ pipe: string; /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ maxResponseBytesPerPage?: number; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 4ec27f4805f98..f47538e456ff4 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -264,7 +264,8 @@ export class API implements FormatDiagnosticsHo /** * Create an API instance from an existing LSP connection's API session. - * Use this when connecting to an API pipe provided by an LSP server via custom/initializeAPISession. + * Use this with the pipe returned by custom/initializeAPISession. The + * requested session transport must match the async or sync API variant. */ static get fromLSPConnection(): { (options: LSPConnectionOptions): API; diff --git a/packages/typescript/src/api/sync/client.ts b/packages/typescript/src/api/sync/client.ts index b174784d7006e..a06281c937528 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -1,4 +1,7 @@ -import { fsCallbackNames } from "../fs.ts"; +import { + type FileSystem, + fsCallbackNames, +} from "../fs.ts"; import { type ClientOptions, type ClientSocketOptions, @@ -32,39 +35,48 @@ export class Client { private maxResponseBytesPerPage: number | undefined; constructor(options: ClientOptions) { - if (!isSpawnOptions(options)) { - throw new Error("Socket connections are not yet supported in the sync client"); - } + let enabledCallbacks: (typeof fsCallbackNames[number])[] = []; + let channel: SyncRpcChannel; + let fs: FileSystem | undefined; + let collectTiming = false; - const args = getAPIProcessArgs(options, false); this.maxResponseBytesPerPage = options.maxResponseBytesPerPage; - - // Enable virtual FS callbacks for each provided FS function - const enabledCallbacks: (typeof fsCallbackNames[number])[] = []; - if (options.fs) { - for (const name of fsCallbackNames) { - if (options.fs[name]) { - enabledCallbacks.push(name); + if (isSpawnOptions(options)) { + const args = getAPIProcessArgs(options, false); + + // Enable virtual FS callbacks for each provided FS function + if (options.fs) { + for (const name of fsCallbackNames) { + if (options.fs[name]) { + enabledCallbacks.push(name); + } } } + if (enabledCallbacks.length > 0) { + args.push(`--callbacks=${enabledCallbacks.join(",")}`); + } + + collectTiming = options.collectTiming ?? false; + fs = options.fs; + channel = new SyncRpcChannel({ + exe: resolveExePath(options), + args, + }, collectTiming); } - if (enabledCallbacks.length > 0) { - args.push(`--callbacks=${enabledCallbacks.join(",")}`); + else { + channel = new SyncRpcChannel({ pipe: options.pipe }, collectTiming); } - - const collectTiming = options.collectTiming ?? false; if (collectTiming) { this.timing = new TimingCollector(); } - const channel = new SyncRpcChannel(resolveExePath(options), args, collectTiming); this.channel = channel; - if (options.fs) { + if (fs) { for (const name of enabledCallbacks) { if (name === "writeFile") { - if (!options.fs.writeFile) continue; - const callback = options.fs.writeFile; + if (!fs.writeFile) continue; + const callback = fs.writeFile; channel.registerCallback(name, (_, arg) => { const { path, data } = JSON.parse(arg); @@ -75,7 +87,7 @@ export class Client { continue; } - const callback = options.fs[name]!; + const callback = fs[name]!; channel.registerCallback(name, (_, arg) => { const result = callback(JSON.parse(arg)); if (name === "readFile") { diff --git a/packages/typescript/src/api/syncChannel.ts b/packages/typescript/src/api/syncChannel.ts index 122203c85e38e..927fb954e7f46 100644 --- a/packages/typescript/src/api/syncChannel.ts +++ b/packages/typescript/src/api/syncChannel.ts @@ -1,13 +1,15 @@ /** * Pure JS replacement for @typescript/libsyncrpc. * - * Spawns a child process and communicates with it synchronously over - * stdin/stdout pipes using the same MessagePack-based tuple protocol: + * Communicates synchronously with an API server using a MessagePack-based + * tuple protocol: * [MessageType (u8), method (bin), payload (bin)] * + * Spawned servers use stdin/stdout on Unix and a named pipe on Windows. + * Existing servers use two POSIX FIFOs on Unix and a named pipe on Windows. + * * Synchronous I/O is achieved by calling fs.readSync / fs.writeSync - * directly on the pipe file descriptors obtained from the spawned - * ChildProcess. + * directly on the pipe file descriptors. */ import { @@ -16,8 +18,10 @@ import { } from "node:child_process"; import { closeSync, + constants, openSync, readSync, + writeFileSync, writeSync, } from "node:fs"; import type { @@ -87,23 +91,14 @@ process.on("exit", () => { }); /** - * SyncRpcChannel – drop-in replacement for the native libsyncrpc class. - * - * API surface intentionally matches the original: - * - constructor(exe, args) - * - requestSync(method, payload): string - * - requestBinarySync(method, payload): Uint8Array - * - registerCallback(name, cb) - * - close() - * * The protocol is unversioned; both sides (this JS channel and the Go - * child process) must be built from the same tree. + * server must be built from the same tree. * * This class is **not** thread-safe. All calls must originate from a * single thread — do not share an instance across worker threads. */ export class SyncRpcChannel { - private child: ChildProcess; + private child: ChildProcess | undefined; private readFd: number; private writeFd: number; private pipeFd: number | undefined; @@ -137,85 +132,135 @@ export class SyncRpcChannel { // Write buffer – assembles entire tuples for a single writeSync. private writeBuf = Buffer.allocUnsafe(65536); - constructor(exe: string, args: string[], collectTiming = false) { + constructor(options: { exe: string; args: string[]; } | { pipe: string; }, collectTiming = false) { this.collectTiming = collectTiming; - const isWindows = process.platform === "win32"; - - if (isWindows) { - // On Windows, libuv pipe handles don't expose POSIX fds, so - // readSync/writeSync can't be used on stdio pipes. Instead, - // 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()}`; - this.child = spawn(exe, [...args, "--pipe", pipePath], { - stdio: ["ignore", "ignore", "inherit"], - }); + if ("exe" in options) { + if (process.platform === "win32") { + const pipePath = `\\\\.\\pipe\\tsgo-sync-${process.pid}-${Date.now()}`; + this.child = spawn(options.exe, [...options.args, "--transport", `sync=${pipePath}`], { + stdio: ["ignore", "ignore", "inherit"], + }); + const fd = this.openPipe(pipePath, this.child); + this.readFd = fd; + this.writeFd = fd; + this.pipeFd = fd; + } + else { + this.child = spawn(options.exe, options.args, { + stdio: ["pipe", "pipe", "inherit"], + }); - // Retry openSync until the child creates the named pipe. - let fd: number | undefined; - for (let i = 0; i < 500; i++) { - try { - fd = openSync(pipePath, "r+"); - break; - } - catch { - if (this.child.exitCode !== null) { - throw new Error( - `Child process exited with code ${this.child.exitCode} before pipe was ready`, - ); - } - Atomics.wait(sleepBuf, 0, 0, 10); + const stdout = this.child.stdout! as StdoutWithHandle; + const stdin = this.child.stdin! as StdinWithHandle; + + this.readFd = stdout._handle.fd; + this.writeFd = stdin._handle.fd; + + if (typeof this.readFd !== "number" || this.readFd < 0 || typeof this.writeFd !== "number" || this.writeFd < 0) { + stdout.destroy(); + stdin.destroy(); + this.child.kill(); + throw new Error( + "SyncRpcChannel: could not obtain pipe file descriptors.", + ); } + + // Set the pipe handles to blocking mode. Under node --test's + // process isolation, pipes are created in non-blocking mode + // (for the IPC channel). This causes readSync/writeSync to get + // EAGAIN, requiring costly 1ms sleeps per retry. Setting + // blocking mode ensures readSync blocks properly until data + // arrives, matching the behavior of the native libsyncrpc. + stdout._handle.setBlocking?.(true); + stdin._handle.setBlocking?.(true); + + // Prevent Node's event-loop from reading stdout or keeping the + // process alive - we will use fs.readSync exclusively. + stdout.pause(); + stdout.unref(); + stdin.unref(); } - if (fd === undefined) { - this.child.kill(); - throw new Error("SyncRpcChannel: timed out connecting to named pipe"); - } + + liveChildren.add(this.child); + this.child.unref(); + } + else if (process.platform === "win32") { + const fd = this.openPipe(options.pipe); this.readFd = fd; this.writeFd = fd; this.pipeFd = fd; } else { - // POSIX: use stdio pipe file descriptors directly. - this.child = spawn(exe, args, { - stdio: ["pipe", "pipe", "inherit"], - }); + const { readFd, writeFd } = this.openFIFOs(options.pipe); + this.readFd = readFd; + this.writeFd = writeFd; + } + } - const stdout = this.child.stdout! as StdoutWithHandle; - const stdin = this.child.stdin! as StdinWithHandle; + private openPipe(pipePath: string, child?: ChildProcess): number { + for (let i = 0; i < 500; i++) { + try { + return openSync(pipePath, "r+"); + } + catch { + if (child?.exitCode !== null && child?.exitCode !== undefined) { + throw new Error( + `Child process exited with code ${child.exitCode} before pipe was ready`, + ); + } + Atomics.wait(sleepBuf, 0, 0, 10); + } + } + child?.kill(); + throw new Error("SyncRpcChannel: timed out connecting to named pipe"); + } - this.readFd = stdout._handle.fd; - this.writeFd = stdin._handle.fd; + private openFIFOs(prefix: string): { readFd: number; writeFd: number; } { + const outPath = prefix + ".out"; + const inPath = prefix + ".in"; + let probeReadFd: number | undefined; + let probeWriteFd: number | undefined; + try { + for (let i = 0; i < 500; i++) { + try { + probeReadFd = openSync(outPath, constants.O_RDONLY | constants.O_NONBLOCK); + break; + } + catch { + Atomics.wait(sleepBuf, 0, 0, 10); + } + } + if (probeReadFd === undefined) { + throw new Error("SyncRpcChannel: timed out connecting to FIFOs"); + } - if (typeof this.readFd !== "number" || this.readFd < 0 || typeof this.writeFd !== "number" || this.writeFd < 0) { - stdout.destroy(); - stdin.destroy(); - this.child.kill(); - throw new Error( - "SyncRpcChannel: could not obtain pipe file descriptors.", - ); + for (let i = 0; i < 500; i++) { + try { + probeWriteFd = openSync(inPath, constants.O_WRONLY | constants.O_NONBLOCK); + break; + } + catch { + Atomics.wait(sleepBuf, 0, 0, 10); + } + } + if (probeWriteFd === undefined) { + throw new Error("SyncRpcChannel: timed out connecting to FIFOs"); } - // Set the pipe handles to blocking mode. Under node --test's - // process isolation, pipes are created in non-blocking mode - // (for the IPC channel). This causes readSync/writeSync to get - // EAGAIN, requiring costly 1ms sleeps per retry. Setting - // blocking mode ensures readSync blocks properly until data - // arrives, matching the behavior of the native libsyncrpc. - stdout._handle.setBlocking?.(true); - stdin._handle.setBlocking?.(true); - - // Prevent Node's event-loop from reading stdout or keeping the - // process alive – we will use fs.readSync exclusively. - stdout.pause(); - stdout.unref(); - stdin.unref(); + writeFileSync(prefix + ".ready", "", { + flag: "wx", + mode: 0o600, + }); + const readFd = probeReadFd; + const writeFd = probeWriteFd; + probeReadFd = undefined; + probeWriteFd = undefined; + return { readFd, writeFd }; + } + finally { + if (probeReadFd !== undefined) closeSync(probeReadFd); + if (probeWriteFd !== undefined) closeSync(probeWriteFd); } - - // Track for auto-cleanup on process exit. - liveChildren.add(this.child); - this.child.unref(); } // ── Public API ────────────────────────────────────────────────── @@ -244,19 +289,27 @@ export class SyncRpcChannel { this.callbacks.set(name, callback); } - /** Kill the child process and release resources. */ + /** Release resources and kill the server if this channel spawned it. */ close(): void { try { - liveChildren.delete(this.child); + if (this.child) { + liveChildren.delete(this.child); + } if (this.pipeFd !== undefined) { closeSync(this.pipeFd); this.pipeFd = undefined; } - // Destroy the stdio streams so that their pipe handles are closed - // and no longer prevent the event loop from draining. - this.child.stdout?.destroy(); - this.child.stdin?.destroy(); - this.child.kill(); + else if (this.child) { + // Destroy the stdio streams so that their pipe handles are closed + // and no longer prevent the event loop from draining. + this.child.stdout?.destroy(); + this.child.stdin?.destroy(); + } + else { + if (this.readFd >= 0) closeSync(this.readFd); + if (this.writeFd >= 0) closeSync(this.writeFd); + } + this.child?.kill(); this.readFd = -1; this.writeFd = -1; } @@ -495,8 +548,11 @@ export class SyncRpcChannel { // ── Low-level synchronous I/O ─────────────────────────────────── - /** Build an EOF error with the child's exit code/signal if available. */ + /** Build an EOF error with the child process status, if this channel owns one. */ private eofError(): Error { + if (!this.child) { + return new Error("Unexpected EOF while reading from API server"); + } const code = this.child.exitCode; const signal = this.child.signalCode; const detail = signal ? `killed by signal ${signal}` : code !== null ? `exited with code ${code}` : "unknown reason"; diff --git a/packages/typescript/test/sync/connection.test.ts b/packages/typescript/test/sync/connection.test.ts new file mode 100644 index 0000000000000..34dbabf6bdf63 --- /dev/null +++ b/packages/typescript/test/sync/connection.test.ts @@ -0,0 +1,227 @@ +import getExePath from "#getExePath"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "#vscode-jsonrpc/node"; +import { API } from "@typescript/typescript/unstable/sync"; +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { + closeSync, + constants, + existsSync, + openSync, + readFileSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { + fileURLToPath, + pathToFileURL, +} from "node:url"; + +async function waitForRemoved(path: string): Promise { + for (let i = 0; i < 100 && existsSync(path); i++) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + assert.equal(existsSync(path), false); +} + +async function waitForCreated(path: string): Promise { + for (let i = 0; i < 100 && !existsSync(path); i++) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + assert.equal(existsSync(path), true); +} + +async function waitForReplaced(path: string, inode: number): Promise { + for (let i = 0; i < 100; i++) { + try { + if (statSync(path).ino !== inode) return; + } + catch { + // The server removes stale endpoints before recreating them. + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + assert.notEqual(statSync(path).ino, inode); +} + +test("connects synchronously to an existing API server", { timeout: 10_000 }, async () => { + const endpoint = process.platform === "win32" + ? `\\\\.\\pipe\\tsgo-api-test-${randomUUID()}` + : path.join(tmpdir(), `tsgo-api-test-${randomUUID()}`); + const child = spawn(getExePath(), ["--api", "--transport", `sync=${endpoint}`], { + stdio: ["ignore", "ignore", "pipe"], + }); + const childExit = once(child, "exit"); + + try { + { + using api = new API({ pipe: endpoint }); + const commandLine = api.parseCommandLine(["--strict"]); + assert.equal(commandLine.options.strict, true); + } + const [exitCode] = await childExit; + assert.equal(exitCode, 0); + if (process.platform !== "win32") { + assert.equal(existsSync(endpoint + ".in"), false); + assert.equal(existsSync(endpoint + ".out"), false); + } + } + finally { + if (child.exitCode === null) { + child.kill(); + } + await childExit; + } +}); + +test("an unconnected synchronous API server can shut down", { + timeout: 10_000, + skip: process.platform === "win32", +}, async () => { + const endpoint = path.join(tmpdir(), `tsgo-api-test-${randomUUID()}`); + const child = spawn(getExePath(), ["--api", "--transport", `sync=${endpoint}`], { + stdio: ["ignore", "ignore", "pipe"], + }); + const childExit = once(child, "exit"); + + try { + await waitForCreated(endpoint + ".in"); + child.kill(); + await childExit; + await waitForRemoved(endpoint + ".in"); + await waitForRemoved(endpoint + ".out"); + } + finally { + if (child.exitCode === null) { + child.kill(); + } + await childExit; + } +}); + +test("a synchronous API server can replace stale FIFOs", { + timeout: 10_000, + skip: process.platform === "win32", +}, async () => { + const endpoint = path.join(tmpdir(), `tsgo-api-test-${randomUUID()}`); + const firstChild = spawn(getExePath(), ["--api", "--transport", `sync=${endpoint}`], { + stdio: ["ignore", "ignore", "pipe"], + }); + const firstExit = once(firstChild, "exit"); + let staleFd: number | undefined; + + try { + await waitForCreated(endpoint + ".in"); + firstChild.kill("SIGKILL"); + await firstExit; + assert.equal(existsSync(endpoint + ".in"), true); + assert.equal(existsSync(endpoint + ".out"), true); + // Keep the stale inode alive so the filesystem cannot reuse its number + // for the replacement FIFO before waitForReplaced observes it. + staleFd = openSync(endpoint + ".in", constants.O_RDWR | constants.O_NONBLOCK); + const staleInode = statSync(endpoint + ".in").ino; + + const secondChild = spawn(getExePath(), ["--api", "--transport", `sync=${endpoint}`], { + stdio: ["ignore", "ignore", "pipe"], + }); + const secondExit = once(secondChild, "exit"); + let stderr = ""; + secondChild.stderr.setEncoding("utf8"); + secondChild.stderr.on("data", chunk => stderr += chunk); + try { + await waitForReplaced(endpoint + ".in", staleInode); + { + using api = new API({ pipe: endpoint }); + assert.equal(api.parseCommandLine(["--strict"]).options.strict, true); + } + const [exitCode] = await secondExit; + assert.equal(exitCode, 0, stderr); + } + finally { + if (secondChild.exitCode === null) { + secondChild.kill(); + } + await secondExit; + } + } + finally { + if (staleFd !== undefined) { + closeSync(staleFd); + } + if (firstChild.exitCode === null) { + firstChild.kill("SIGKILL"); + } + await firstExit; + } +}); + +test("connects synchronously to an API session in an existing LSP server", { timeout: 10_000 }, async () => { + const child = spawn(getExePath(), ["--lsp", "--stdio"], { + stdio: ["pipe", "pipe", "pipe"], + }); + const childExit = once(child, "exit"); + const connection = createMessageConnection( + new StreamMessageReader(child.stdout), + new StreamMessageWriter(child.stdin), + ); + connection.listen(); + + try { + await connection.sendRequest("initialize", { + processId: process.pid, + rootUri: null, + capabilities: {}, + }); + connection.sendNotification("initialized", {}); + const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); + const fileName = path.join(repoRoot, "tsc/testdata/fixtures/compiler/program.ts"); + connection.sendNotification("textDocument/didOpen", { + textDocument: { + uri: pathToFileURL(fileName).href, + languageId: "typescript", + version: 1, + text: readFileSync(fileName, "utf8"), + }, + }); + + const { pipe } = await connection.sendRequest<{ sessionId: string; pipe: string; }>( + "custom/initializeAPISession", + { synchronous: true }, + ); + const api = API.fromLSPConnection({ pipe }); + try { + const commandLine = api.parseCommandLine(["--strict"]); + assert.equal(commandLine.options.strict, true); + const snapshot = api.updateSnapshot(); + const sourceFile = snapshot.getProjects()[0].program.getSourceFile("program.ts"); + assert.equal(sourceFile?.fileName.endsWith("/program.ts"), true); + snapshot.dispose(); + + await connection.sendRequest("shutdown"); + connection.sendNotification("exit"); + await childExit; + if (process.platform !== "win32") { + await waitForRemoved(pipe + ".in"); + await waitForRemoved(pipe + ".out"); + } + } + finally { + api.close(); + } + } + finally { + connection.dispose(); + if (child.exitCode === null) { + child.kill(); + } + await childExit; + } +}); diff --git a/packages/vscode-typescript/src/client.ts b/packages/vscode-typescript/src/client.ts index b8bd17df8540a..e7b7701ad6545 100644 --- a/packages/vscode-typescript/src/client.ts +++ b/packages/vscode-typescript/src/client.ts @@ -429,11 +429,11 @@ export class Client implements vscode.Disposable { * Initialize an API session and return the socket path for connecting. * This allows other extensions to get a direct connection to the API server. */ - async initializeAPISession(pipe?: string): Promise<{ sessionId: string; pipe: string; }> { + async initializeAPISession(pipe?: string, synchronous?: boolean): Promise<{ sessionId: string; pipe: string; }> { if (!this.client) { throw new Error(vscode.l10n.t("Language client is not initialized")); } - return this.client.sendRequest<{ sessionId: string; pipe: string; }>("custom/initializeAPISession", { pipe }); + return this.client.sendRequest<{ sessionId: string; pipe: string; }>("custom/initializeAPISession", { pipe, synchronous }); } /** diff --git a/packages/vscode-typescript/src/extension.ts b/packages/vscode-typescript/src/extension.ts index 7bff6514e4397..467abccc2c7cf 100644 --- a/packages/vscode-typescript/src/extension.ts +++ b/packages/vscode-typescript/src/extension.ts @@ -26,7 +26,7 @@ import assert from "node:assert"; export interface ExtensionAPI { onLanguageServerInitialized: vscode.Event; - initializeAPIConnection(pipe?: string): Promise; + initializeAPIConnection(pipe?: string, synchronous?: boolean): Promise; registerContentMappers(contributorId: string, contributions: readonly ContentMapperContribution[]): vscode.Disposable; } @@ -79,8 +79,8 @@ export async function activate(context: vscode.ExtensionContext): Promise { - return sessionManager.initializeAPIConnection(pipe); + async initializeAPIConnection(pipe?: string, synchronous?: boolean): Promise { + return sessionManager.initializeAPIConnection(pipe, synchronous); }, registerContentMappers(contributorId, contributions): vscode.Disposable { return sessionManager.registerContentMappers(contributorId, contributions); diff --git a/packages/vscode-typescript/src/session.ts b/packages/vscode-typescript/src/session.ts index 46303606b836f..b46c6e0010856 100644 --- a/packages/vscode-typescript/src/session.ts +++ b/packages/vscode-typescript/src/session.ts @@ -106,11 +106,11 @@ export class SessionManager implements vscode.Disposable { }); } - async initializeAPIConnection(pipe?: string): Promise { + async initializeAPIConnection(pipe?: string, synchronous?: boolean): Promise { if (!this.currentSession) { throw new Error(vscode.l10n.t("Language server is not running.")); } - const result = await this.currentSession.client.initializeAPISession(pipe); + const result = await this.currentSession.client.initializeAPISession(pipe, synchronous); return result.pipe; } diff --git a/tsc/cmd/tsc/api.go b/tsc/cmd/tsc/api.go index 56058687c66e5..49107ecf36e82 100644 --- a/tsc/cmd/tsc/api.go +++ b/tsc/cmd/tsc/api.go @@ -16,7 +16,7 @@ import ( type apiFlags struct { cwd string - pipePath string + transport string callbacks string async bool timing bool @@ -27,7 +27,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.transport, "transport", "", "transport mechanism: stdio, pipe=, sync=") flags.StringVar(&result.callbacks, "callbacks", "", "comma-separated list of FS callbacks to enable (readFile,fileExists,directoryExists,getAccessibleEntries,realpath)") flags.BoolVar(&result.async, "async", false, "use JSON-RPC protocol instead of MessagePack (for async API)") flags.BoolVar(&result.timing, "timing", false, "collect per-request server processing time, folded into the client's timing snapshot") @@ -52,24 +52,17 @@ func runAPI(args []string) int { callbacksList = strings.Split(flags.callbacks, ",") } - options := &api.StdioServerOptions{ - Err: os.Stderr, + options := &api.ServerOptions{ Cwd: flags.cwd, DefaultLibraryPath: defaultLibraryPath, + Transport: flags.transport, Callbacks: callbacksList, Async: flags.async, CollectTiming: flags.timing, RunExternalCode: flags.runExternalCode, ContentMapperSpawner: newSystem(), } - if flags.pipePath != "" { - options.PipePath = flags.pipePath - } else { - options.In = os.Stdin - options.Out = os.Stdout - } - - s := api.NewStdioServer(options) + s := api.NewServer(options) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/tsc/internal/api/server.go b/tsc/internal/api/server.go index d5e55fc74be8f..514bcdf92ff22 100644 --- a/tsc/internal/api/server.go +++ b/tsc/internal/api/server.go @@ -3,7 +3,8 @@ package api import ( "context" "fmt" - "io" + "os" + "strings" "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/contentmapper" @@ -13,16 +14,12 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" ) -// StdioServerOptions configures the STDIO-based API server. -type StdioServerOptions struct { - In io.ReadCloser - Out io.WriteCloser - Err io.Writer +// ServerOptions configures the API server. +type ServerOptions struct { Cwd string DefaultLibraryPath string - // PipePath, if set, listens on a named pipe (Windows) or Unix domain - // socket instead of using In/Out for communication. - PipePath string + // Transport specifies "stdio", "pipe=", or "sync=". + Transport string // Callbacks specifies which filesystem operations should be delegated // to the client (e.g., "readFile", "fileExists"). Empty means no callbacks. Callbacks []string @@ -40,39 +37,31 @@ type StdioServerOptions struct { ContentMapperSpawner contentmapper.Spawner } -// StdioServer runs an API session over STDIO using MessagePack protocol. -// This is the entry point for the synchronous STDIO-based API used by +// Server runs an API session using MessagePack or JSON-RPC. +// This is the entry point for the API used by // native TypeScript tooling integration. -type StdioServer struct { - options *StdioServerOptions +type Server struct { + options *ServerOptions } -// NewStdioServer creates a new STDIO-based API server. -func NewStdioServer(options *StdioServerOptions) *StdioServer { +// NewServer creates an API server. +func NewServer(options *ServerOptions) *Server { if options.Cwd == "" { - panic("StdioServerOptions.Cwd is required") + panic("ServerOptions.Cwd is required") } - return &StdioServer{ + return &Server{ options: options, } } // Run starts the server and blocks until the connection closes. -func (s *StdioServer) Run(ctx context.Context) error { - var transport ipc.Transport - if s.options.PipePath != "" { - t, err := ipc.NewPipeTransport(s.options.PipePath) - if err != nil { - return fmt.Errorf("failed to create pipe transport: %w", err) - } - defer t.Close() - transport = t - } else { - t := ipc.NewStdioTransport(s.options.In, s.options.Out) - defer t.Close() - transport = t +func (s *Server) Run(ctx context.Context) error { + transport, err := s.createTransport() + if err != nil { + return fmt.Errorf("failed to create transport: %w", err) } + defer transport.Close() fs := bundled.WrapFS(osvfs.FS()) @@ -103,10 +92,21 @@ func (s *StdioServer) Run(ctx context.Context) error { defer session.Close() // Accept connection from transport - rwc, err := transport.Accept() + rwc, err := transport.Accept(ctx) if err != nil { return fmt.Errorf("failed to accept connection: %w", err) } + defer rwc.Close() + connectionDone := make(chan struct{}) + defer close(connectionDone) + go func() { + select { + case <-ctx.Done(): + _ = rwc.Close() + case <-connectionDone: + return + } + }() // Create protocol and connection based on async mode var conn ipc.Conn @@ -129,3 +129,20 @@ func (s *StdioServer) Run(ctx context.Context) error { return conn.Run(ctx) } + +func (s *Server) createTransport() (ipc.Transport, error) { + spec := s.options.Transport + switch { + case spec == "" || spec == "stdio": + return ipc.NewStdioTransport( + os.Stdin, //nolint:forbidigo + os.Stdout, //nolint:forbidigo + ), nil + case strings.HasPrefix(spec, "pipe="): + return ipc.NewPipeTransport(strings.TrimPrefix(spec, "pipe=")) + case strings.HasPrefix(spec, "sync="): + return ipc.NewSyncTransport(strings.TrimPrefix(spec, "sync=")) + default: + return nil, fmt.Errorf("unknown transport: %q", spec) + } +} diff --git a/tsc/internal/ipc/transport.go b/tsc/internal/ipc/transport.go index b912f7c050292..295d65481c3bf 100644 --- a/tsc/internal/ipc/transport.go +++ b/tsc/internal/ipc/transport.go @@ -1,14 +1,16 @@ package ipc import ( + "context" "io" "net" + "sync" ) // Transport is an interface for accepting connections from API clients. type Transport interface { // Accept waits for and returns the next connection. - Accept() (io.ReadWriteCloser, error) + Accept(ctx context.Context) (io.ReadWriteCloser, error) // Close stops the transport from accepting new connections. Close() error } @@ -16,6 +18,8 @@ type Transport interface { // PipeTransport accepts connections on a Unix domain socket or Windows named pipe. type PipeTransport struct { listener net.Listener + once sync.Once + closeErr error } // NewPipeTransport creates a new transport listening on the given path. @@ -29,13 +33,43 @@ func NewPipeTransport(path string) (*PipeTransport, error) { } // Accept implements Transport. -func (t *PipeTransport) Accept() (io.ReadWriteCloser, error) { - return t.listener.Accept() +func (t *PipeTransport) Accept(ctx context.Context) (io.ReadWriteCloser, error) { + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = t.close() + case <-done: + return + } + }() + conn, err := t.listener.Accept() + close(done) + if closeErr := t.close(); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + return nil, err + } else if closeErr != nil { + conn.Close() + return nil, closeErr + } else if ctxErr := ctx.Err(); ctxErr != nil { + conn.Close() + return nil, ctxErr + } + return conn, nil } // Close implements Transport. func (t *PipeTransport) Close() error { - return t.listener.Close() + return t.close() +} + +func (t *PipeTransport) close() error { + t.once.Do(func() { + t.closeErr = t.listener.Close() + }) + return t.closeErr } // Path returns the path of the pipe/socket. @@ -60,7 +94,7 @@ func NewStdioTransport(stdin io.ReadCloser, stdout io.WriteCloser) *StdioTranspo } // Accept implements Transport. -func (t *StdioTransport) Accept() (io.ReadWriteCloser, error) { +func (t *StdioTransport) Accept(_ context.Context) (io.ReadWriteCloser, error) { if t.used { return nil, io.EOF } diff --git a/tsc/internal/ipc/transport_unix.go b/tsc/internal/ipc/transport_unix.go index 952cefb9e74e0..4322e3234d42a 100644 --- a/tsc/internal/ipc/transport_unix.go +++ b/tsc/internal/ipc/transport_unix.go @@ -3,9 +3,19 @@ package ipc import ( + "context" + "errors" + "fmt" + "io" + iofs "io/fs" "net" "os" "path" + "sync" + "syscall" + "time" + + "golang.org/x/sys/unix" ) // newPipeListener creates a Unix domain socket listener. @@ -20,3 +30,207 @@ func GeneratePipePath(name string) string { //nolint:forbidigo return path.Join(os.TempDir(), name) } + +// NewSyncTransport creates two POSIX FIFOs at prefix.in and prefix.out. +func NewSyncTransport(prefix string) (Transport, error) { + inPath := prefix + ".in" + outPath := prefix + ".out" + readyPath := prefix + ".ready" + + if err := removeStalePath(inPath, iofs.ModeNamedPipe); err != nil { + return nil, err + } + if err := removeStalePath(outPath, iofs.ModeNamedPipe); err != nil { + return nil, err + } + if err := removeStalePath(readyPath, 0); err != nil { + return nil, err + } + if err := unix.Mkfifo(inPath, 0o600); err != nil { + return nil, fmt.Errorf("failed to create FIFO %s: %w", inPath, err) + } + if err := unix.Mkfifo(outPath, 0o600); err != nil { + _ = os.Remove(inPath) //nolint:forbidigo + return nil, fmt.Errorf("failed to create FIFO %s: %w", outPath, err) + } + + inInfo, err := os.Lstat(inPath) //nolint:forbidigo + if err != nil { + _ = syscall.Unlink(inPath) + _ = syscall.Unlink(outPath) + return nil, err + } + outInfo, err := os.Lstat(outPath) //nolint:forbidigo + if err != nil { + _ = syscall.Unlink(inPath) + _ = syscall.Unlink(outPath) + return nil, err + } + + return &fifoTransport{ + prefix: prefix, + inInfo: inInfo, + outInfo: outInfo, + }, nil +} + +type fifoTransport struct { + prefix string + inInfo iofs.FileInfo + outInfo iofs.FileInfo + readyInfo iofs.FileInfo + removeOnce sync.Once + removeErr error + used bool +} + +func (t *fifoTransport) Accept(ctx context.Context) (io.ReadWriteCloser, error) { + if t.used { + return nil, io.EOF + } + t.used = true + + outFile, err := openFIFOForWrite(ctx, t.prefix+".out") + if err != nil { + return nil, fmt.Errorf("failed to open FIFO %s.out for writing: %w", t.prefix, err) + } + + inFile, err := os.OpenFile(t.prefix+".in", os.O_RDONLY|syscall.O_NONBLOCK, 0) //nolint:forbidigo + if err != nil { + outFile.Close() //nolint:forbidigo + return nil, fmt.Errorf("failed to open FIFO %s.in for reading: %w", t.prefix, err) + } + + readyInfo, err := waitForPath(ctx, t.prefix+".ready") + t.readyInfo = readyInfo + if err != nil { + outFile.Close() //nolint:forbidigo + inFile.Close() //nolint:forbidigo + return nil, err + } + if readyInfo.Mode().Type() != 0 { + outFile.Close() //nolint:forbidigo + inFile.Close() //nolint:forbidigo + return nil, fmt.Errorf("unexpected file at sync transport path %s.ready", t.prefix) + } + if err := syscall.SetNonblock(int(outFile.Fd()), false); err != nil { //nolint:forbidigo + outFile.Close() //nolint:forbidigo + inFile.Close() //nolint:forbidigo + return nil, fmt.Errorf("failed to set FIFO %s.out to blocking mode: %w", t.prefix, err) + } + if err := syscall.SetNonblock(int(inFile.Fd()), false); err != nil { //nolint:forbidigo + outFile.Close() //nolint:forbidigo + inFile.Close() //nolint:forbidigo + return nil, fmt.Errorf("failed to set FIFO %s.in to blocking mode: %w", t.prefix, err) + } + return &fifoConn{reader: inFile, writer: outFile}, nil +} + +func (t *fifoTransport) Close() error { + return t.remove() +} + +type fifoConn struct { + reader *os.File //nolint:forbidigo + writer *os.File //nolint:forbidigo +} + +func (c *fifoConn) Read(p []byte) (int, error) { + return c.reader.Read(p) //nolint:forbidigo +} + +func (c *fifoConn) Write(p []byte) (int, error) { + return c.writer.Write(p) //nolint:forbidigo +} + +func (c *fifoConn) Close() error { + err1 := c.reader.Close() //nolint:forbidigo + err2 := c.writer.Close() //nolint:forbidigo + if err1 != nil { + return err1 + } + return err2 +} + +func (t *fifoTransport) remove() error { + t.removeOnce.Do(func() { + t.removeErr = errors.Join( + removePathIfSame(t.prefix+".in", t.inInfo), + removePathIfSame(t.prefix+".out", t.outInfo), + removePathIfSame(t.prefix+".ready", t.readyInfo), + ) + }) + return t.removeErr +} + +func openFIFOForWrite(ctx context.Context, path string) (*os.File, error) { //nolint:forbidigo + for { + file, err := os.OpenFile(path, os.O_WRONLY|syscall.O_NONBLOCK, 0) //nolint:forbidigo + if err == nil { + return file, nil + } + if !errors.Is(err, syscall.ENXIO) { + return nil, err + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(10 * time.Millisecond): + continue + } + } +} + +func waitForPath(ctx context.Context, path string) (iofs.FileInfo, error) { + for { + if info, err := os.Lstat(path); err == nil { //nolint:forbidigo + return info, nil + } else if !errors.Is(err, syscall.ENOENT) { + return nil, err + } + select { + case <-ctx.Done(): + info, err := os.Lstat(path) //nolint:forbidigo + if err == nil { + return info, ctx.Err() + } + if !errors.Is(err, syscall.ENOENT) { + return nil, err + } + return nil, ctx.Err() + case <-time.After(10 * time.Millisecond): + continue + } + } +} + +func removeStalePath(path string, expectedType iofs.FileMode) error { + info, err := os.Lstat(path) //nolint:forbidigo + if errors.Is(err, syscall.ENOENT) { + return nil + } + if err != nil { + return err + } + if info.Mode().Type() != expectedType { + return fmt.Errorf("refusing to remove unexpected file at sync transport path %s", path) + } + return syscall.Unlink(path) +} + +func removePathIfSame(path string, expected iofs.FileInfo) error { + if expected == nil { + return nil + } + current, err := os.Lstat(path) //nolint:forbidigo + if errors.Is(err, syscall.ENOENT) { + return nil + } + if err != nil { + return err + } + if !os.SameFile(expected, current) { //nolint:forbidigo + return nil + } + return syscall.Unlink(path) +} diff --git a/tsc/internal/ipc/transport_windows.go b/tsc/internal/ipc/transport_windows.go index 6e105a8033e39..6b25df961f397 100644 --- a/tsc/internal/ipc/transport_windows.go +++ b/tsc/internal/ipc/transport_windows.go @@ -17,3 +17,8 @@ func newPipeListener(path string) (net.Listener, error) { func GeneratePipePath(name string) string { return `\\.\pipe\` + name } + +// NewSyncTransport creates a Windows named pipe transport. +func NewSyncTransport(path string) (Transport, error) { + return NewPipeTransport(path) +} diff --git a/tsc/internal/lsp/lsproto/_generate/generate.mts b/tsc/internal/lsp/lsproto/_generate/generate.mts index 6f65a9af99c1e..65a3b88a2cd40 100755 --- a/tsc/internal/lsp/lsproto/_generate/generate.mts +++ b/tsc/internal/lsp/lsproto/_generate/generate.mts @@ -401,7 +401,13 @@ 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 path to use for API communication. If not provided, a unique path will be generated.", + }, + { + name: "synchronous", + type: { kind: "base", name: "boolean" }, + optional: true, + documentation: "Use the synchronous MessagePack API protocol over FIFOs on Unix or a named pipe on Windows.", }, ], documentation: "Parameters for the initializeAPISession request.", @@ -417,7 +423,7 @@ const customStructures: Structure[] = [ { name: "pipe", type: { kind: "base", name: "string" }, - documentation: "The path to the named pipe or Unix domain socket for API communication.", + documentation: "The path to use for API communication.", }, ], documentation: "Result for the initializeAPISession request.", diff --git a/tsc/internal/lsp/lsproto/lsp_generated.go b/tsc/internal/lsp/lsproto/lsp_generated.go index d7fd435e7c038..3ebc23151e80a 100644 --- a/tsc/internal/lsp/lsproto/lsp_generated.go +++ b/tsc/internal/lsp/lsproto/lsp_generated.go @@ -9057,8 +9057,11 @@ 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 path to use for API communication. If not provided, a unique path will be generated. Pipe *string `json:"pipe,omitzero"` + + // Use the synchronous MessagePack API protocol over FIFOs on Unix or a named pipe on Windows. + Synchronous *bool `json:"synchronous,omitzero"` } var _ json.UnmarshalerFrom = (*InitializeAPISessionParams)(nil) @@ -9072,7 +9075,7 @@ type InitializeAPISessionResult struct { // The unique identifier for this API session. SessionId string `json:"sessionId" lsp:"required"` - // The path to the named pipe or Unix domain socket for API communication. + // The path to use for API communication. Pipe string `json:"pipe" lsp:"required"` } diff --git a/tsc/internal/lsp/server.go b/tsc/internal/lsp/server.go index f23a7fcadcd3c..46532442b9989 100644 --- a/tsc/internal/lsp/server.go +++ b/tsc/internal/lsp/server.go @@ -2287,8 +2287,10 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto s.apiSessions = make(map[string]*api.Session) } - var apiSession *api.Session - apiSession = api.NewLSPSession(s.session, nil) + synchronous := params.Synchronous != nil && *params.Synchronous + apiSession := api.NewLSPSession(s.session, &api.SessionOptions{ + UseBinaryResponses: synchronous, + }) // Use provided pipe path or generate a unique one var pipePath string @@ -2298,28 +2300,45 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto pipePath = s.generateAPIPipePath() } - transport, err := ipc.NewPipeTransport(pipePath) + var transport ipc.Transport + var err error + if synchronous { + transport, err = ipc.NewSyncTransport(pipePath) + } else { + transport, err = ipc.NewPipeTransport(pipePath) + } if err != nil { return nil, fmt.Errorf("failed to create API transport: %w", err) } + apiCtx, apiCancel := context.WithCancel(s.backgroundCtx) // Start accepting connections in the background go func() { defer func() { + apiCancel() apiSession.Close() s.removeAPISession(apiSession.ID()) }() - rwc, acceptErr := transport.Accept() + rwc, acceptErr := transport.Accept(apiCtx) _ = transport.Close() if acceptErr != nil { s.logger.Errorf("API session %s: failed to accept connection: %v", apiSession.ID(), acceptErr) return } + defer rwc.Close() // Create a cancellable context for the API connection - apiCtx, apiCancel := context.WithCancel(s.backgroundCtx) - defer apiCancel() + connectionDone := make(chan struct{}) + defer close(connectionDone) + go func() { + select { + case <-apiCtx.Done(): + _ = rwc.Close() + case <-connectionDone: + return + } + }() // Run the connection with panic recovery defer func() { @@ -2333,7 +2352,13 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto } }() - conn := ipc.NewAsyncConn(rwc, apiSession) + var conn ipc.Conn + if synchronous { + protocol := api.NewMessagePackProtocol(rwc) + conn = ipc.NewSyncConn(rwc, protocol, apiSession) + } else { + conn = ipc.NewAsyncConn(rwc, apiSession) + } if apiErr := conn.Run(apiCtx); apiErr != nil { s.logger.Errorf("API session %s: %v", apiSession.ID(), apiErr) }