mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-09 17:11:46 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
32d4ad0072
commit
2c69918cfe
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,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,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user