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, + ); + } +});