From 2c69918cfe5161a6025fd0b3c1d8535261a95825 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 30 Oct 2026 03:01:06 -0700 Subject: [PATCH] Reuse checker hosts across the loads of one compile Each npm-static fixpoint round loaded the program with new frontend services, so every load started new tsc-ts processes and parsed the libraries and unchanged package files again. The Node frontend now keeps one FrontendServices per compile, as the native frontend does, and program hosts serve up to four loads: a host its load closes goes back to an idle list, and the next load rebinds it to its own file system shadow and sends its first snapshot with invalidateAll, so the server reads every file again and reuses only identical text. Loads alive at once hold different hosts; timed hosts are never reused. SCRIPTC_TS7_HOST_LOADS=1 restores a new host per load. A rebound host answers as a new host for changed shadows, hidden files, dropped in-memory files and on-disk edits (unit test), npm builds emit identical IR with and without reuse (parity test), and pi's loads after the scout take 1-3 s less each, with identical coverage diagnostics. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/pipeline-native.ts | 3 +- .../compiler/src/frontend/program-node.ts | 28 ++++-- .../compiler/src/frontend/services.test.ts | 68 +++++++++++++- packages/compiler/src/frontend/services.ts | 53 ++++++++++- .../src/frontend/ts7/program-host.test.ts | 94 +++++++++++++++++++ .../compiler/src/frontend/ts7/program-host.ts | 58 +++++++++++- packages/compiler/src/index.ts | 35 ++++++- tests/harness/ts7-host-reuse-parity.test.ts | 71 ++++++++++++++ 8 files changed, 392 insertions(+), 18 deletions(-) create mode 100644 tests/harness/ts7-host-reuse-parity.test.ts diff --git a/packages/compiler/src/frontend/pipeline-native.ts b/packages/compiler/src/frontend/pipeline-native.ts index f2df526084..c6bf0f77fc 100644 --- a/packages/compiler/src/frontend/pipeline-native.ts +++ b/packages/compiler/src/frontend/pipeline-native.ts @@ -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) => diff --git a/packages/compiler/src/frontend/program-node.ts b/packages/compiler/src/frontend/program-node.ts index 8a45f92ee4..60d23efd8c 100644 --- a/packages/compiler/src/frontend/program-node.ts +++ b/packages/compiler/src/frontend/program-node.ts @@ -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 | Readonly> | 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 { + return loadWithServices(entryPath, services, serviceOptions(options)); +} + export function loadProgram( entryPath: string, options?: NodeProgramLoadOptions, ): ReturnType { 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: () => { diff --git a/packages/compiler/src/frontend/services.test.ts b/packages/compiler/src/frontend/services.test.ts index e491b0ac5b..e80565c31a 100644 --- a/packages/compiler/src/frontend/services.test.ts +++ b/packages/compiler/src/frontend/services.test.ts @@ -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", + ); +}); diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 5e493f0baa..ce5e88ed16 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -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(); + } } } diff --git a/packages/compiler/src/frontend/ts7/program-host.test.ts b/packages/compiler/src/frontend/ts7/program-host.test.ts index 2ab6a0293f..5003353feb 100644 --- a/packages/compiler/src/frontend/ts7/program-host.test.ts +++ b/packages/compiler/src/frontend/ts7/program-host.test.ts @@ -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) { + 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, + '/// \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"); +}); diff --git a/packages/compiler/src/frontend/ts7/program-host.ts b/packages/compiler/src/frontend/ts7/program-host.ts index 1b6b7b1035..a61fcffa44 100644 --- a/packages/compiler/src/frontend/ts7/program-host.ts +++ b/packages/compiler/src/frontend/ts7/program-host.ts @@ -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 | 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(); diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index ca9211c121..cb991759af 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -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 } { diff --git a/tests/harness/ts7-host-reuse-parity.test.ts b/tests/harness/ts7-host-reuse-parity.test.ts new file mode 100644 index 0000000000..22284f4266 --- /dev/null +++ b/tests/harness/ts7-host-reuse-parity.test.ts @@ -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 { + 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, + ); + } +});