Files
scriptc/packages/compiler/src/native/cli.ts
T
Chris Tate 3b374f3201 Check with the shared tsc-ts server by default on Linux and macOS
Builds on Linux and macOS now connect to a shared `tsc-ts --api --listen`
server by default, which keeps the files it parsed and bound, so a later
build in the same working directory does not parse the TypeScript libs and
scriptc's declaration files again. SCRIPTC_CHECKER_SERVER=0 turns it off.
When CI is set to a value other than 0 or false, builds use their own tsc-ts
process unless SCRIPTC_CHECKER_SERVER=1.

- The server exits after 600 seconds without a build (was 300).
- Its name also digests the compiler executable's identity, so each scriptc
  installation and build connects only to servers it started.
- The server process runs in the root directory, so it does not keep the
  first build's directory in use; its --cwd argument is unchanged.
- When the connection to the server fails after it answered, through a
  failed transfer or an ended stream, the native CLI runs the command again
  in a new process with SCRIPTC_CHECKER_SERVER=0 and exits with its status.
- SCRIPTC_CHECKER_SERVER no longer changes the frontend cache key: the
  server's answers are the build's own checker's.

The CLI docs describe the background process, its socket directory, when it
exits and how to stop it.
2026-10-27 08:21:48 -05:00

279 lines
11 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { appendFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCli } from "../cli/command.js";
import type { NativeCacheWarmProfile } from "../cli/host.js";
import {
selectNativeRuntimePack,
stageNativeRuntimeSelection,
} from "../backend/runtime-pack-native.js";
import { mergeNativeProfiles } from "../backend/native-tools.js";
import { setDeclarationRoot } from "../frontend/dts-paths.js";
import { setFrontendDisposedHook } from "../frontend/dispose-hook.js";
import { FrontendServices } from "../frontend/services.js";
import { runTypePatchCommand } from "../frontend/type-patches/command.js";
import { checkerServerLost, createNativeTs7Api } from "../frontend/ts7/native-api.js";
import { resolveProvenanceSourcesWithParser } from "../frontend/provenance-core.js";
import { setCompilerReleaseVersion } from "../library/sidecar.js";
import { NativeCompiler } from "./compiler.js";
import { NATIVE_STRING_BYTES, openNativeCache } from "./cache.js";
import {
checkerExecutableIdentity,
checkerPackageVersion,
distributionPrebuiltLibDiagnostics,
recordPrebuiltLibDiagnostics,
setLibDiagnosticsStore,
} from "../frontend/ts7/lib-diagnostics.js";
import { setCheckerFeatureStore } from "../frontend/ts7/checker-features.js";
import { loadNativeToolchain } from "./toolchain.js";
import { buildSanitizedRuntime } from "./sanitizer.js";
import { runCompilerTask } from "./task.js";
import { processFork } from "../frontend/process-fork.js";
declare function compilerNativePrivateDirectory(path: string, harden: boolean): boolean;
declare function compilerNativeCollectCycles(): void;
declare function compilerNativeFork(): number;
declare function compilerNativeExit(code: number): void;
declare function compilerNativeWait(pid: number): number;
declare function compilerNativeLockOpen(path: string): boolean;
declare function compilerNativeLock(): void;
declare function compilerNativeUnlock(): void;
declare function compilerNativeBoundCycleGrowth(): void;
/** Runs a build. When this process's connection to the shared checker server
* failed during it, the command runs again in a new process that checks with
* its own tsc-ts process (SCRIPTC_CHECKER_SERVER=0), and this process exits
* with that one's status: the new process prints the build's output. */
async function withCheckerFallback<T>(work: () => Promise<T>): Promise<T> {
let result: T;
try {
result = await work();
} catch (error) {
if (!checkerServerLost()) throw error;
return runWithOwnChecker();
}
return checkerServerLost() ? runWithOwnChecker() : result;
}
function runWithOwnChecker(): never {
const run = spawnSync(process.execPath, process.argv.slice(2), {
stdio: "inherit",
env: { ...process.env, SCRIPTC_CHECKER_SERVER: "0" },
});
if (run.error) throw new Error(run.error.message);
process.exit(run.status ?? 1);
}
async function main(): Promise<number> {
compilerNativeBoundCycleGrowth();
setFrontendDisposedHook(() => compilerNativeCollectCycles());
if (process.platform !== "win32")
processFork.primitives = {
fork: () => compilerNativeFork(),
exit: (code) => compilerNativeExit(code),
wait: (pid) => compilerNativeWait(pid),
openLock: (path) => compilerNativeLockOpen(path),
lock: () => compilerNativeLock(),
unlock: () => compilerNativeUnlock(),
};
const manifestPath = process.env["SCRIPTC_TOOLCHAIN"] ?? process.execPath + ".json";
let compiler: NativeCompiler | null = null;
let cache: ReturnType<typeof openNativeCache> = null;
const getCompiler = (): NativeCompiler => {
if (compiler !== null) return compiler;
const toolchain = loadNativeToolchain(manifestPath, process.env);
if (toolchain.declarationsRoot !== undefined) setDeclarationRoot(toolchain.declarationsRoot);
setCompilerReleaseVersion(toolchain.compilerVersion);
cache = openNativeCache((path, harden) => compilerNativePrivateDirectory(path, harden));
if (cache !== null) {
const store = cache;
setLibDiagnosticsStore(
{
read: (key) =>
store.read("lib-diagnostics", key, NATIVE_STRING_BYTES)?.toString("utf8") ?? null,
write: (key, text) => store.write("lib-diagnostics", key, text),
},
checkerExecutableIdentity(toolchain.ts7Executable),
toolchain.prebuiltLibDiagnostics === undefined
? null
: () =>
distributionPrebuiltLibDiagnostics(
toolchain.prebuiltLibDiagnostics!,
toolchain.ts7Executable,
),
);
// A distribution build records its prebuilt lib diagnostics (build-native-cli.mts).
const recordPath = process.env["SCRIPTC_LIB_DIAGNOSTICS_RECORD"];
const checkerVersion =
recordPath === undefined ? null : checkerPackageVersion(toolchain.ts7Executable);
if (recordPath !== undefined && checkerVersion !== null)
recordPrebuiltLibDiagnostics({
checker: checkerVersion,
record: (key) => appendFileSync(recordPath, `${key}\n`),
});
setCheckerFeatureStore({
read: (key) =>
store.read("checker-features", key, NATIVE_STRING_BYTES)?.toString("utf8") ?? null,
write: (key, text) => store.write("checker-features", key, text),
});
}
compiler = new NativeCompiler(toolchain, cache);
return compiler;
};
return runCli(process.argv.slice(2), {
version: () => loadNativeToolchain(manifestPath).compilerVersion,
sourceTargetPlatform: () => getCompiler().toolchain.target.platform,
analyze: (entry, options) =>
withCheckerFallback(() => runCompilerTask(() => getCompiler().analyzeCached(entry, options))),
compile: (entry, options) =>
withCheckerFallback(() => runCompilerTask(() => getCompiler().compile(entry, options))),
compileLibrary: (options) =>
withCheckerFallback(() => runCompilerTask(() => getCompiler().compileLibrary(options))),
resolveProvenanceSources: async (entry) => {
const toolchain = getCompiler().toolchain;
const services = new FrontendServices((options) =>
createNativeTs7Api({ ...options, executable: toolchain.ts7Executable }),
);
try {
return await resolveProvenanceSourcesWithParser(entry, (path, source, kind) =>
services.parse(path, source, kind),
);
} finally {
services.close();
}
},
warmNativeCaches: async (options) => {
const toolchain = getCompiler().toolchain;
if (cache === null) throw new Error("the native build cache is disabled or unavailable");
if (!toolchain.target.supports.exe)
throw new Error(
`native cache warming requires an executable target; ${toolchain.target.name} supports library archives`,
);
const profiles: { profile: NativeCacheWarmProfile; elapsedMs: number }[] = [];
for (const profile of [
...new Set(options.profiles ?? (["runtime", "tls", "dynamic"] as NativeCacheWarmProfile[])),
]) {
const start = performance.now();
const stage = mkdtempSync(join(tmpdir(), "scriptc-warm-"));
try {
const pack = selectNativeRuntimePack(
toolchain.runtimePackRoot,
toolchain.target,
toolchain.compilerVersion,
{
fetch: profile === "tls",
regex: false,
copying: false,
textDecoderLegacy: false,
fileHandle: false,
zlib: false,
assert: false,
inspect: false,
dynInvoke: false,
dc: false,
dynAsync: false,
events: false,
emitter: false,
symbol: false,
bigint: false,
searchParams: false,
qs: false,
utilPromisify: false,
dynBuiltinClass: false,
dynBuiltinStream: false,
parseArgs: false,
stream: false,
net: false,
http: false,
http2: false,
dgram: false,
dnsPromises: false,
dgramDyn: false,
textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
tls: false,
cipher: false,
ecdh: false,
dh: false,
pki: false,
ed25519: false,
x509: false,
webcrypto: false,
perfLoop: false,
tlsCa: false,
},
options.optimization ?? "release",
);
if (options.sanitize) buildSanitizedRuntime(toolchain, pack, stage, "executable", cache);
else stageNativeRuntimeSelection(pack, stage);
} finally {
rmSync(stage, { recursive: true, force: true });
}
profiles.push({ profile, elapsedMs: performance.now() - start });
}
return { cacheRoot: cache.root, profiles };
},
typePatches: async (request) => {
const toolchain = getCompiler().toolchain;
const services = new FrontendServices((options) =>
createNativeTs7Api({ ...options, executable: toolchain.ts7Executable }),
);
try {
return runTypePatchCommand(request, (path, source, kind) =>
services.parse(path, source, kind),
);
} finally {
services.close();
}
},
mergeProfiles: async (outputPath, inputPaths, sparse) => {
const toolchain = getCompiler().toolchain;
mergeNativeProfiles({
executable: toolchain.helperExecutable,
packageRoot: toolchain.helperPackageRoot,
compilerVersion: toolchain.compilerVersion,
target: toolchain.target,
helper: toolchain.helper,
outputPath,
inputPaths,
sparse,
});
},
run: async (binary) => {
const toolchain = getCompiler().toolchain;
let executable = binary;
const args: string[] = [];
if (toolchain.target.platform === "wasi") {
if (toolchain.wasiNodeRunner === undefined)
throw new Error("the WASI runner is missing from this installation");
executable = "node";
args.push("--no-warnings", toolchain.wasiNodeRunner, binary);
}
const result = spawnSync(executable, args, { stdio: "inherit" });
if (result.error) {
if (toolchain.target.platform === "wasi") {
throw new Error(
`could not start the Node.js WASI host: ${result.error.message}; running WASI modules requires Node.js 24 or newer on PATH; use scriptc build to compile without Node`,
);
}
throw new Error(result.error.message);
}
if (result.signal) {
process.stderr.write(`scriptc: program killed by ${result.signal}\n`);
return 1;
}
return result.status ?? 0;
},
});
}
try {
process.exitCode = await main();
} catch (error) {
process.stderr.write(`scriptc: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}