diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d311c3b46..ab35020b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,8 @@ jobs: run: | node packages/cli/dist/main.js build tests/corpus/001-hello.ts -o "$RUNNER_TEMP/hello" test "$("$RUNNER_TEMP/hello")" = 'hello world' + - name: Linux release runtime reachability and debug payload + run: pnpm test tests/harness/runtime-tree-shaking.test.ts - name: Debian 12 glibc 2.36 runtime-pack smoke run: | docker run --rm --volume "$GITHUB_WORKSPACE:/work:ro" --workdir /work node:24-bookworm-slim sh -ec ' diff --git a/packages/compiler/src/backend/targets.test.ts b/packages/compiler/src/backend/targets.test.ts index 1fe162e75..6eb585cb9 100644 --- a/packages/compiler/src/backend/targets.test.ts +++ b/packages/compiler/src/backend/targets.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { IOS_ARM64_TARGET, IOS_SIMULATOR_ARM64_TARGET, @@ -19,7 +19,46 @@ import { windowsSubsystemLinkerArgs, } from "./targets.js"; +afterEach(() => vi.restoreAllMocks()); + describe("native code-generation targets", () => { + test("non-Linux and unsupported hosts never collect a process report", async () => { + vi.resetModules(); + const targets = await import("./targets.js"); + const report = vi.spyOn(process.report, "getReport").mockImplementation(() => { + throw new Error("unexpected host libc probe"); + }); + for (const [platform, arch, release] of [ + ["darwin", "arm64", "24.0.0"], ["darwin", "x64", "24.0.0"], ["win32", "x64", "10.0.0"], + ] as const) { + expect(targets.nativeCodegenTarget({}, platform, arch, release)).not.toBeNull(); + expect(targets.nativeCodegenTargetRefusal({}, platform, arch, release)).toBeNull(); + expect(targets.nativeHelperForTarget(LINUX_X64_GNU_TARGET, platform, arch)).not.toBeNull(); + } + expect(targets.nativeCodegenTarget({}, "linux", "ia32", "6.8.0")).toBeNull(); + expect(targets.nativeHelperForTarget(LINUX_X64_GNU_TARGET, "linux", "ia32")).toBeNull(); + expect(report).not.toHaveBeenCalled(); + }); + + test.each(["gnu", "musl"] as const)("probes the %s host libc once across target and helper selection", async (libc) => { + vi.resetModules(); + const targets = await import("./targets.js"); + const report = vi.spyOn(process.report, "getReport").mockReturnValue({ + header: libc === "gnu" ? { glibcVersionRuntime: "2.36" } : {}, + } as ReturnType); + const target = libc === "gnu" ? LINUX_X64_GNU_TARGET : LINUX_X64_MUSL_TARGET; + expect(targets.nativeCodegenTarget({}, "linux", "x64", "6.8.0")).toEqual(target); + expect(targets.nativeCodegenTargetRefusal({}, "linux", "x64", "6.8.0")).toBeNull(); + expect(targets.nativeHelperForTarget(target, "linux", "x64")?.packageName).toBe(`@scriptc/llvm-linux-x64-${libc}`); + expect(targets.nativeCodegenTarget({ SCRIPTC_TARGET: "wasm32-wasi" }, "linux", "x64", "6.8.0")).toEqual(WASM32_WASI_TARGET); + expect(report).toHaveBeenCalledTimes(1); + + // Explicit host descriptions remain independent of the process memo. + expect(targets.nativeCodegenTarget({}, "linux", "x64", "6.8.0", "gnu")).toEqual(LINUX_X64_GNU_TARGET); + expect(targets.nativeCodegenTarget({}, "linux", "x64", "6.8.0", "musl")).toEqual(LINUX_X64_MUSL_TARGET); + expect(report).toHaveBeenCalledTimes(1); + }); + test("matches Clang's narrow integer ABI independently of the build host", () => { for (const target of [MACOS_ARM64_TARGET, MACOS_X64_TARGET, LINUX_X64_GNU_TARGET, LINUX_X64_MUSL_TARGET, WASM32_WASI_TARGET]) { expect(ffiExtendsNarrowIntegers(target.llvmTriple, "win32", "x64")).toBe(true); diff --git a/packages/compiler/src/backend/targets.ts b/packages/compiler/src/backend/targets.ts index 1824f986f..96d914117 100644 --- a/packages/compiler/src/backend/targets.ts +++ b/packages/compiler/src/backend/targets.ts @@ -340,24 +340,29 @@ export const NATIVE_TARGETS = [ IOS_ARM64_TARGET, IOS_SIMULATOR_ARM64_TARGET, ANDROID_ARM64_TARGET, ] as const; +let linuxLibcMemo: LinuxLibc | undefined; + function detectedLinuxLibc(): LinuxLibc { + if (linuxLibcMemo !== undefined) return linuxLibcMemo; // Node exposes glibc's runtime version without any external command or // filesystem probe. Its absence on Linux is the portable musl signal used - // by npm's own optional-dependency selection conventions. + // by npm's own optional-dependency selection conventions. The host libc + // cannot change within this process, and generating a full report is costly. const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } } | undefined; const header = report?.header; - return typeof header?.glibcVersionRuntime === "string" ? "gnu" : "musl"; + linuxLibcMemo = typeof header?.glibcVersionRuntime === "string" ? "gnu" : "musl"; + return linuxLibcMemo; } function helperHost( platform: NodeJS.Platform, arch: string, - linuxLibc: LinuxLibc = detectedLinuxLibc(), + linuxLibc?: LinuxLibc, ): NativeHelperHost | null { if (platform === "darwin" && arch === "arm64") return "darwin-arm64"; if (platform === "darwin" && arch === "x64") return "darwin-x64"; - if (platform === "linux" && arch === "x64") return `linux-x64-${linuxLibc}`; - if (platform === "linux" && arch === "arm64") return `linux-arm64-${linuxLibc}`; + if (platform === "linux" && arch === "x64") return `linux-x64-${linuxLibc ?? detectedLinuxLibc()}`; + if (platform === "linux" && arch === "arm64") return `linux-arm64-${linuxLibc ?? detectedLinuxLibc()}`; if (platform === "win32" && arch === "x64") return "win32-x64"; return null; } @@ -385,19 +390,18 @@ function nativeHostTarget( hostPlatform: NodeJS.Platform, hostArch: string, hostRelease: string, - linuxLibc: LinuxLibc, + linuxLibc?: LinuxLibc, ): NativeTargetSpec | null { if (hostPlatform === "darwin") { const major = Number.parseInt(hostRelease.split(".", 1)[0] ?? "", 10); if (!Number.isFinite(major) || major < 24) return null; return hostArch === "arm64" ? MACOS_ARM64_TARGET : hostArch === "x64" ? MACOS_X64_TARGET : null; } - if (hostPlatform === "linux") { + if (hostPlatform === "linux" && (hostArch === "x64" || hostArch === "arm64")) { + const libc = linuxLibc ?? detectedLinuxLibc(); return hostArch === "x64" - ? linuxLibc === "musl" ? LINUX_X64_MUSL_TARGET : LINUX_X64_GNU_TARGET - : hostArch === "arm64" - ? linuxLibc === "musl" ? LINUX_ARM64_MUSL_TARGET : LINUX_ARM64_GNU_TARGET - : null; + ? libc === "musl" ? LINUX_X64_MUSL_TARGET : LINUX_X64_GNU_TARGET + : libc === "musl" ? LINUX_ARM64_MUSL_TARGET : LINUX_ARM64_GNU_TARGET; } return hostPlatform === "win32" && hostArch === "x64" ? WINDOWS_X64_MSVC_TARGET : null; } @@ -442,7 +446,7 @@ export function nativeCodegenTarget( hostPlatform: NodeJS.Platform = process.platform, hostArch: string = process.arch, hostRelease: string = release(), - linuxLibc: LinuxLibc = detectedLinuxLibc(), + linuxLibc?: LinuxLibc, ): NativeTargetSpec | null { const host = nativeHostTarget(hostPlatform, hostArch, hostRelease, linuxLibc); const target = requestedTarget(env["SCRIPTC_TARGET"] ?? "", host, hostPlatform); @@ -466,7 +470,7 @@ export function nativeCodegenTargetRefusal( hostPlatform: NodeJS.Platform = process.platform, hostArch: string = process.arch, hostRelease: string = release(), - linuxLibc: LinuxLibc = detectedLinuxLibc(), + linuxLibc?: LinuxLibc, ): string | null { if (nativeCodegenTarget(env, hostPlatform, hostArch, hostRelease, linuxLibc) !== null) return null; const requested = env["SCRIPTC_TARGET"] ?? ""; diff --git a/packages/compiler/src/frontend/comptime-node.ts b/packages/compiler/src/frontend/comptime-node.ts index 8331f8863..ee6bb1c2d 100644 --- a/packages/compiler/src/frontend/comptime-node.ts +++ b/packages/compiler/src/frontend/comptime-node.ts @@ -1,10 +1,13 @@ import vm from "node:vm"; -import ts5 from "typescript5"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); /** The Node client's compile-time evaluator. Only source text and the * resulting value cross this boundary; TS5 ASTs never enter the frontend. * Every invocation gets fresh globals and a bounded execution window. */ export function evaluateNodeComptime(source: string, timeoutMs: number): unknown { + const ts5 = require("typescript5") as typeof import("typescript5"); const js = ts5.transpileModule(`(${source})()`, { compilerOptions: { target: ts5.ScriptTarget.ESNext }, }).outputText; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 5834af8fa..cc400ec92 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3423,8 +3423,12 @@ export class Lowerer { } } } - node.arguments.forEach((arg, i) => { - const callbackSlot = sig.params[i]?.type; + const restAt = sig.params.findIndex((shape) => + shape.mode === "rest" || shape.mode === "dynRest" || shape.mode === "islandRest" || shape.mode === "arguments"); + node.arguments.forEach((arg, argumentIndex) => { + const i = restAt >= 0 && argumentIndex >= restAt ? restAt : argumentIndex; + const shape = sig.params[i]; + const callbackSlot = shape?.type; if (callbackSlot?.kind === "func" && !ts.isSpreadElement(arg)) { const optionalCallbackParams = callbackSlot.params.flatMap((type, index) => type.kind === "union" && this.armTag(type.unionId, UNDEFINED_T) >= 0 ? [index] : []); @@ -3432,9 +3436,28 @@ export class Lowerer { changed = true; } } - if (ts.isSpreadElement(arg) || !mayBeOptional(arg) || !sig.params[i]) return; + if (ts.isSpreadElement(arg) || !mayBeOptional(arg) || !shape) return; + if (i === restAt) { + // A rest binding always receives an array. An unchecked + // argument can make its elements undefined, not the pack. + // Keep every virtual implementation on the same array ABI. + for (const target of familyBySymbol.get(symbol) ?? [symbol]) { + const rest = signatureBySymbol.get(target)?.params[i]; + if (rest?.mode !== "rest" || rest.type.kind !== "array") continue; + const widened = arrayOf(addUndefined(rest.type.elem)); + if (typeEquals(rest.type, widened)) continue; + rest.type = widened; + const parameter = functionDeclBySymbol.get(target)?.parameters[i]; + if (parameter && ts.isIdentifier(parameter.name)) { + const bound = symbolOf(parameter.name); + if (bound) this.runtimeOptionalBindingTypes.set(bound, widened); + } + changed = true; + } + return; + } const set = optionalParams.get(symbol) ?? new Set(); - sig.params[i]!.type = addUndefined(sig.params[i]!.type); + shape.type = addUndefined(shape.type); const before = set.size; set.add(i); optionalParams.set(symbol, set); diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index d09e2a735..015839c74 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -972,10 +972,6 @@ async function compileTracked( sourceTexts: new Map(), }; } - if (outputKind === "exe" && opts.sanitize !== true && process.env["SCRIPTC_FETCH_CURL"] !== "1") { - const refusal = nativeCodegenTargetRefusal(); - if (refusal !== null) return { ok: false, diagnostics: [nativeCodegenDiag("SC3002", refusal, entryPath)], sourceTexts: new Map() }; - } let ffi: FfiProfile | null = null; let ffiProfileBytes: Uint8Array | null = null; if (opts.ffiProfilePath !== undefined) { @@ -1080,6 +1076,10 @@ async function compileTracked( }; } } + if (outputKind === "exe" && opts.sanitize !== true && process.env["SCRIPTC_FETCH_CURL"] !== "1") { + const refusal = nativeCodegenTargetRefusal(); + if (refusal !== null) return { ok: false, diagnostics: [nativeCodegenDiag("SC3002", refusal, entryPath)], sourceTexts: new Map() }; + } const cacheRoot = outputKind === "exe" && provenanceSources() === null ? await prepareBuildCacheRoot(buildCacheRoot()) : null; diff --git a/packages/compiler/src/library/semantic-source.ts b/packages/compiler/src/library/semantic-source.ts index 54bc9845b..a2892e4d1 100644 --- a/packages/compiler/src/library/semantic-source.ts +++ b/packages/compiler/src/library/semantic-source.ts @@ -1,7 +1,10 @@ import { InternalCompilerError } from "../errors.js"; import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; import { extname } from "node:path"; -import ts from "typescript5"; +import type ts from "typescript5"; + +const require = createRequire(import.meta.url); interface SemanticToken { kind: "token" | "comment"; @@ -32,15 +35,16 @@ function semanticTokens(path: string, source: string): SemanticToken[] | null { // both syntax validity (including a shebang's byte-zero requirement) and // exact regular-expression spans, then keep the cheap scanner for trivia // equivalence and location mapping. - const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true); + const parser = require("typescript5") as typeof ts; + const sourceFile = parser.createSourceFile(path, source, parser.ScriptTarget.Latest, true); const parseDiagnostics = (sourceFile as ts.SourceFile & { parseDiagnostics: readonly ts.Diagnostic[]; }).parseDiagnostics; if (parseDiagnostics.length > 0) return null; const regexEnds = new Map(); const collectRegex = (node: ts.Node): void => { - if (ts.isRegularExpressionLiteral(node)) regexEnds.set(node.getStart(sourceFile), node.end); - ts.forEachChild(node, collectRegex); + if (parser.isRegularExpressionLiteral(node)) regexEnds.set(node.getStart(sourceFile), node.end); + parser.forEachChild(node, collectRegex); }; collectRegex(sourceFile); diff --git a/packages/compiler/test/startup.test.ts b/packages/compiler/test/startup.test.ts new file mode 100644 index 000000000..ecc39eb39 --- /dev/null +++ b/packages/compiler/test/startup.test.ts @@ -0,0 +1,62 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +const compiler = new URL("../src/index.ts", import.meta.url).href; +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const prelude = ` + import assert from "node:assert/strict"; + import { createRequire } from "node:module"; + const require = createRequire(${JSON.stringify(compiler)}); + const legacyParser = require.resolve("typescript5"); + assert.equal(require.cache[legacyParser], undefined); +`; + +function freshProcess(source: string, args: string[] = []): void { + const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", prelude + source, ...args], { + cwd: root, encoding: "utf8", timeout: 60_000, + }); + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); +} + +test("ordinary compilation does not initialize the legacy parser", () => { + const directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-startup-")); + const entry = join(directory, "main.ts"); + writeFileSync(entry, "const answer: number = 42; console.log(answer);\n"); + try { + freshProcess(` + const { compile } = await import(${JSON.stringify(compiler)}); + assert.equal(require.cache[legacyParser], undefined, "compiler import loaded TypeScript 5"); + const result = await compile(process.argv[1], { + outputKind: "llvm", outDir: process.argv[2], outPath: process.argv[3], + }); + assert.equal(result.ok, true, JSON.stringify(result.diagnostics)); + assert.equal(require.cache[legacyParser], undefined, "ordinary compilation loaded TypeScript 5"); + `, [entry, directory, join(directory, "main.ll")]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test.each([ + ["compile-time evaluation", "frontend/comptime-node.ts", ` + assert.equal(api.evaluateNodeComptime("(): number => 21 * 2", 1000), 42); + `], + ["semantic source comparison", "library/semantic-source.ts", ` + assert.equal(api.semanticallyEqualSource("entry.ts", "const n = 1;", "// note\\nconst n = 1;"), true); + assert.equal(api.semanticallyEqualSource("entry.ts", "const n = 1;", "const n = 2;"), false); + `], +])("%s loads the legacy parser on first use", (_name, module, operation) => { + freshProcess(` + const api = await import(${JSON.stringify(new URL(`../src/${module}`, import.meta.url).href)}); + assert.equal(require.cache[legacyParser], undefined); + ${operation} + assert.ok(require.cache[legacyParser], "the deferred parser was not loaded"); + `); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index cb04950fe..c097dab03 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11703,6 +11703,42 @@ "/tests/corpus/llvm-read-receiver-lifetime.ts" ], "diags": [] + }, + "/tests/corpus/class-brand-overload.js": { + "order": [ + "/tests/corpus/class-brand-overload.js" + ], + "diags": [] + }, + "/tests/corpus/class-prototype-data.js": { + "order": [ + "/tests/corpus/class-prototype-data.js" + ], + "diags": [] + }, + "/tests/corpus/js-omitted-typed-arguments.js": { + "order": [ + "/tests/corpus/js-omitted-typed-arguments.js" + ], + "diags": [] + }, + "/tests/corpus/math-function-values.js": { + "order": [ + "/tests/corpus/math-function-values.js" + ], + "diags": [] + }, + "/tests/diagnostics/class-prototype-reflection.ts": { + "order": [ + "/tests/diagnostics/class-prototype-reflection.ts" + ], + "diags": [] + }, + "/tests/corpus/rest-optional-array-arguments.ts": { + "order": [ + "/tests/corpus/rest-optional-array-arguments.ts" + ], + "diags": [] } } } diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 970e6bd5a..d23ffe40f 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -83,6 +83,9 @@ async function build() { const flavors = {}; for (const [flavor, flavorSpec] of Object.entries(matrix.flavors)) { process.stdout.write(`building ${packageManifest.name} ${flavor} runtime\n`); + // Zig emits DWARF by default, including descriptions of functions + // removed by section GC. Release packs must opt out explicitly. + const debugFlags = flavor.endsWith("release") ? ["-g0"] : []; const units = []; for (const unit of flavorSpec.runtime_units ?? matrix.runtime_units) { const variants = []; @@ -94,7 +97,7 @@ async function build() { ...(unit.source === "scr_tls.c" ? ["-I", join(mbedtls, "include")] : []), ...(unit.source === "scr_zlib.c" || unit.source === "scr_fetch.c" ? ["-I", zlib] : []), ]; - await compile(join(runtimeSrc, unit.source), output, [...commonFlags, flavorSpec.optimization, ...variant.defines.map((define) => `-D${define}`), ...includeFlags]); + await compile(join(runtimeSrc, unit.source), output, [...commonFlags, flavorSpec.optimization, ...debugFlags, ...variant.defines.map((define) => `-D${define}`), ...includeFlags]); variants.push({ id: variant.id, when: variant.when, defines: variant.defines, path: artifactPath(output), sha256: await sha256(output), size: (await stat(output)).size }); } units.push({ source: unit.source, predicate: unit.predicate, variants }); @@ -108,6 +111,7 @@ async function build() { const vendorTarget = [ ...config.targetArgs, ...(config.compilerFlags ?? []), + "-g0", ...(config.runtimeDefines ?? []).map((define) => `-D${define}`), ]; const requestedArchives = new Set(matrix.archives.map((entry) => entry.id)); diff --git a/scripts/sandbox-command.mjs b/scripts/sandbox-command.mjs index 5efaa907c..be9d186aa 100644 --- a/scripts/sandbox-command.mjs +++ b/scripts/sandbox-command.mjs @@ -8,9 +8,13 @@ export const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; * same script records the remote exit status for either transport. */ export function sandboxCommand(command, args, exitMarker) { const statusPath = `/tmp/${exitMarker}.status`; + const logPath = `/tmp/${exitMarker}.log`; + const pendingStatus = statusPath + ".pending"; const script = - `${[command, ...args].map(shellQuote).join(" ")}; scriptc_status=$?; ` + - `printf '%s\\n' "$scriptc_status" > ${shellQuote(statusPath)}; ` + + `( ${[command, ...args].map(shellQuote).join(" ")}; scriptc_status=$?; ` + + `printf '%s\\n' "$scriptc_status" > ${shellQuote(pendingStatus)} ) 2>&1 | tee ${shellQuote(logPath)}; ` + + `mv ${shellQuote(pendingStatus)} ${shellQuote(statusPath)}; ` + + `scriptc_status=$(cat ${shellQuote(statusPath)}); ` + `printf '\\n${exitMarker}%s\\n' "$scriptc_status"`; const scriptPath = `/tmp/${exitMarker}.sh`; const file = Buffer.byteLength(script, "utf8") > MAX_INLINE_SANDBOX_COMMAND_BYTES; @@ -18,6 +22,7 @@ export function sandboxCommand(command, args, exitMarker) { script, scriptPath, statusPath, + logPath, file, argv: file ? ["sh", scriptPath] : ["sh", "-c", script], }; diff --git a/scripts/sandbox-config.mjs b/scripts/sandbox-config.mjs index fffb8bf78..8a90d6f12 100644 --- a/scripts/sandbox-config.mjs +++ b/scripts/sandbox-config.mjs @@ -21,12 +21,20 @@ function loadLocalEnv() { export function sandboxRunnerConfig(env) { loadLocalEnv(); const source = env ?? process.env; + const sandboxTimeout = source.SCRIPTC_SANDBOX_TIMEOUT ?? "45m"; + const duration = /^(\d+)\s*(ms|s|m|h)$/.exec(sandboxTimeout.trim()); + const units = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }; + const sandboxTimeoutMs = duration ? Number(duration[1]) * units[duration[2]] : 0; + if (!Number.isSafeInteger(sandboxTimeoutMs) || sandboxTimeoutMs <= 0 || sandboxTimeoutMs > 2_147_483_647) { + throw new Error("SCRIPTC_SANDBOX_TIMEOUT must be a positive duration such as 45m or 2h"); + } return { vcpus: source.SCRIPTC_SANDBOX_VCPUS ?? "8", testWorkers: source.SCRIPTC_TEST_WORKERS ?? "4", localTestWorkers: source.SCRIPTC_LOCAL_TEST_WORKERS ?? "2", localCaseShards: source.SCRIPTC_LOCAL_CASE_SHARDS ?? "2", - sandboxTimeout: source.SCRIPTC_SANDBOX_TIMEOUT ?? "45m", + sandboxTimeout, + sandboxTimeoutMs, }; } diff --git a/scripts/sandbox-test.mjs b/scripts/sandbox-test.mjs index 67b0508ef..58572f83a 100644 --- a/scripts/sandbox-test.mjs +++ b/scripts/sandbox-test.mjs @@ -206,6 +206,7 @@ const { localTestWorkers, localCaseShards, sandboxTimeout, + sandboxTimeoutMs, } = sandboxRunnerConfig(); if (!["plain", "san", "both"].includes(values.lane)) { @@ -382,14 +383,14 @@ const execIn = async ( args, env = {}, task = "", - wallTimeoutMs = 15 * 60_000, + wallTimeoutMs = sandboxTimeoutMs, workdir = "/workspace", idleTimeoutMs = 90_000, ) => { const envArgs = Object.entries(env).flatMap(([key, value]) => ["--env", `${key}=${value}`]); const exitMarker = `__SCRIPTC_REMOTE_EXIT_${randomBytes(12).toString("hex")}__`; const prepared = sandboxCommand(command, args, exitMarker); - const { statusPath } = prepared; + const { statusPath, logPath } = prepared; const label = task ? `${worker.label} ${task}` : worker.label; if (prepared.file) { const localScript = join(temp, `${exitMarker}.sh`); @@ -416,6 +417,11 @@ const execIn = async ( ...prepared.argv, ]; const deadline = Date.now() + wallTimeoutMs; + const recoveredLog = async () => { + await vercel(["sandbox", "exec", "--timeout", "1m", "--workdir", workdir, worker.name, "tail", "-n", "160", logPath], { + label: `${label} recovered log`, timeoutMs: 60_000, idleTimeoutMs: 30_000, + }).catch((error) => console.warn(`[${label}] could not recover ${logPath}: ${error.message}`)); + }; try { await vercel(commandArgs, { exitMarker, @@ -428,7 +434,10 @@ const execIn = async ( console.warn(`[${label}] CLI completion was not confirmed (${error.message}); checking the remote command status...`); for (;;) { const remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error(`${label} did not confirm completion before its timeout`, { cause: error }); + if (remaining <= 0) { + await recoveredLog(); + throw new Error(`${label} did not confirm completion before its timeout`, { cause: error }); + } const probeMarker = `__SCRIPTC_REMOTE_PROBE_${randomBytes(12).toString("hex")}__`; const probeScript = sandboxStatusCommand(statusPath, probeMarker, Math.min(20, Math.floor(remaining / 1000))); try { @@ -441,9 +450,13 @@ const execIn = async ( timeoutMs: Math.min(60_000, remaining), }, ); + await recoveredLog(); return; } catch (probeError) { - if (probeError.remoteExitCode !== REMOTE_COMMAND_PENDING) throw probeError; + if (probeError.remoteExitCode !== REMOTE_COMMAND_PENDING) { + await recoveredLog(); + throw probeError; + } console.log(`[${label}] remote command has not recorded completion; waiting...`); } } diff --git a/tests/corpus/rest-optional-array-arguments.ts b/tests/corpus/rest-optional-array-arguments.ts new file mode 100644 index 000000000..546423784 --- /dev/null +++ b/tests/corpus/rest-optional-array-arguments.ts @@ -0,0 +1,50 @@ +// Unchecked array reads widen rest elements while the rest array stays present. +class Collector { + first(...values: number[]): string { + return values.map(value => value === undefined ? "missing" : String(value)).join(","); + } + + later(prefix: string, ...values: number[]): string { + return prefix + values.map(value => value === undefined ? "missing" : String(value)).join(","); + } +} + +const items: number[] = [7]; +const collector = new Collector(); +console.log(collector.first(items[3])); +console.log(collector.first()); +console.log(collector.first(...items)); +console.log(collector.later("values:", 1, items[3], 2)); +console.log(collector.later("empty:")); + +function collect(...values: number[]): string { + return values.map(value => value === undefined ? "missing" : String(value)).join(","); +} +console.log(collect(1, items[3], 2)); +console.log(collect()); + +class Base { + describe(...values: number[]): string { + return "base:" + values.map(value => value === undefined ? "missing" : String(value)).join(","); + } +} +class Derived extends Base { + describe(...values: number[]): string { + return "derived:" + values.map(value => value === undefined ? "missing" : String(value)).join(","); + } +} +function describe(receiver: Base): string { + return receiver.describe(1, items[3], 2); +} +console.log(describe(new Base())); +console.log(describe(new Derived())); + +interface Node { marked?: boolean } +class Gate { + blocked(...nodes: Node[]): boolean { + return nodes.some(node => node?.marked === true); + } +} +const nodes: Node[] = [{ marked: true }]; +const gate = new Gate(); +console.log(gate.blocked(nodes[3]), gate.blocked(nodes[0]!), gate.blocked()); diff --git a/tests/harness/library-multi.test.ts b/tests/harness/library-multi.test.ts index eaf70e402..f96bb293c 100644 --- a/tests/harness/library-multi.test.ts +++ b/tests/harness/library-multi.test.ts @@ -1071,22 +1071,30 @@ test.each(["aarch64-ios", "x86_64-linux-android", "armv7-linux-androideabi"])( }, ); -test.each(MOBILE_TARGETS)( - "M12: the executable lane refuses %s with the pointer to --lib", - async (target) => { +test.each(MOBILE_TARGETS.flatMap((target) => + ["darwin", "linux", "win32"].map((host) => ({ target, host })), +))( + "M12: the executable lane refuses $target on $host with the pointer to --lib", + async ({ target, host }) => { const outDir = join(cacheDir, `mobile-exe-refusal-${target}`); mkdirSync(outDir, { recursive: true }); const entry = join(outDir, "main.ts"); writeFileSync(entry, 'console.log("hi");\n'); - await withMobileTarget(target, async () => { - const result = await compile(entry, { outDir, outPath: join(outDir, "main") }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.diagnostics[0]!.code).toBe("SC3002"); - expect(result.diagnostics[0]!.message).toContain(target); - expect(result.diagnostics[0]!.message).toContain("scriptc build --lib --profile "); - } - }); + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { ...platformDescriptor, value: host }); + try { + await withMobileTarget(target, async () => { + const result = await compile(entry, { outDir, outPath: join(outDir, "main") }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics[0]!.code).toBe("SC3002"); + expect(result.diagnostics[0]!.message).toContain(target); + expect(result.diagnostics[0]!.message).toContain("scriptc build --lib --profile "); + } + }); + } finally { + Object.defineProperty(process, "platform", platformDescriptor); + } }, ); diff --git a/tests/harness/runtime-tree-shaking.test.ts b/tests/harness/runtime-tree-shaking.test.ts index 1af62f354..247ba3db3 100644 --- a/tests/harness/runtime-tree-shaking.test.ts +++ b/tests/harness/runtime-tree-shaking.test.ts @@ -1,9 +1,6 @@ -/* Executable runtime reachability. The source-runtime recipe intentionally - * still includes the complete historical base for direct compileC callers; - * executable section GC is what makes its unused functions/data disappear. - * These fixtures therefore pin both halves of that contract: hello has no - * reachable members from the formerly-unavoidable families, while every - * feature fixture retains an anchor and behaves exactly like Node. */ +/* Executable runtime reachability. Section GC removes unused code from the + * precompiled runtime pack. Hello must discard unreachable families, while + * each feature fixture retains its runtime anchor and matches Node. */ import { execFile } from "node:child_process"; import { createServer } from "node:http"; import { mkdirSync, statSync, writeFileSync } from "node:fs"; @@ -16,7 +13,7 @@ const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests/runtime-tree-shaking"); // The executable and symbol contracts use POSIX tools and /bin/echo. -const sourceToolchainTest = process.platform === "win32" ? test.skip : test; +const nativeToolchainTest = process.platform === "win32" ? test.skip : test; interface Fixture { name: string; @@ -25,6 +22,11 @@ interface Fixture { } const FIXTURES: Fixture[] = [ + { + name: "regex", + source: `console.log(/world/.test("hello world"));\n`, + anchor: "scr_regex_test", + }, { name: "child", source: `import { spawnSync } from "node:child_process"; @@ -89,8 +91,6 @@ async function build(name: string, source: string) { const result = await compile(sourcePath, { outPath: join(outDir, "program"), outDir, - // The C lane proves source-toolchain linking. macOS additionally runs - // the default lane below, which selects the helper/runtime-pack path. backend: "llvm", }); if (!result.ok) { @@ -110,6 +110,12 @@ async function symbols(binaryPath: string): Promise { return output("nm", [binaryPath]); } +async function expectNoReleaseDebugInfo(binaryPath: string): Promise { + if (process.platform !== "linux") return; + const sections = await output("readelf", ["--section-headers", "--wide", binaryPath]); + expect(sections).not.toMatch(/\.(?:z?debug_|stab)/); +} + async function expectNodeParity(sourcePath: string, binaryPath: string, args: string[] = []): Promise { const [node, native] = await Promise.all([ output(process.execPath, [sourcePath, ...args]), @@ -118,10 +124,11 @@ async function expectNodeParity(sourcePath: string, binaryPath: string, args: st expect(native).toBe(node); } -sourceToolchainTest("static hello strips unreachable runtime families while feature programs retain them", async () => { +nativeToolchainTest("static hello strips unreachable runtime families while feature programs retain them", async () => { const helloSource = `console.log("hello", "world");\n`; const hello = await build("hello", helloSource); await expectNodeParity(hello.sourcePath, hello.binaryPath); + await expectNoReleaseDebugInfo(hello.binaryPath); const helloSymbols = await symbols(hello.binaryPath); for (const family of [ "scr_path_win32_", @@ -139,6 +146,7 @@ sourceToolchainTest("static hello strips unreachable runtime families while feat if (fixture.name === "child" && process.platform === "win32") continue; const result = await build(fixture.name, fixture.source); await expectNodeParity(result.sourcePath, result.binaryPath); + await expectNoReleaseDebugInfo(result.binaryPath); const nativeSymbols = await symbols(result.binaryPath); expect(nativeSymbols, `${fixture.name} lost ${fixture.anchor}`).toContain(fixture.anchor); } @@ -146,14 +154,14 @@ sourceToolchainTest("static hello strips unreachable runtime families while feat // Symbol absence is the primary reachability contract. Keep a deliberately // roomy, platform-specific hello-world ceiling too: it catches losing // section GC without pinning an exact linker/SDK byte count. The canonical - // Linux C build is about 41KB and current Mach-O builds are about 70KB; + // Linux build is about 41KB and current Mach-O builds are about 70KB; // these limits leave several native pages of linker-version slack while // remaining far below the former roughly-400KB always-linked runtime. const helloSizeLimit = process.platform === "linux" ? 64 * 1024 : 96 * 1024; expect(statSync(hello.binaryPath).size).toBeLessThan(helloSizeLimit); }); -sourceToolchainTest("fetch response JSON retains the URL and parser runtime", async () => { +nativeToolchainTest("fetch response JSON retains the URL and parser runtime", async () => { const server = createServer((_request, response) => { response.setHeader("content-type", "application/json"); response.end('{"ok":true}'); @@ -171,6 +179,7 @@ console.log((await response.json()).ok); }; const result = await build(fixture.name, fixture.source); await expectNodeParity(result.sourcePath, result.binaryPath, [`http://127.0.0.1:${address.port}`]); + await expectNoReleaseDebugInfo(result.binaryPath); const nativeSymbols = await symbols(result.binaryPath); expect(nativeSymbols).toContain("scr_json_parse"); expect(nativeSymbols).toContain("scr_url_release"); @@ -179,9 +188,7 @@ console.log((await response.json()).ok); } }); -// The helper emits a native program object only on supported macOS arm64. -// Its normal backend path links the precompiled runtime pack; run the same -// reachability assertion there so the source-only C lane cannot regress it. +// Also exercise default backend selection on the Darwin shipping host. test.skipIf(process.platform !== "darwin" || process.arch !== "arm64")( "macOS helper/runtime-pack links dead-strip static hello too", async () => { diff --git a/tests/harness/sandbox-command.test.ts b/tests/harness/sandbox-command.test.ts index 6c1ab796b..9a03e67e0 100644 --- a/tests/harness/sandbox-command.test.ts +++ b/tests/harness/sandbox-command.test.ts @@ -15,13 +15,14 @@ afterEach(async () => { test("short commands stay inline and retain the remote exit contract", async () => { const marker = `__SCRIPTC_COMMAND_TEST_${process.pid}_SHORT__`; - const prepared = sandboxCommand("sh", ["-c", "exit 7"], marker); - statuses.push(prepared.statusPath); + const prepared = sandboxCommand("sh", ["-c", "printf 'failure detail\\n' >&2; exit 7"], marker); + statuses.push(prepared.statusPath, prepared.logPath); expect(prepared.file).toBe(false); const output = execFileSync(prepared.argv[0], prepared.argv.slice(1), { encoding: "utf8" }); - expect(output).toBe(`\n${marker}7\n`); + expect(output).toBe(`failure detail\n\n${marker}7\n`); expect(await readFile(prepared.statusPath, "utf8")).toBe("7\n"); + expect(await readFile(prepared.logPath, "utf8")).toBe("failure detail\n"); }); test("uploaded scripts preserve long and shell-sensitive argument bytes", async () => { @@ -37,7 +38,7 @@ test("uploaded scripts preserve long and shell-sensitive argument bytes", async ]; const marker = `__SCRIPTC_COMMAND_TEST_${process.pid}_LONG__`; const prepared = sandboxCommand("printf", ["<%s>\n", ...args], marker); - statuses.push(prepared.statusPath); + statuses.push(prepared.statusPath, prepared.logPath); expect(prepared.file).toBe(true); expect(prepared.argv).toEqual(["sh", prepared.scriptPath]); expect(prepared.argv.every((arg) => arg.length < 128)).toBe(true); @@ -46,6 +47,7 @@ test("uploaded scripts preserve long and shell-sensitive argument bytes", async await writeFile(localScript, prepared.script); const output = execFileSync("sh", [localScript], { encoding: "utf8" }); expect(output).toBe(args.map((arg) => `<${arg}>\n`).join("") + `\n${marker}0\n`); + expect(await readFile(prepared.logPath, "utf8")).toBe(args.map((arg) => `<${arg}>\n`).join("")); await expect(readFile(sentinel)).rejects.toMatchObject({ code: "ENOENT" }); expect(await readFile(prepared.statusPath, "utf8")).toBe("0\n"); }); diff --git a/tests/harness/sandbox-config.test.ts b/tests/harness/sandbox-config.test.ts index d2c8df5cd..71f637ae1 100644 --- a/tests/harness/sandbox-config.test.ts +++ b/tests/harness/sandbox-config.test.ts @@ -30,6 +30,7 @@ test("runner settings are read after loading the sandbox environment", () => { localTestWorkers: "3", localCaseShards: "4", sandboxTimeout: "90m", + sandboxTimeoutMs: 5_400_000, }); }); @@ -40,9 +41,19 @@ test("runner settings retain their documented defaults", () => { localTestWorkers: "2", localCaseShards: "2", sandboxTimeout: "45m", + sandboxTimeoutMs: 2_700_000, }); }); +test("the local command deadline follows the configured Sandbox duration", () => { + for (const [duration, milliseconds] of [["120m", 7_200_000], ["2h", 7_200_000], ["30 s", 30_000], ["500ms", 500]] as const) { + expect(sandboxRunnerConfig({ SCRIPTC_SANDBOX_TIMEOUT: duration }).sandboxTimeoutMs).toBe(milliseconds); + } + for (const duration of ["0m", "-1h", "forever", "1.5m", "999999999h"]) { + expect(() => sandboxRunnerConfig({ SCRIPTC_SANDBOX_TIMEOUT: duration })).toThrow("positive duration"); + } +}); + test("the managed Sandbox image is the default", () => { expect(sandboxImageConfig({})).toEqual({ custom: false, diff --git a/tests/harness/self-hosting-frontend-metadata.test.ts b/tests/harness/self-hosting-frontend-metadata.test.ts index 2ca630bd4..368cc5734 100644 --- a/tests/harness/self-hosting-frontend-metadata.test.ts +++ b/tests/harness/self-hosting-frontend-metadata.test.ts @@ -33,8 +33,8 @@ for (const backend of ["llvm"] as const) { expect(actual.status, actual.stderr).toBe(expected.status); expect(actual.stderr).toBe(expected.stderr); expect(actual.stdout).toBe(expected.stdout); - expect(actual.stdout).toContain('"import":"./cjs.js","require":"./cjs.js"'); - expect(actual.stdout).toContain('./feature/use ./lib/use.js'); + expect(actual.stdout).toContain('"import":"./esm.js","require":"./cjs.js"'); + expect(actual.stdout).toContain('./feature/use ./esm/use.js'); expect(actual.stdout).toContain('false true true 7 99'); expect(actual.stdout).toContain('true lib.es2023.d.ts'); } finally { diff --git a/tests/harness/self-hosting-native-frontend.test.ts b/tests/harness/self-hosting-native-frontend.test.ts index 031248964..8114be278 100644 --- a/tests/harness/self-hosting-native-frontend.test.ts +++ b/tests/harness/self-hosting-native-frontend.test.ts @@ -21,6 +21,12 @@ const execFileAsync = promisify(execFile); const sanitize = process.env["SCRIPTC_SAN"] === "1"; const runOptions = { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" as const }; +function comparableStderr(text: string): string { + return sanitize + ? text.replace(/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext functions and may produce false positives in some cases!\n/gm, "") + : text; +} + const programs = [ "001-hello.ts", "101-arithmetic.ts", @@ -90,7 +96,7 @@ for (const backend of ["llvm"] as const) { expect(result.error, file + "\n" + result.stderr).toBeUndefined(); expect(result.signal, file + "\n" + result.stderr).toBeNull(); expect(result.status, file + "\n" + result.stderr).toBe(0); - expect(result.stderr, file).toBe(""); + expect(comparableStderr(result.stderr), file).toBe(""); } const expectedReport = expected.stdout.trim().split("\n").map((line) => JSON.parse(line)); const actualReport = actual.stdout.trim().split("\n").map((line) => JSON.parse(line)); @@ -129,10 +135,7 @@ for (const backend of ["llvm"] as const) { expect(result.status, file + "\n" + result.stderr).toBe(0); } expect(native.stdout, file).toBe(node.stdout); - const nativeStderr = sanitize - ? native.stderr.replace(/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext functions and may produce false positives in some cases!\n/gm, "") - : native.stderr; - expect(nativeStderr, file).toBe(node.stderr); + expect(comparableStderr(native.stderr), file).toBe(node.stderr); } for (const [file, npmStatic, packages] of [ @@ -216,7 +219,7 @@ for (const backend of ["llvm"] as const) { expect(ffiRun.error).toBeUndefined(); expect(ffiRun.status, ffiRun.stderr).toBe(0); expect(ffiRun.stdout).toBe("42\n"); - expect(ffiRun.stderr).toBe(""); + expect(comparableStderr(ffiRun.stderr)).toBe(""); } finally { rmSync(directory, { recursive: true, force: true }); } }, 1_200_000); }