Reuse checker hosts across a compile's loads, and leave no zombie checker processes (#2650)

This commit is contained in:
Chris Tate
2026-10-30 14:13:18 -05:00
committed by GitHub
10 changed files with 629 additions and 137 deletions
@@ -1,6 +1,6 @@
import { runFrontend, type Frontend, type FrontendOptions } from "./pipeline.js";
import { loadProgram } from "./program.js";
import { FrontendServices, type ComptimeEvaluator } from "./services.js";
import { FrontendServices, programHostLoads, type ComptimeEvaluator } from "./services.js";
import { createNativeTs7Api } from "./ts7/native-api.js";
/** Native callers supply the installed TS7 executable explicitly. The
@@ -22,6 +22,7 @@ export function runNativeFrontend(
undefined,
);
try {
services.reuseProgramHosts(programHostLoads());
const frontend = runFrontend(
entryPath,
(path, options) =>
+21 -7
View File
@@ -7,6 +7,7 @@ import {
} from "./program.js";
import { createNodeFrontendServices } from "./services-node.js";
import type { Ts7Host } from "./ts7/program-host.js";
import type { FrontendServices } from "./services.js";
export * from "./program.js";
export interface NodeProgramLoadOptions {
@@ -14,19 +15,32 @@ export interface NodeProgramLoadOptions {
externalTypes?: ReadonlyMap<string, string> | Readonly<Record<string, string>> | undefined;
}
function serviceOptions(options: NodeProgramLoadOptions | undefined) {
return {
npmStatic: [...(options?.npmStatic ?? [])],
externalTypes:
options?.externalTypes instanceof Map
? [...options.externalTypes]
: Object.entries(options?.externalTypes ?? {}),
};
}
/** A load on services the caller owns (and closes). */
export function loadProgramWithServices(
entryPath: string,
services: FrontendServices,
options?: NodeProgramLoadOptions,
): ReturnType<typeof loadWithServices> {
return loadWithServices(entryPath, services, serviceOptions(options));
}
export function loadProgram(
entryPath: string,
options?: NodeProgramLoadOptions,
): ReturnType<typeof loadWithServices> {
const services = createNodeFrontendServices();
try {
const load = loadWithServices(entryPath, services, {
npmStatic: [...(options?.npmStatic ?? [])],
externalTypes:
options?.externalTypes instanceof Map
? [...options.externalTypes]
: Object.entries(options?.externalTypes ?? {}),
});
const load = loadWithServices(entryPath, services, serviceOptions(options));
return {
...load,
dispose: () => {
@@ -2,7 +2,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, expect, test, vi } from "vitest";
import { FrontendServices, type ComptimeEvaluator } from "./services.js";
import {
FrontendServices,
PROGRAM_HOST_LOADS,
programHostLoads,
type ComptimeEvaluator,
} from "./services.js";
import { Ts7Api } from "./ts7/rpc-api.js";
import { checkPreflight, isNodeEsmFile, loadProgram } from "./program.js";
import { clearResolveCaches } from "./resolve.js";
@@ -168,3 +173,64 @@ test("Node format decisions belong to their program even when source ASTs are re
}
expect(first.analysis.nodeEsmFiles.size).toBe(0);
});
test("reused program hosts serve later loads, up to their share, one connection per live host", () => {
const { services, connections, dir } = setup();
services.reuseProgramHosts(3);
const entry = join(dir, "main.ts");
writeFileSync(entry, "export const value = 1;");
try {
// Two loads alive at once hold two hosts; after they close, the next
// loads reuse those connections.
const a = loadProgram(entry, services);
const b = loadProgram(entry, services);
expect(connections).toHaveLength(2);
a.dispose();
b.dispose();
for (let load = 0; load < 5; load++) {
const next = loadProgram(entry, services);
expect(checkPreflight(next)).toEqual([]);
next.dispose();
}
// Each host served 3 loads (the share), then closed: a third host
// started for the last load.
expect(connections).toHaveLength(3);
expect(connections[0]!.close).toHaveBeenCalledTimes(1);
expect(connections[1]!.close).toHaveBeenCalledTimes(1);
expect(connections[2]!.close).not.toHaveBeenCalled();
} finally {
services.close();
}
expect(connections[2]!.close).toHaveBeenCalledTimes(1);
});
test("without reuse, and for timed hosts, every load gets its own host", () => {
const { services, connections } = setup();
try {
services.createProgramHost().close();
services.createProgramHost().close();
expect(connections).toHaveLength(2);
services.reuseProgramHosts(4);
services.createProgramHost({ collectTiming: true }).close();
services.createProgramHost({ collectTiming: true }).close();
expect(connections).toHaveLength(4);
for (const connection of connections) expect(connection.close).toHaveBeenCalledTimes(1);
// A host in a different directory is not reused for this one.
services.createProgramHost({ cwd: tmpdir() }).close();
services.createProgramHost().close();
expect(connections).toHaveLength(6);
} finally {
services.close();
}
for (const connection of connections) expect(connection.close).toHaveBeenCalledTimes(1);
});
test("SCRIPTC_TS7_HOST_LOADS selects the share and rejects malformed values", () => {
expect(programHostLoads({})).toBe(PROGRAM_HOST_LOADS);
expect(programHostLoads({ SCRIPTC_TS7_HOST_LOADS: "1" })).toBe(1);
expect(programHostLoads({ SCRIPTC_TS7_HOST_LOADS: "12" })).toBe(12);
for (const value of ["0", "-1", "2.5", "many"])
expect(() => programHostLoads({ SCRIPTC_TS7_HOST_LOADS: value })).toThrow(
"SCRIPTC_TS7_HOST_LOADS must be a positive integer",
);
});
+51 -2
View File
@@ -42,6 +42,25 @@ export interface RuntimeModuleResolver {
lookupPaths: (fromFile: string, specifier: string) => readonly string[] | null;
}
/** How many loads of one compile a program host serves before a new one
* replaces it. tsc-ts keeps every syntax tree it builds for the life of its
* process, so a host grows with each load whose files differ from the last
* one's (on pi, by 200-400 MB a load); a new one starts empty. */
export const PROGRAM_HOST_LOADS = 4;
/** PROGRAM_HOST_LOADS, or SCRIPTC_TS7_HOST_LOADS (1: a new host per load,
* the comparison the host reuse parity test runs). */
export function programHostLoads(env: NodeJS.ProcessEnv = process.env): number {
const value = env["SCRIPTC_TS7_HOST_LOADS"];
if (value === undefined || value === "") return PROGRAM_HOST_LOADS;
const loads = Number(value);
if (!Number.isSafeInteger(loads) || loads < 1)
throw new Error(
`SCRIPTC_TS7_HOST_LOADS must be a positive integer, not ${JSON.stringify(value)}`,
);
return loads;
}
/** Own the syntax and semantic services for a compiler client. The supplied
* connection factory and optional evaluator provide host operations;
* parsing, projection and filesystem resolution share their implementation
@@ -49,6 +68,10 @@ export interface RuntimeModuleResolver {
export class FrontendServices {
private parser: Ts7SourceParser | undefined;
private closed = false;
/** How many loads one program host serves (reuseProgramHosts). */
private hostLoads = 1;
/** Reusable program hosts that no load holds. */
private readonly idleHosts: Ts7Host[] = [];
constructor(
private readonly createApi: Ts7ApiFactory,
@@ -81,9 +104,29 @@ export class FrontendServices {
throw new Error("this compiler host does not provide compile-time evaluation");
return evaluate(source, timeoutMs);
}
/** Let every program host serve up to `loads` loads (reuseProgramHosts). */
reuseProgramHosts(loads: number): void {
this.ensureOpen();
this.hostLoads = loads > 1 ? loads : 1;
}
createProgramHost(options?: Ts7HostOptions): Ts7Host {
this.ensureOpen();
return new Ts7Host(this.createApi, { ...options, cwd: options?.cwd ?? this.cwd });
const cwd = options?.cwd ?? this.cwd;
// A timed host reports its own load's timing, so it is never reused.
if (this.hostLoads === 1 || options?.collectTiming === true)
return new Ts7Host(this.createApi, { ...options, cwd });
const index = this.idleHosts.findIndex((host) => host.cwd === cwd);
if (index >= 0) {
const host = this.idleHosts.splice(index, 1)[0]!;
host.rebind(options?.fsShadow);
return host;
}
return new Ts7Host(this.createApi, { ...options, cwd }, (released) => {
// The host's load closed it: keep its connection for the next load,
// unless it has served its share or these services are closed.
if (this.closed || released.loads >= this.hostLoads) released.closeConnection();
else this.idleHosts.push(released);
});
}
rewriteCjs(
source: string,
@@ -214,6 +257,12 @@ export class FrontendServices {
this.closed = true;
const parser = this.parser;
this.parser = undefined;
parser?.close();
// A host a load still holds closes when that load closes it.
const idle = this.idleHosts.splice(0);
try {
for (const host of idle) host.closeConnection();
} finally {
parser?.close();
}
}
}
@@ -333,3 +333,97 @@ test("closed hosts refuse every operation and release virtual contents", () => {
expect(() => host.getTimingInfo()).toThrow("closed");
expect(() => host.createProgram([entry], options)).toThrow("closed");
});
/** What a program answers that a reused host could answer stale: its file
* list, each file's text, and its diagnostics. */
function answers(program: ReturnType<Ts7Host["createProgram"]>) {
const names = [...program.getSourceFileNames()];
return {
names,
texts: names.map((name) => program.getSourceFile(name)?.text),
diagnostics: getPreEmitDiagnostics(program),
};
}
test("a rebound host answers as a new host when shadows, disk files and in-memory files change", () => {
const { directory, entry } = fixture();
const dep = join(directory, "dep.ts");
const extra = join(directory, "extra.d.ts");
const virtual = join(directory, "virtual.ts");
writeFileSync(
entry,
'/// <reference path="./extra.d.ts" />\nimport { value } from "./dep.js";\nimport { v } from "./virtual.js";\nexport const sum: number = value + extra + v;\n',
);
writeFileSync(dep, "export const value: number = 1;\n");
writeFileSync(extra, "declare const extra: number;\n");
// Load 1: dep.ts reads as a string-typed replacement, extra.d.ts is
// hidden, and virtual.ts exists in memory only.
const shadow = {
readFile: (path: string) =>
tsgoPath(path) === tsgoPath(dep) ? 'export const value: string = "1";\n' : undefined,
hideFile: (path: string) => tsgoPath(path) === tsgoPath(extra),
};
const factory = vi.fn((connection: Ts7ApiOptions) => new Ts7Api(connection));
const reused = new Ts7Host(factory, { cwd: directory, fsShadow: shadow }, () => {});
hosts.push(reused);
reused.addVirtualFile(virtual, "export const v = 2;\n");
const first = reused.createProgram([entry], options);
const firstAnswers = answers(first);
first.dispose();
expect(firstAnswers.diagnostics.map((d) => d.code).sort()).toEqual([2304, 2322, 6053]);
// Load 2: no shadow, no in-memory file, and dep.ts changed on disk
// without a change notification.
writeFileSync(dep, "export const value: boolean = true;\n");
reused.rebind(null);
const second = reused.createProgram([entry], options);
const fresh = makeHost(directory).host.createProgram([entry], options);
try {
expect(factory).toHaveBeenCalledTimes(1);
expect(answers(second)).toEqual(answers(fresh));
expect(
answers(second)
.diagnostics.map((d) => d.code)
.sort(),
).toEqual([2307, 2365]);
} finally {
second.dispose();
fresh.dispose();
}
// Load 3: the first load's shadow again answers as it did.
writeFileSync(dep, "export const value: number = 1;\n");
reused.rebind(shadow);
reused.addVirtualFile(virtual, "export const v = 2;\n");
const third = reused.createProgram([entry], options);
try {
expect(answers(third)).toEqual(firstAnswers);
} finally {
third.dispose();
}
});
test("a reusable host returns to its owner on close and closes its connection only once", () => {
const { directory, entry } = fixture();
const released: Ts7Host[] = [];
let api!: Ts7Api;
const host = new Ts7Host(
(connection) => (api = new Ts7Api(connection)),
{ cwd: directory },
(h) => released.push(h),
);
hosts.push(host);
const program = host.createProgram([entry], options);
program.dispose();
const close = vi.spyOn(api, "close");
host.close();
host.close();
expect(released).toEqual([host]);
expect(close).not.toHaveBeenCalled();
// Still open: the next load rebinds it.
host.rebind(null);
expect(host.loads).toBe(2);
host.createProgram([entry], options).dispose();
host.closeConnection();
host.close();
expect(close).toHaveBeenCalledTimes(1);
expect(() => host.createProgram([entry], options)).toThrow("closed");
});
@@ -166,16 +166,37 @@ export class Ts7Host {
/** The text the server reads for a file (its readFile hook). */
private readonly readSource: (fileName: string) => string | null;
private closed = false;
/** The file system shadow of the load that holds this host (rebind). */
private shadow: NonNullable<Ts7HostOptions["fsShadow"]> | null;
/** Whether the next snapshot drops every file the server read: set when
* the host passes to another load, whose files may read differently. */
private invalidateAll = false;
/** The working directory of the server connection. */
readonly cwd: string;
/** The loads this host has served: 1, and one more for each rebind. */
loads = 1;
/** Called instead of closing the connection when the host is reusable. */
private release: ((host: Ts7Host) => void) | undefined;
/** Whether a load holds this reusable host: from its creation or rebind
* until that load closes it. */
private held = true;
constructor(createApi: Ts7ApiFactory, options?: Ts7HostOptions) {
constructor(
createApi: Ts7ApiFactory,
options?: Ts7HostOptions,
release?: (host: Ts7Host) => void,
) {
const virtualFiles = this.virtualFiles;
const shadow = options?.fsShadow ?? null;
this.shadow = options?.fsShadow ?? null;
this.cwd = options?.cwd ?? process.cwd();
this.release = release;
// A virtual file, a shadow's replacement, a package shim's declaration
// entry, a type-patched package file or the file on disk; null when shadowed out of existence or
// missing.
this.readSource = (fileName) => {
const virtual = virtualFiles.get(tsgoPath(fileName));
if (virtual !== undefined) return stripSourceBom(virtual);
const shadow = this.shadow;
if (shadow !== null) {
if (shadow.hideFile(fileName)) return null;
const replacement = shadow.readFile(fileName);
@@ -199,7 +220,7 @@ export class Ts7Host {
readFile: this.readSource,
fileExists: (fileName) => {
if (virtualFiles.has(tsgoPath(fileName))) return true;
if (shadow !== null && shadow.hideFile(fileName)) return false;
if (this.shadow !== null && this.shadow.hideFile(fileName)) return false;
return trackedFileExists(fileName) || typePatchFileExists(fileName);
},
directoryExists: (path) => trackedDirectoryExists(path),
@@ -270,8 +291,9 @@ export class Ts7Host {
const snapshot = this.api.updateSnapshot({
openProjects: [configPath],
closeProjects,
fileChanges: { created, changed },
fileChanges: this.invalidateAll ? { invalidateAll: true } : { created, changed },
});
this.invalidateAll = false;
this.closingProjects.clear();
this.virtualChanges.clear();
const project = snapshot.getProject(configPath);
@@ -303,8 +325,36 @@ export class Ts7Host {
if (this.closed) throw new InternalCompilerError("Ts7Host is closed");
}
/** Pass this pooled host to another load: its shadow replaces the last
* load's, the last load's in-memory files are gone, and the next snapshot
* reads every file again (the server reuses a file's syntax tree only for
* identical text). */
rebind(fsShadow: Ts7HostOptions["fsShadow"]): void {
this.ensureOpen();
this.held = true;
this.loads++;
this.shadow = fsShadow ?? null;
this.virtualFiles.clear();
this.virtualChanges.clear();
this.invalidateAll = true;
}
/** Close the server connection, even when the host is pooled. */
closeConnection(): void {
this.release = undefined;
this.close();
}
close(): void {
if (this.closed) return;
const release = this.release;
if (release !== undefined) {
// A second close by the same load returns nothing twice.
if (!this.held) return;
this.held = false;
release(this);
return;
}
this.closed = true;
try {
this.api.close();
@@ -1,37 +1,68 @@
import { execFile } from "node:child_process";
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { expect, test } from "vitest";
import { Ts7FifoIo } from "./rpc-process.js";
const execFileAsync = promisify(execFile);
const posix = process.platform !== "win32";
test.skipIf(process.platform === "win32")(
"closing the owned server is quiet even when its cancellation handler writes stderr",
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
test.skipIf(!posix)("a FIFO server echoes, and exits when its transport closes", () => {
const io = new Ts7FifoIo("/bin/cat", []);
const bytes = Buffer.from("ping\n");
expect(io.write(bytes, 0, bytes.length)).toBe(bytes.length);
const reply = Buffer.alloc(16);
expect(reply.subarray(0, io.read(reply, 0, reply.length)).toString()).toBe("ping\n");
io.close();
io.close();
expect(() => io.read(reply, 0, 1)).toThrow("closed");
// The server is not this process's child: init reaps it once it exits.
const deadline = Date.now() + 5000;
while (alive(io.pid) && Date.now() < deadline) execFileSync("sleep", ["0.01"]);
expect(alive(io.pid)).toBe(false);
});
test.skipIf(!posix)("a missing FIFO server fails at construction and tracks nothing", () => {
const before = process.listenerCount("exit");
expect(() => new Ts7FifoIo("/nonexistent/scriptc-ts7-server", [])).toThrow(
"Unable to start TypeScript server",
);
expect(process.listenerCount("exit")).toBe(before);
});
test.skipIf(!posix)(
"replaced and closed checker servers leave no zombie children behind",
async () => {
// A ready message ensures the signal handler is installed before close.
// Keep stdin idle, matching an API server waiting for its next request.
const server = `
process.on('SIGTERM', () => {
process.stderr.write('context canceled\\n');
process.exit(0);
});
process.stdout.write(Buffer.from([0x93, 4, 0xc4, 0, 0xc4, 0]));
setInterval(() => {}, 1000);
`;
const moduleUrl = new URL("./rpc-process.ts", import.meta.url).href;
// Source budget 1: every parse after the first replaces the server.
const parserUrl = new URL("./source-parser.ts", import.meta.url).href;
const apiUrl = new URL("./rpc-api.ts", import.meta.url).href;
const host = `
import { spawnTs7Wire } from ${JSON.stringify(moduleUrl)};
const wire = spawnTs7Wire(process.execPath, ['--eval', ${JSON.stringify(server)}]);
const ready = wire.read();
if (ready.kind !== 4) throw new Error('server was not ready');
wire.close();
wire.close();
import { execFileSync } from 'node:child_process';
import { Ts7SourceParser } from ${JSON.stringify(parserUrl)};
import { Ts7Api } from ${JSON.stringify(apiUrl)};
const parser = new Ts7SourceParser((options) => new Ts7Api(options), process.cwd(), 1);
for (let n = 0; n < 12; n++) parser.parse('f' + n + '.ts', 'export const v' + n + ' = ' + n + ';', 'ts');
parser.close();
const zombies = execFileSync('ps', ['-A', '-o', 'ppid=,stat='], { encoding: 'utf8' })
.split('\\n')
.map((line) => line.trim().split(/\\s+/))
.filter(([ppid, stat]) => Number(ppid) === process.pid && (stat ?? '').startsWith('Z'));
console.log(JSON.stringify({ zombies: zombies.length }));
`;
const result = await execFileAsync(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", host],
{ timeout: 10_000 },
{ timeout: 60_000 },
);
expect(result.stdout).toBe("");
expect(JSON.parse(result.stdout)).toEqual({ zombies: 0 });
expect(result.stderr).toBe("");
},
);
+184 -97
View File
@@ -1,98 +1,96 @@
import { spawn, type ChildProcess } from "node:child_process";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { closeSync, openSync, readSync, writeSync } from "node:fs";
import type { Readable, Writable } from "node:stream";
import { closeSync, constants, mkdtempSync, openSync, readSync, rmSync, writeSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Ts7Wire, type Ts7WireIo } from "./rpc-wire.js";
// Node's public spawn API does not expose synchronous pipe descriptors.
// Keep this host-specific boundary out of the statically compiled protocol.
// Windows uses a named pipe because libuv's Windows handles are not CRT fds.
interface PipeHandle {
fd: number;
setBlocking: (blocking: boolean) => void;
}
type PipeStream = (Readable | Writable) & {
_handle?: PipeHandle;
unref: () => void;
};
// Node's public spawn API does not expose synchronous pipe descriptors, so
// the server's end of the channel is a named pipe on Windows and a FIFO pair
// elsewhere, both opened with openSync. Keep this host-specific boundary out
// of the statically compiled protocol.
const liveTransports = new Set<Ts7ProcessIo>();
const liveTransports = new Set<Ts7WireIo>();
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
function closeLiveTransports(): void {
for (const transport of liveTransports) transport.close();
}
/** Own the child and its descriptors together: every failure path closes
* both, and the exit hook exists only while at least one host is alive. */
class Ts7ProcessIo implements Ts7WireIo {
function track(transport: Ts7WireIo): void {
if (liveTransports.size === 0) process.on("exit", closeLiveTransports);
liveTransports.add(transport);
}
function untrack(transport: Ts7WireIo): void {
liveTransports.delete(transport);
if (liveTransports.size === 0) process.removeListener("exit", closeLiveTransports);
}
/** A synchronous read or write that retries an interrupted call and waits
* out a full pipe. */
function transfer(
operation: (
fd: number,
buffer: Uint8Array,
offset: number,
length: number,
position: null,
) => number,
fd: number,
buffer: Uint8Array,
offset: number,
length: number,
): number {
for (;;) {
try {
return operation(fd, buffer, offset, length, null);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EINTR") continue;
if (code !== "EAGAIN") throw error;
Atomics.wait(sleepBuffer, 0, 0, 1);
}
}
}
/** Windows: the server is a child process that creates a named pipe
* (`--pipe`), which this process opens. Windows has no zombie processes; the
* child's handle closes with the process. */
class Ts7NamedPipeIo implements Ts7WireIo {
private readonly child: ChildProcess;
private readFd = -1;
private writeFd = -1;
private pipeFd = -1;
private closed = false;
private spawnError: Error | undefined;
constructor(executable: string, args: string[]) {
const windows = process.platform === "win32";
const pipe = `\\\\.\\pipe\\scriptc-ts7-${process.pid}-${randomUUID()}`;
this.child = spawn(executable, windows ? [...args, "--pipe", pipe] : args, {
stdio: windows ? ["ignore", "ignore", "inherit"] : ["pipe", "pipe", "inherit"],
this.child = spawn(executable, [...args, "--pipe", pipe], {
stdio: ["ignore", "ignore", "inherit"],
windowsHide: true,
});
// A failed spawn emits asynchronously even when descriptor validation
// below has already thrown. Always consume that event.
// A failed spawn emits asynchronously even when the connection below has
// already thrown. Always consume that event.
this.child.on("error", (error: Error) => {
this.spawnError = error;
});
try {
if (this.child.pid === undefined)
throw new Error(`Unable to start TypeScript server: ${executable}`);
if (windows) {
for (let attempt = 0; attempt < 500; attempt++) {
try {
this.pipeFd = openSync(pipe, "r+");
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "EBUSY") throw error;
Atomics.wait(sleepBuffer, 0, 0, 10);
}
for (let attempt = 0; attempt < 500; attempt++) {
try {
this.pipeFd = openSync(pipe, "r+");
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "EBUSY") throw error;
Atomics.wait(sleepBuffer, 0, 0, 10);
}
if (this.pipeFd === -1)
throw new Error("TypeScript server: timed out connecting to named pipe");
this.readFd = this.pipeFd;
this.writeFd = this.pipeFd;
} else {
const stdout = this.child.stdout as PipeStream | null;
const stdin = this.child.stdin as PipeStream | null;
const readHandle = stdout?._handle;
const writeHandle = stdin?._handle;
if (
!stdout ||
!stdin ||
!readHandle ||
!writeHandle ||
!Number.isInteger(readHandle.fd) ||
readHandle.fd < 0 ||
!Number.isInteger(writeHandle.fd) ||
writeHandle.fd < 0 ||
typeof readHandle.setBlocking !== "function" ||
typeof writeHandle.setBlocking !== "function"
) {
throw new Error("TypeScript server: Node synchronous pipe handles are unavailable");
}
readHandle.setBlocking(true);
writeHandle.setBlocking(true);
this.child.stdout!.pause();
stdout.unref();
stdin.unref();
this.readFd = readHandle.fd;
this.writeFd = writeHandle.fd;
}
if (this.pipeFd === -1)
throw new Error("TypeScript server: timed out connecting to named pipe");
this.child.unref();
if (liveTransports.size === 0) process.on("exit", closeLiveTransports);
liveTransports.add(this);
track(this);
} catch (error) {
this.close();
throw error;
@@ -102,52 +100,27 @@ class Ts7ProcessIo implements Ts7WireIo {
read = (buffer: Uint8Array, offset: number, length: number): number => {
if (this.spawnError) throw this.spawnError;
if (this.closed) throw new Error("TypeScript server transport is closed");
for (;;) {
try {
return readSync(this.readFd, buffer, offset, length, null);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EINTR") continue;
if (code !== "EAGAIN") throw error;
Atomics.wait(sleepBuffer, 0, 0, 1);
}
}
return transfer(readSync, this.pipeFd, buffer, offset, length);
};
write = (buffer: Uint8Array, offset: number, length: number): number => {
if (this.spawnError) throw this.spawnError;
if (this.closed) throw new Error("TypeScript server transport is closed");
for (;;) {
try {
return writeSync(this.writeFd, buffer, offset, length, null);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EINTR") continue;
if (code !== "EAGAIN") throw error;
Atomics.wait(sleepBuffer, 0, 0, 1);
}
}
return transfer(writeSync, this.pipeFd, buffer, offset, length);
};
close = (): void => {
if (this.closed) return;
this.closed = true;
liveTransports.delete(this);
if (liveTransports.size === 0) process.removeListener("exit", closeLiveTransports);
untrack(this);
if (this.pipeFd !== -1) {
try {
closeSync(this.pipeFd);
} catch {
/* still close the child */
/* still end the child */
}
this.pipeFd = -1;
}
this.readFd = -1;
this.writeFd = -1;
this.child.stdout?.destroy();
this.child.stdin?.destroy();
// A failed spawn can leave libuv's process handle without a PID.
// Never signal it: PID zero has process-group semantics on POSIX.
// The channel is closed and all its work is discarded. SIGTERM invokes
// tsgo's cancellation handler, which can race EOF and print "context
// canceled" into an otherwise successful compile's stderr.
@@ -155,6 +128,120 @@ class Ts7ProcessIo implements Ts7WireIo {
};
}
/* POSIX: the server is not this process's child. A shell that spawnSync runs
* and reaps creates two FIFOs, starts the server in the background with its
* standard input and output on them, prints its PID and exits; the server's
* parent becomes init (or the nearest subreaper), which reaps it. A child of
* this process would stay a zombie after it exits: Node reaps children only
* from its event loop, which a synchronous compile never returns to. The
* subshell points its standard streams at /dev/null before it opens the
* FIFOs, so spawnSync's output pipe closes as soon as the shell exits. A
* missing or non-executable server fails here, before any FIFO exists: the
* background subshell would otherwise block opening its input and the failure
* would only surface as end of file on the first read. */
const FIFO_LAUNCH =
'i=$1 o=$2; shift 2; command -v "$1" >/dev/null || exit 127; mkfifo -m 600 "$i" "$o" || exit 1; ' +
'( exec </dev/null >/dev/null; exec "$@" <"$i" >"$o" ) & echo $!';
/** How long a starting server may take to open its input, in milliseconds. */
const FIFO_OPEN_TIMEOUT = 10_000;
function processAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
export class Ts7FifoIo implements Ts7WireIo {
private readFd = -1;
private writeFd = -1;
private closed = false;
/** The server's process ID, for tests: it is never signaled. */
readonly pid: number;
constructor(executable: string, args: string[]) {
const directory = mkdtempSync(join(tmpdir(), "scriptc-ts7-"));
const input = join(directory, "in");
const output = join(directory, "out");
try {
const shell = spawnSync(
"/bin/sh",
["-c", FIFO_LAUNCH, "sh", input, output, executable, ...args],
{ stdio: ["ignore", "pipe", "inherit"], encoding: "utf8" },
);
const pid = Number(shell.stdout?.trim());
if (shell.status !== 0 || !Number.isSafeInteger(pid) || pid <= 0)
throw new Error(`Unable to start TypeScript server: ${executable}`);
this.pid = pid;
// A non-blocking open fails with ENXIO until the server opens its end,
// so a server that exits as it starts cannot block this process.
let probe = -1;
for (let waited = 0; probe < 0; waited++) {
try {
probe = openSync(input, constants.O_WRONLY | constants.O_NONBLOCK);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENXIO") throw error;
if (waited >= FIFO_OPEN_TIMEOUT || !processAlive(pid))
throw new Error(`Unable to start TypeScript server: ${executable}`);
Atomics.wait(sleepBuffer, 0, 0, 1);
}
}
try {
// The server reads now, so a blocking open returns at once.
this.writeFd = openSync(input, "w");
} finally {
closeSync(probe);
}
// The server's shell opens its output right after its input.
this.readFd = openSync(output, "r");
track(this);
} catch (error) {
this.close();
throw error;
} finally {
// Both ends are open, or the start failed: the names are not needed.
rmSync(directory, { recursive: true, force: true });
}
}
read = (buffer: Uint8Array, offset: number, length: number): number => {
if (this.closed) throw new Error("TypeScript server transport is closed");
return transfer(readSync, this.readFd, buffer, offset, length);
};
write = (buffer: Uint8Array, offset: number, length: number): number => {
if (this.closed) throw new Error("TypeScript server transport is closed");
return transfer(writeSync, this.writeFd, buffer, offset, length);
};
/** Closing both FIFOs ends the session: the server reads end of file on
* its input and exits, and a write to its closed output ends it with
* SIGPIPE. It is never signaled, because once init has reaped it its PID
* can belong to another process. */
close = (): void => {
if (this.closed) return;
this.closed = true;
untrack(this);
for (const fd of [this.writeFd, this.readFd]) {
if (fd === -1) continue;
try {
closeSync(fd);
} catch {
/* close the other end too */
}
}
this.writeFd = -1;
this.readFd = -1;
};
}
export function spawnTs7Wire(executable: string, args: string[]): Ts7Wire {
return new Ts7Wire(new Ts7ProcessIo(executable, args));
return new Ts7Wire(
process.platform === "win32"
? new Ts7NamedPipeIo(executable, args)
: new Ts7FifoIo(executable, args),
);
}
+32 -3
View File
@@ -1,7 +1,7 @@
import { createRequire } from "node:module";
import { setShapeOverlayMemoDir, shapeOverlayEnabled } from "./frontend/shape-inference/index.js";
import { jsInferenceEnabled } from "./frontend/js-inference.js";
import { nodeFrontendServices } from "./frontend/services-node.js";
import { createNodeFrontendServices, nodeFrontendServices } from "./frontend/services-node.js";
import {
runTypePatchCommand,
type TypePatchCommandRequest,
@@ -173,7 +173,8 @@ import { serializeModuleParts } from "./ir/serialize.js";
import { readTextParts, writeTextParts } from "./text-parts.js";
import { validateModule } from "./ir/validate.js";
import { compilerOwnedFileRoots } from "./frontend/dts-paths.js";
import { loadProgram } from "./frontend/program-node.js";
import { loadProgramWithServices } from "./frontend/program-node.js";
import { programHostLoads } from "./frontend/services.js";
import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js";
import {
type ProvenanceSources,
@@ -485,7 +486,35 @@ const nodeFrontend: FrontendFactory = (
externalTypes,
libraryNpmStatic,
options,
) => runFrontend(entry, loadProgram, npmStatic, externalTypes, libraryNpmStatic, options);
) => {
// One syntax parser for every load of this compile, and program hosts
// that serve several loads (FrontendServices.reuseProgramHosts).
const services = createNodeFrontendServices();
try {
services.reuseProgramHosts(programHostLoads());
const frontend = runFrontend(
entry,
(path, loadOptions) => loadProgramWithServices(path, services, loadOptions),
npmStatic,
externalTypes,
libraryNpmStatic,
options,
);
return {
...frontend,
dispose: () => {
try {
frontend.dispose();
} finally {
services.close();
}
},
};
} catch (error) {
services.close();
throw error;
}
};
/** The checker's part of the executable cache key (`checkerCacheIdentity`), as the CLI's startup route computes it. */
function checkerField(): { checker?: string } {
@@ -0,0 +1,71 @@
/* Program host reuse (FrontendServices.reuseProgramHosts) must not change
* a compile: a build whose loads share program hosts emits the same IR,
* the same diagnostics and the same exit status as a build with a new host
* for every load (SCRIPTC_TS7_HOST_LOADS=1). Share 2 replaces each host after
* two loads, so a fixpoint of several loads exercises both a rebind and a
* replacement. The npm cases are the frontend's multi-load builds: each
* imports packages, so its build loads the program at least twice. */
import { execFile } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterAll, beforeEach, describe, expect, test } from "vitest";
import { npmCases } from "./npm-cases.js";
import { skipUnownedTest } from "./shard.js";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const cli = join(repoRoot, "packages/cli/dist/bootstrap.js");
const out = mkdtempSync(join(tmpdir(), "scriptc-host-reuse-"));
afterAll(() => rmSync(out, { recursive: true, force: true }));
interface Build {
ir: string | null;
stderr: string;
status: number;
}
async function build(entry: string, name: string, loads: string): Promise<Build> {
const ir = join(out, `${name}.${loads}.ir.json`);
const env = {
...process.env,
SCRIPTC_TS7_HOST_LOADS: loads,
SCRIPTC_CACHE_DIR: join(out, `cache-${loads}`),
};
try {
const { stderr } = await execFileAsync(
process.execPath,
["--max-old-space-size=8192", cli, "build", entry, "--emit=ir", "-o", ir],
{ env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
);
return { ir: readFileSync(ir, "utf8"), stderr, status: 0 };
} catch (error) {
const e = error as { code?: unknown; stderr?: string };
if (typeof e.code !== "number") throw error;
return { ir: null, stderr: e.stderr ?? "", status: e.code };
}
}
// Every sixth case by name (all of them with SCRIPTC_HOST_REUSE_PARITY_ALL=1).
const all = process.env["SCRIPTC_HOST_REUSE_PARITY_ALL"] === "1";
const cases = npmCases(join(repoRoot, "tests/fixtures"))
.sort((a, b) => a.name.localeCompare(b.name))
.filter((_, index) => all || index % 6 === 0);
describe("program host reuse parity", () => {
beforeEach((context) => skipUnownedTest(context, {}));
for (const { name, entry } of cases) {
test(
name,
async () => {
const fresh = await build(entry, name, "1");
const reused = await build(entry, name, "2");
expect(reused.status).toBe(fresh.status);
expect(reused.stderr).toBe(fresh.stderr);
expect(reused.ir === fresh.ir).toBe(true);
},
600_000,
);
}
});