mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 00:25:34 +08:00
- Pin GNU runtime packs to glibc 2.36 Zig targets and reject incompatible imports. - Provision verified Zig builds and exercise published artifacts on Debian Bookworm. Fixes #349 Co-authored-by: Cruel <383999+Cruel@users.noreply.github.com>
1030 lines
35 KiB
JavaScript
1030 lines
35 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { parseArgs } from "node:util";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
sandboxBootstrapCommand,
|
|
sandboxImageConfig,
|
|
sandboxRunnerConfig,
|
|
sandboxTestWorkerAllocation,
|
|
sandboxVercelConfig,
|
|
sandboxVercelEnvironment,
|
|
} from "./sandbox-config.mjs";
|
|
import {
|
|
sandboxHostSchedule,
|
|
sandboxLaneEnv,
|
|
} from "./sandbox-platform.mjs";
|
|
import {
|
|
filterExistingWorktreePaths,
|
|
workspaceResetCommand,
|
|
} from "./worktree-files.mjs";
|
|
import { sandboxCommand, shellQuote } from "./sandbox-command.mjs";
|
|
|
|
const root = fileURLToPath(new URL("../", import.meta.url));
|
|
const laneCaseShardedFiles = [
|
|
"tests/harness/differential.test.ts",
|
|
"tests/harness/llvm-differential.test.ts",
|
|
"tests/harness/npm.test.ts",
|
|
"tests/harness/server.test.ts",
|
|
];
|
|
// Coverage analysis is frontend-only: SCRIPTC_SAN cannot change its result.
|
|
// It still case-shards across the selected lane so every corpus entry is
|
|
// checked, but running the same sweep in the second lane adds no coverage.
|
|
const invariantCaseShardedFiles = ["tests/harness/coverage.test.ts"];
|
|
// Native-cache invalidation cases mutate process-wide compiler inputs and
|
|
// deliberately disable the immutable-toolchain memo used by the differential
|
|
// corpus. Keep them in their own Vitest process, matching CI: co-scheduling
|
|
// this file with the corpus can consume one of the corpus workers with the
|
|
// strict (and intentionally expensive) production probe path.
|
|
//
|
|
// They are sanitizer-invariant and use their own shard variable so the
|
|
// independent cases can spread across the once lane's Sandboxes without
|
|
// colliding with corpus case selection.
|
|
const cacheCaseShardedFiles = ["packages/compiler/src/backend/native-toolchain.test.ts"];
|
|
const caseShardedFiles = [
|
|
...laneCaseShardedFiles,
|
|
...invariantCaseShardedFiles,
|
|
...cacheCaseShardedFiles,
|
|
];
|
|
|
|
// These files neither consume SCRIPTC_SAN nor delegate to a helper that does.
|
|
// Run their full coverage once. Files absent from this allowlist remain in
|
|
// both lanes by default, so a new test never silently loses sanitizer coverage.
|
|
const invariantRemoteFiles = [
|
|
"packages/cli/test/flush.test.ts",
|
|
"packages/cli/test/paths.test.ts",
|
|
"packages/compiler/src/library/int-infer.test.ts",
|
|
"packages/compiler/test/cjs-lexer.test.ts",
|
|
"packages/compiler/test/emit-c.test.ts",
|
|
"packages/compiler/test/ir.test.ts",
|
|
"packages/compiler/test/llvm-runtime-abi.test.ts",
|
|
"packages/compiler/test/ts7/bench.test.ts",
|
|
"packages/compiler/test/ts7/coverage.test.ts",
|
|
"packages/compiler/test/ts7/facade.test.ts",
|
|
"packages/compiler/test/ts7/order-parity.test.ts",
|
|
"packages/compiler/test/ts7/parity.test.ts",
|
|
"packages/compiler/test/ts7/program-adapter.test.ts",
|
|
"packages/compiler/test/ts7/resolver-parity.test.ts",
|
|
// These C runtime units compile with ASan + SCR_RC_AUDIT themselves.
|
|
"packages/runtime/test/array.test.ts",
|
|
"packages/runtime/test/bytes.test.ts",
|
|
"packages/runtime/test/closure.test.ts",
|
|
"packages/runtime/test/inspect.test.ts",
|
|
"packages/runtime/test/json.test.ts",
|
|
"packages/runtime/test/map.test.ts",
|
|
"packages/runtime/test/path.test.ts",
|
|
"packages/runtime/test/regex.test.ts",
|
|
"tests/harness/island-surface.test.ts",
|
|
"tests/harness/library-mode.test.ts",
|
|
"tests/harness/library-profile.test.ts",
|
|
"tests/harness/linux-differential.test.ts",
|
|
"tests/harness/oci-manifest.test.ts",
|
|
"tests/harness/sandbox-config.test.ts",
|
|
"tests/harness/sandbox-platform.test.ts",
|
|
"tests/harness/shard.test.ts",
|
|
"tests/harness/smoke.test.ts",
|
|
"tests/harness/surface-manifest.test.ts",
|
|
"tests/harness/windows-differential.test.ts",
|
|
"tests/harness/worktree-files.test.ts",
|
|
];
|
|
|
|
// Full native-oracle coverage stays on the host where the expected answer
|
|
// really is Darwin-, libc-, architecture-, or linker-specific. Each test is
|
|
// already explicitly sanitized where useful, so a second flavor is identical.
|
|
const hostInvariantFiles = [
|
|
"packages/compiler/test/cc-driver.test.ts",
|
|
"packages/runtime/test/lib.test.ts",
|
|
"packages/runtime/test/number.test.ts",
|
|
"packages/runtime/test/runtime.test.ts",
|
|
"packages/runtime/test/string.test.ts",
|
|
"packages/runtime/test/tonumber.test.ts",
|
|
"packages/runtime/test/url.test.ts",
|
|
];
|
|
const hostSchedule = sandboxHostSchedule(process.platform, hostInvariantFiles);
|
|
const nativeHostInvariantFiles = hostSchedule.localInvariantFiles;
|
|
const remoteWorkspaceReset = workspaceResetCommand("/workspace");
|
|
|
|
// The full portable behavior of these suites runs remotely. A compact
|
|
// host-native contract additionally pins the places Darwin can disagree:
|
|
// object/archive ABI, Mach-O size classes, linker diagnostics, ucontext,
|
|
// and the kqueue event-loop arms under both ordinary and Apple-ASan builds.
|
|
// Non-Darwin hosts retain the full portable remote suites without trying
|
|
// to execute these macOS-specific contracts locally.
|
|
const hostLaneContractFiles = [
|
|
"tests/harness/ffi.test.ts",
|
|
"tests/harness/island.test.ts",
|
|
"tests/harness/library-multi.test.ts",
|
|
"tests/harness/differential.test.ts",
|
|
"tests/harness/server.test.ts",
|
|
"tests/harness/dgram.test.ts",
|
|
"tests/harness/event-loop.test.ts",
|
|
];
|
|
const hostLaneContractPattern = [
|
|
"calls the manifest-bound archive across every v1 ABI class",
|
|
"a missing FFI symbol is an SC5004 diagnostic",
|
|
"deep island recursion on a fiber is a catchable RangeError",
|
|
"M1: external definitions equal the declared set exactly",
|
|
"M2: independent state and collects",
|
|
"M6: four threads, one archive",
|
|
"M7: thread-instanced and runtime-localized archives compose",
|
|
"M8: M6 under ASan",
|
|
"net-echo",
|
|
"udp-loopback-pair",
|
|
"1564-fs-watch.ts",
|
|
"1470-child-lifecycle.ts",
|
|
"read-all: chunked writes with delays, then EOF",
|
|
].join("|");
|
|
const hostInvariantContractFiles = [
|
|
"tests/harness/island.test.ts",
|
|
"tests/harness/library-mode.test.ts",
|
|
"tests/harness/regex.test.ts",
|
|
];
|
|
const hostInvariantContractPattern = [
|
|
"static hello-world stays in its size class",
|
|
"K1/K2/K8: scalar round-trips, symbol exactness, ambient audit",
|
|
"K3: buffer round-trips \\+ lifetime, auto-reset posture",
|
|
"K5: a trap delivers to the sink exactly once, host survives",
|
|
"K10: K4 under ASan \\+ RC audit",
|
|
"K10: K5/K7 under ASan",
|
|
"regex-free programs never reference the regex runtime",
|
|
].join("|");
|
|
|
|
// Logically portable acceptance suites whose oracle lives in an external
|
|
// worktree that is intentionally not uploaded. Run them locally in both
|
|
// flavors, sharding suites whose individual cases are independently addressable.
|
|
const localLaneFiles = [
|
|
"tests/harness/prettier-e2e.test.ts",
|
|
"tests/harness/portless-e2e.test.ts",
|
|
];
|
|
const localCaseShardedFiles = ["tests/harness/vercel-e2e.test.ts"];
|
|
|
|
const { values } = parseArgs({
|
|
options: {
|
|
help: { type: "boolean", short: "h" },
|
|
keep: { type: "boolean" },
|
|
lane: { type: "string", default: "both" },
|
|
"remote-only": { type: "boolean" },
|
|
shards: { type: "string", default: "8" },
|
|
},
|
|
});
|
|
|
|
if (values.help) {
|
|
console.log(`Run the scriptc test suite across Vercel Sandboxes.
|
|
|
|
Usage:
|
|
pnpm test:sandbox [--lane plain|san|both] [--shards 8] [--remote-only] [--keep]
|
|
|
|
Environment:
|
|
VERCEL_OIDC_TOKEN Preferred project-scoped Sandbox credential
|
|
VERCEL_TOKEN Access-token fallback; also set VERCEL_TEAM_ID + VERCEL_PROJECT_ID
|
|
SCRIPTC_SANDBOX_IMAGE Optional fully qualified VCR image (default: vercel/sandbox/universal)
|
|
SCRIPTC_SANDBOX_VCPUS vCPUs per sandbox (default: 8)
|
|
SCRIPTC_SANDBOX_TIMEOUT sandbox and command timeout (default: 45m)
|
|
SCRIPTC_TEST_WORKERS Vitest workers per sandbox (default: 4)
|
|
SCRIPTC_LOCAL_TEST_WORKERS Vitest workers per local lane (default: 2)
|
|
SCRIPTC_LOCAL_CASE_SHARDS local shards per external suite lane (default: 2)`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const imageConfig = sandboxImageConfig();
|
|
const { sandboxImage: image } = imageConfig;
|
|
const bootstrapCommand = sandboxBootstrapCommand(imageConfig.custom);
|
|
const vercelConfig = sandboxVercelConfig();
|
|
const vercelProcessEnv = sandboxVercelEnvironment(vercelConfig);
|
|
const {
|
|
vcpus,
|
|
testWorkers,
|
|
localTestWorkers,
|
|
localCaseShards,
|
|
sandboxTimeout,
|
|
} = sandboxRunnerConfig();
|
|
|
|
if (!["plain", "san", "both"].includes(values.lane)) {
|
|
throw new Error(`--lane must be plain, san, or both (got ${values.lane})`);
|
|
}
|
|
const shardCount = Number(values.shards);
|
|
if (!Number.isInteger(shardCount) || shardCount < 1 || shardCount > 10) {
|
|
throw new Error(`--shards must be an integer from 1 to 10 (got ${values.shards})`);
|
|
}
|
|
|
|
const lanes = values.lane === "both" ? ["plain", "san"] : [values.lane];
|
|
const remoteWorkerCount = Number(testWorkers);
|
|
if (!Number.isInteger(remoteWorkerCount) || remoteWorkerCount < 1) {
|
|
throw new Error(`SCRIPTC_TEST_WORKERS must be a positive integer (got ${testWorkers})`);
|
|
}
|
|
const fileWorkers = "1";
|
|
const localCaseShardCount = Number(localCaseShards);
|
|
if (!Number.isInteger(localCaseShardCount) || localCaseShardCount < 1) {
|
|
throw new Error(`SCRIPTC_LOCAL_CASE_SHARDS must be a positive integer (got ${localCaseShards})`);
|
|
}
|
|
const onceLane = lanes.includes("plain") ? "plain" : lanes[0];
|
|
const specialFiles = [
|
|
...caseShardedFiles,
|
|
...invariantRemoteFiles,
|
|
...hostInvariantFiles,
|
|
...localLaneFiles,
|
|
...localCaseShardedFiles,
|
|
];
|
|
if (new Set(specialFiles).size !== specialFiles.length) {
|
|
throw new Error("a test file cannot belong to more than one execution path");
|
|
}
|
|
const nonce = `${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
|
|
const workers = lanes.flatMap((lane) =>
|
|
Array.from({ length: shardCount }, (_, offset) => {
|
|
const shard = offset + 1;
|
|
return {
|
|
lane,
|
|
shard,
|
|
label: `${lane} ${shard}/${shardCount}`,
|
|
name: `scriptc-${lane}-${shard}-${nonce}`,
|
|
};
|
|
}),
|
|
);
|
|
const created = new Set();
|
|
const children = new Set();
|
|
let cleanupPromise;
|
|
let handlingSignal = false;
|
|
|
|
function lineWriter(destination, prefix, handleLine) {
|
|
let buffered = "";
|
|
return {
|
|
write(chunk) {
|
|
buffered += chunk;
|
|
const lines = buffered.split(/\r?\n/);
|
|
buffered = lines.pop() ?? "";
|
|
for (const line of lines) {
|
|
if (!handleLine?.(line)) destination.write(`${prefix}${line}\n`);
|
|
}
|
|
},
|
|
end() {
|
|
if (buffered && !handleLine?.(buffered)) destination.write(`${prefix}${buffered}\n`);
|
|
},
|
|
};
|
|
}
|
|
|
|
function run(
|
|
command,
|
|
args,
|
|
{
|
|
baseEnv = process.env,
|
|
env = {},
|
|
exitMarker,
|
|
idleTimeoutMs,
|
|
label,
|
|
quiet = false,
|
|
timeoutMs,
|
|
} = {},
|
|
) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: root,
|
|
env: { ...baseEnv, NO_UPDATE_NOTIFIER: "1", ...env },
|
|
stdio: quiet ? "ignore" : ["ignore", "pipe", "pipe"],
|
|
});
|
|
children.add(child);
|
|
const prefix = label ? `[${label}] ` : "";
|
|
let remoteExitCode;
|
|
let timedOut = false;
|
|
let timeoutReason = "";
|
|
let killTimeout;
|
|
const stopForTimeout = (reason) => {
|
|
if (timedOut) return;
|
|
timedOut = true;
|
|
timeoutReason = reason;
|
|
child.kill("SIGTERM");
|
|
// A network-stalled CLI may not honor SIGTERM promptly. Escalate so a
|
|
// wall timeout also bounds the time spent waiting for the close event.
|
|
killTimeout = setTimeout(() => child.kill("SIGKILL"), 5_000);
|
|
};
|
|
const timeout =
|
|
timeoutMs === undefined
|
|
? undefined
|
|
: setTimeout(() => {
|
|
stopForTimeout(`after ${Math.round(timeoutMs / 1000)}s`);
|
|
}, timeoutMs);
|
|
let idleTimeout;
|
|
const resetIdleTimeout = () => {
|
|
if (idleTimeoutMs === undefined) return;
|
|
if (idleTimeout !== undefined) clearTimeout(idleTimeout);
|
|
idleTimeout = setTimeout(
|
|
() => stopForTimeout(`after ${Math.round(idleTimeoutMs / 1000)}s without output`),
|
|
idleTimeoutMs,
|
|
);
|
|
};
|
|
resetIdleTimeout();
|
|
const stdout = lineWriter(process.stdout, prefix, (line) => {
|
|
if (!exitMarker) return false;
|
|
const match = new RegExp(`^${exitMarker}(\\d+)$`).exec(line);
|
|
if (!match) return false;
|
|
remoteExitCode = Number(match[1]);
|
|
return true;
|
|
});
|
|
const stderr = lineWriter(process.stderr, prefix);
|
|
if (!quiet) {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => {
|
|
resetIdleTimeout();
|
|
stdout.write(chunk);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
resetIdleTimeout();
|
|
stderr.write(chunk);
|
|
});
|
|
}
|
|
child.on("error", reject);
|
|
child.on("close", (code, signal) => {
|
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
if (idleTimeout !== undefined) clearTimeout(idleTimeout);
|
|
if (killTimeout !== undefined) clearTimeout(killTimeout);
|
|
children.delete(child);
|
|
stdout.end();
|
|
stderr.end();
|
|
if (timedOut) {
|
|
reject(
|
|
Object.assign(new Error(`${label ?? command} timed out ${timeoutReason}`), {
|
|
code: "SCRIPTC_RUN_TIMEOUT",
|
|
}),
|
|
);
|
|
} else if (code !== 0) {
|
|
reject(new Error(`${label ?? command} exited ${signal ?? code}`));
|
|
} else if (exitMarker && remoteExitCode === undefined) {
|
|
reject(new Error(`${label ?? command} did not report its remote exit status`));
|
|
} else if (remoteExitCode !== undefined && remoteExitCode !== 0) {
|
|
reject(new Error(`${label ?? command} remote command exited ${remoteExitCode}`));
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
const scopeArgs = vercelConfig.scopeArgs;
|
|
const vercel = ([group, command, ...args], options) =>
|
|
run("vercel", [group, command, ...scopeArgs, ...args], {
|
|
...options,
|
|
baseEnv: vercelProcessEnv,
|
|
});
|
|
// `vercel sandbox exec` does not propagate the remote process's exit code.
|
|
// Print a per-command nonce after it finishes and enforce that status here.
|
|
const execIn = async (
|
|
worker,
|
|
command,
|
|
args,
|
|
env = {},
|
|
task = "",
|
|
wallTimeoutMs = 15 * 60_000,
|
|
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 label = task ? `${worker.label} ${task}` : worker.label;
|
|
if (prepared.file) {
|
|
const localScript = join(temp, `${exitMarker}.sh`);
|
|
await writeFile(localScript, prepared.script, { mode: 0o600 });
|
|
try {
|
|
await vercel(["sandbox", "copy", localScript, `${worker.name}:${prepared.scriptPath}`], {
|
|
idleTimeoutMs: 60_000,
|
|
label: `${label} command`,
|
|
timeoutMs: 2 * 60_000,
|
|
});
|
|
} finally {
|
|
await rm(localScript, { force: true });
|
|
}
|
|
}
|
|
const commandArgs = [
|
|
"sandbox",
|
|
"exec",
|
|
"--timeout",
|
|
sandboxTimeout,
|
|
"--workdir",
|
|
workdir,
|
|
...envArgs,
|
|
worker.name,
|
|
...prepared.argv,
|
|
];
|
|
try {
|
|
await vercel(commandArgs, {
|
|
exitMarker,
|
|
idleTimeoutMs,
|
|
label,
|
|
timeoutMs: wallTimeoutMs,
|
|
});
|
|
} catch (error) {
|
|
console.warn(`[${label}] CLI completion was not confirmed (${error.message}); checking the remote command status...`);
|
|
const probeMarker = `__SCRIPTC_REMOTE_PROBE_${randomBytes(12).toString("hex")}__`;
|
|
const probeScript =
|
|
`scriptc_status=125; test ! -f ${shellQuote(statusPath)} || ` +
|
|
`scriptc_status=$(cat ${shellQuote(statusPath)}); ` +
|
|
`printf '\\n${probeMarker}%s\\n' "$scriptc_status"`;
|
|
await vercel(
|
|
[
|
|
"sandbox",
|
|
"exec",
|
|
"--timeout",
|
|
"1m",
|
|
"--workdir",
|
|
workdir,
|
|
worker.name,
|
|
"sh",
|
|
"-c",
|
|
probeScript,
|
|
],
|
|
{
|
|
exitMarker: probeMarker,
|
|
idleTimeoutMs: 30_000,
|
|
label: `${label} status`,
|
|
timeoutMs: 60_000,
|
|
},
|
|
);
|
|
}
|
|
};
|
|
|
|
async function preflight() {
|
|
const customImage = imageConfig.custom ? "custom VCR image" : "managed fallback image";
|
|
console.log("Sandbox preflight:");
|
|
console.log(` auth: ${vercelConfig.authSource}`);
|
|
console.log(` scope: ${vercelConfig.scopeSource}`);
|
|
console.log(` image: ${image} (${customImage})`);
|
|
console.log(` shape: ${workers.length} sandboxes, ${vcpus} vCPUs each`);
|
|
|
|
try {
|
|
await run("vercel", ["--version"], {
|
|
baseEnv: vercelProcessEnv,
|
|
label: "preflight CLI",
|
|
timeoutMs: 30_000,
|
|
});
|
|
} catch (cause) {
|
|
throw new Error(
|
|
"Sandbox preflight could not run the repository's Vercel CLI; run `pnpm install` first",
|
|
{ cause },
|
|
);
|
|
}
|
|
|
|
try {
|
|
await vercel(["sandbox", "list", "--limit", "1"], {
|
|
label: "preflight access",
|
|
timeoutMs: 60_000,
|
|
});
|
|
} catch (cause) {
|
|
const credentialHint = vercelConfig.oidc
|
|
? "Refresh VERCEL_OIDC_TOKEN with `vercel env pull` and verify that it belongs to a Sandbox-enabled project."
|
|
: vercelConfig.authToken
|
|
? "Verify VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID and confirm that project can use Sandbox."
|
|
: "Run `vercel login` and `vercel link`, or provide VERCEL_OIDC_TOKEN.";
|
|
throw new Error(
|
|
`Sandbox preflight could not access the selected Vercel project. ${credentialHint}`,
|
|
{ cause },
|
|
);
|
|
}
|
|
console.log("Sandbox preflight passed.\n");
|
|
}
|
|
|
|
async function createArchive(path) {
|
|
const git = spawn("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
cwd: root,
|
|
env: { ...process.env, COPYFILE_DISABLE: "1" },
|
|
stdio: ["ignore", "pipe", "inherit"],
|
|
});
|
|
const tarArgs = [
|
|
...(process.platform === "darwin" ? ["--no-xattrs", "--no-mac-metadata"] : []),
|
|
"--null",
|
|
"-T",
|
|
"-",
|
|
"-czf",
|
|
path,
|
|
];
|
|
const tar = spawn("tar", tarArgs, {
|
|
cwd: root,
|
|
env: { ...process.env, COPYFILE_DISABLE: "1" },
|
|
stdio: ["pipe", "inherit", "inherit"],
|
|
});
|
|
children.add(git);
|
|
children.add(tar);
|
|
const pack = pipeline(git.stdout, filterExistingWorktreePaths(root), tar.stdin);
|
|
const wait = (child, name) =>
|
|
new Promise((resolve, reject) => {
|
|
child.on("error", reject);
|
|
child.on("exit", (code, signal) => {
|
|
children.delete(child);
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`${name} exited ${signal ?? code}`));
|
|
});
|
|
});
|
|
await Promise.all([wait(git, "git ls-files"), wait(tar, "tar"), pack]);
|
|
}
|
|
|
|
async function createWorker(worker) {
|
|
const args = [
|
|
"sandbox",
|
|
"create",
|
|
"--name",
|
|
worker.name,
|
|
"--image",
|
|
image,
|
|
"--timeout",
|
|
sandboxTimeout,
|
|
"--vcpus",
|
|
vcpus,
|
|
"--non-persistent",
|
|
];
|
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
try {
|
|
await vercel(args, {
|
|
idleTimeoutMs: 60_000,
|
|
label: worker.label,
|
|
timeoutMs: 2 * 60_000,
|
|
});
|
|
return;
|
|
} catch {
|
|
console.warn(`[${worker.label}] create completion was not confirmed; checking the Sandbox...`);
|
|
try {
|
|
await execIn(worker, "true", [], {}, "create status", 60_000, "/");
|
|
return;
|
|
} catch (error) {
|
|
if (attempt === 2) throw error;
|
|
console.warn(`[${worker.label}] Sandbox is not reachable; retrying creation once...`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function uploadArchive(worker, archive) {
|
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
try {
|
|
await vercel(["sandbox", "copy", archive, `${worker.name}:/tmp/worktree.tar.gz`], {
|
|
idleTimeoutMs: 60_000,
|
|
label: worker.label,
|
|
timeoutMs: 2 * 60_000,
|
|
});
|
|
return;
|
|
} catch {
|
|
console.warn(`[${worker.label}] copy completion was not confirmed; checking the remote archive...`);
|
|
try {
|
|
// Listing every member also verifies the gzip stream reached its
|
|
// footer; a merely non-empty, partially uploaded file is rejected.
|
|
await execIn(
|
|
worker,
|
|
"sh",
|
|
["-c", "tar -tzf /tmp/worktree.tar.gz >/dev/null"],
|
|
{},
|
|
"copy status",
|
|
60_000,
|
|
"/",
|
|
);
|
|
return;
|
|
} catch (error) {
|
|
if (attempt === 2) throw error;
|
|
console.warn(`[${worker.label}] remote archive is absent or incomplete; retrying the copy once...`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function allWorkers(phase, task, concurrency = workers.length) {
|
|
const started = Date.now();
|
|
console.log(`\n${phase} (${workers.length} sandboxes)...`);
|
|
const results = [];
|
|
for (let offset = 0; offset < workers.length; offset += concurrency) {
|
|
results.push(...(await Promise.allSettled(workers.slice(offset, offset + concurrency).map(task))));
|
|
}
|
|
const failures = results.filter((result) => result.status === "rejected");
|
|
if (failures.length) {
|
|
for (const failure of failures) console.error(failure.reason);
|
|
throw new Error(`${phase} failed for ${failures.length} sandbox${failures.length === 1 ? "" : "es"}`);
|
|
}
|
|
console.log(`${phase} completed in ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
|
}
|
|
|
|
async function runTaskQueue(tasks, concurrency) {
|
|
for (let offset = 0; offset < tasks.length; offset += concurrency) {
|
|
await Promise.all(tasks.slice(offset, offset + concurrency).map((task) => task()));
|
|
}
|
|
}
|
|
|
|
async function cleanup() {
|
|
if (values.keep || created.size === 0) return;
|
|
if (!cleanupPromise) {
|
|
cleanupPromise = (async () => {
|
|
console.log(`\nRemoving ${created.size} disposable sandbox${created.size === 1 ? "" : "es"}...`);
|
|
const results = await Promise.allSettled(
|
|
[...created].map((name) =>
|
|
vercel(["sandbox", "remove", name], {
|
|
label: name,
|
|
quiet: true,
|
|
timeoutMs: 60_000,
|
|
}).then(() => created.delete(name)),
|
|
),
|
|
);
|
|
const failures = results.filter((result) => result.status === "rejected");
|
|
if (failures.length) {
|
|
console.error(`Failed to remove ${failures.length} sandbox${failures.length === 1 ? "" : "es"}.`);
|
|
}
|
|
})();
|
|
}
|
|
await cleanupPromise;
|
|
}
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
process.once(signal, () => {
|
|
if (handlingSignal) return;
|
|
handlingSignal = true;
|
|
for (const child of children) child.kill("SIGTERM");
|
|
if (!values.keep && created.size) {
|
|
console.log(`\nRemoving ${created.size} disposable sandbox${created.size === 1 ? "" : "es"}...`);
|
|
spawnSync(
|
|
"vercel",
|
|
["sandbox", "remove", ...scopeArgs, ...created],
|
|
{
|
|
cwd: root,
|
|
env: { ...vercelProcessEnv, NO_UPDATE_NOTIFIER: "1" },
|
|
stdio: "inherit",
|
|
timeout: 30_000,
|
|
},
|
|
);
|
|
}
|
|
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
});
|
|
}
|
|
|
|
const temp = await mkdtemp(join(tmpdir(), "scriptc-sandbox-test-"));
|
|
const archive = join(temp, "worktree.tar.gz");
|
|
const suiteStarted = Date.now();
|
|
let failure;
|
|
|
|
try {
|
|
await preflight();
|
|
console.log(
|
|
`Running ${lanes.join("+")} corpus lanes in ${workers.length} ${vcpus}-vCPU sandboxes from ${image} (${shardCount} shards/lane).`,
|
|
);
|
|
console.log("Packing the exact tracked + untracked, non-ignored worktree...");
|
|
await createArchive(archive);
|
|
|
|
const remote = (async () => {
|
|
await allWorkers("Creating", async (worker) => {
|
|
created.add(worker.name);
|
|
await createWorker(worker);
|
|
});
|
|
|
|
await allWorkers(
|
|
"Uploading worktree",
|
|
(worker) => uploadArchive(worker, archive),
|
|
8,
|
|
);
|
|
|
|
await allWorkers("Preparing worktree", async (worker) => {
|
|
if (bootstrapCommand) {
|
|
await execIn(
|
|
worker,
|
|
bootstrapCommand.prepareWorkspace.command,
|
|
bootstrapCommand.prepareWorkspace.args,
|
|
{},
|
|
"workspace",
|
|
2 * 60_000,
|
|
bootstrapCommand.prepareWorkspace.workdir,
|
|
);
|
|
}
|
|
await execIn(
|
|
worker,
|
|
remoteWorkspaceReset.command,
|
|
remoteWorkspaceReset.args,
|
|
{},
|
|
"reset",
|
|
2 * 60_000,
|
|
);
|
|
await execIn(
|
|
worker,
|
|
"tar",
|
|
["-xzf", "/tmp/worktree.tar.gz", "-C", "/workspace"],
|
|
{},
|
|
"",
|
|
2 * 60_000,
|
|
);
|
|
if (bootstrapCommand) {
|
|
await execIn(
|
|
worker,
|
|
bootstrapCommand.install.command,
|
|
bootstrapCommand.install.args,
|
|
{},
|
|
"bootstrap",
|
|
15 * 60_000,
|
|
bootstrapCommand.install.workdir,
|
|
5 * 60_000,
|
|
);
|
|
}
|
|
await execIn(worker, "pnpm", ["install", "--frozen-lockfile"], {}, "", 2 * 60_000);
|
|
await execIn(worker, "pnpm", ["build"], {}, "", 2 * 60_000);
|
|
// Workspace builds deliberately do not rebuild packaged native artifacts.
|
|
// Every remote lane needs the Linux helper and runtime from this worktree.
|
|
await execIn(worker, "pnpm", ["--filter", "@scriptc/llvm-linux-x64-gnu", "build:native"], {}, "LLVM helper", 5 * 60_000);
|
|
await execIn(
|
|
worker,
|
|
"pnpm",
|
|
["--filter", "@scriptc/runtime-linux-x64-gnu", "build:native"],
|
|
{ CC: "zig", AR: "zig" },
|
|
"runtime pack",
|
|
5 * 60_000,
|
|
"/workspace",
|
|
3 * 60_000,
|
|
);
|
|
// Zig is a build-only dependency in this lane. Cross-target suites own
|
|
// the conditional Zig tests; exposing it here would silently expand the
|
|
// native-cache shard while that shard deliberately disables stable
|
|
// toolchain caching.
|
|
await execIn(
|
|
worker,
|
|
"sudo",
|
|
["rm", "-f", "/usr/local/bin/zig"],
|
|
{},
|
|
"runtime toolchain cleanup",
|
|
60_000,
|
|
);
|
|
}, imageConfig.custom ? workers.length : 8);
|
|
|
|
await allWorkers("Testing", async (worker) => {
|
|
const sharedTestEnv = {
|
|
...sandboxLaneEnv(worker.lane),
|
|
// Platform artifact contracts run against the native host below.
|
|
// Remote lanes retain every portable behavior assertion.
|
|
SCRIPTC_PORTABLE_ONLY: "1",
|
|
...(worker.lane === "san"
|
|
? {
|
|
// Match the macOS shipping lane: Apple ASan has no
|
|
// LeakSanitizer, while scriptc's RC audit owns leak
|
|
// detection (including its intentional-abandonment rules).
|
|
ASAN_OPTIONS: "detect_leaks=0",
|
|
}
|
|
: {}),
|
|
};
|
|
const workerCaseFiles = [
|
|
...laneCaseShardedFiles,
|
|
...(worker.lane === onceLane ? invariantCaseShardedFiles : []),
|
|
];
|
|
const runCacheCases = worker.lane === onceLane;
|
|
const { caseWorkers, sideConcurrency } = sandboxTestWorkerAllocation(
|
|
remoteWorkerCount,
|
|
runCacheCases ? 2 : 1,
|
|
);
|
|
const cases = () =>
|
|
execIn(
|
|
worker,
|
|
"pnpm",
|
|
["test", "--reporter=dot", ...workerCaseFiles],
|
|
{
|
|
...sharedTestEnv,
|
|
SCRIPTC_TEST_SHARD: `${worker.shard}/${shardCount}`,
|
|
SCRIPTC_TEST_WORKERS: String(caseWorkers),
|
|
},
|
|
"cases",
|
|
);
|
|
const files = () =>
|
|
execIn(
|
|
worker,
|
|
"pnpm",
|
|
[
|
|
"test",
|
|
"--reporter=dot",
|
|
"--passWithNoTests",
|
|
`--shard=${worker.shard}/${shardCount}`,
|
|
...caseShardedFiles.map((file) => `--exclude=${file}`),
|
|
...nativeHostInvariantFiles.map((file) => `--exclude=${file}`),
|
|
...localLaneFiles.map((file) => `--exclude=${file}`),
|
|
...localCaseShardedFiles.map((file) => `--exclude=${file}`),
|
|
...(worker.lane === onceLane
|
|
? []
|
|
: [
|
|
...invariantRemoteFiles,
|
|
...hostSchedule.remoteInvariantFiles,
|
|
].map((file) => `--exclude=${file}`)),
|
|
],
|
|
{
|
|
...sharedTestEnv,
|
|
SCRIPTC_TEST_WORKERS: fileWorkers,
|
|
},
|
|
"files",
|
|
);
|
|
const cacheCases = () =>
|
|
execIn(
|
|
worker,
|
|
"pnpm",
|
|
["test", "--reporter=dot", ...cacheCaseShardedFiles],
|
|
{
|
|
...sharedTestEnv,
|
|
SCRIPTC_CACHE_TEST_SHARD: `${worker.shard}/${shardCount}`,
|
|
// This is the strict production-path contract. Keep it out of
|
|
// the memoized corpus process and pin the opt-out explicitly,
|
|
// just as the GitHub Actions matrix does.
|
|
SCRIPTC_TEST_STABLE_TOOLCHAIN: "0",
|
|
SCRIPTC_TEST_WORKERS: "1",
|
|
},
|
|
"cache",
|
|
);
|
|
// The corpus owns the remaining pool while file/cache processes share
|
|
// the reserved side slots (serially when only one slot is available).
|
|
const sideTasks = [files, ...(runCacheCases ? [cacheCases] : [])];
|
|
if (sideConcurrency === 0) {
|
|
await cases();
|
|
await runTaskQueue(sideTasks, 1);
|
|
} else {
|
|
await Promise.all([
|
|
cases(),
|
|
runTaskQueue(sideTasks, sideConcurrency),
|
|
]);
|
|
}
|
|
});
|
|
|
|
if (hostSchedule.remoteArtifactContracts) {
|
|
const contractWorker = workers.find(
|
|
(worker) => worker.lane === onceLane && worker.shard === 1,
|
|
);
|
|
if (!contractWorker) throw new Error("could not select a host artifact contract worker");
|
|
console.log("\nTesting host artifact contracts in a Linux Sandbox...");
|
|
await execIn(
|
|
contractWorker,
|
|
"pnpm",
|
|
[
|
|
"test",
|
|
"--reporter=dot",
|
|
"-t",
|
|
hostInvariantContractPattern,
|
|
...hostInvariantContractFiles,
|
|
],
|
|
{
|
|
CI: "1",
|
|
ASAN_OPTIONS: "detect_leaks=0",
|
|
SCRIPTC_TEST_WORKERS: fileWorkers,
|
|
},
|
|
"host artifact contracts",
|
|
);
|
|
}
|
|
})();
|
|
|
|
const local = values["remote-only"]
|
|
? Promise.resolve()
|
|
: (async () => {
|
|
const hostName =
|
|
process.platform === "darwin"
|
|
? "Darwin"
|
|
: process.platform === "linux"
|
|
? "Linux"
|
|
: process.platform;
|
|
const localWork = [
|
|
...(nativeHostInvariantFiles.length > 0
|
|
? [`${nativeHostInvariantFiles.length} ${hostName}-native files`]
|
|
: []),
|
|
...(hostSchedule.darwinContracts
|
|
? ["compact Darwin platform contracts"]
|
|
: []),
|
|
...(hostSchedule.localArtifactContracts
|
|
? ["compact host artifact contracts"]
|
|
: []),
|
|
`${localLaneFiles.length + localCaseShardedFiles.length} external suites`,
|
|
];
|
|
console.log(
|
|
`\nBuilding and testing ${localWork.join(", ")} locally...`,
|
|
);
|
|
await run("pnpm", ["build"], { label: "local build" });
|
|
const nativeHostTasks =
|
|
nativeHostInvariantFiles.length === 0
|
|
? []
|
|
: [
|
|
run(
|
|
"pnpm",
|
|
[
|
|
"test",
|
|
...nativeHostInvariantFiles,
|
|
],
|
|
{
|
|
env: {
|
|
...sandboxLaneEnv(onceLane),
|
|
SCRIPTC_TEST_WORKERS: localTestWorkers,
|
|
},
|
|
label: `local ${onceLane} ${hostName}`,
|
|
},
|
|
),
|
|
];
|
|
const artifactContractTasks =
|
|
!hostSchedule.localArtifactContracts
|
|
? []
|
|
: [
|
|
run(
|
|
"pnpm",
|
|
[
|
|
"test",
|
|
"--reporter=dot",
|
|
"-t",
|
|
hostInvariantContractPattern,
|
|
...hostInvariantContractFiles,
|
|
],
|
|
{
|
|
env: {
|
|
...sandboxLaneEnv(onceLane),
|
|
...(process.platform === "linux"
|
|
? { ASAN_OPTIONS: "detect_leaks=0" }
|
|
: {}),
|
|
SCRIPTC_TEST_WORKERS: localTestWorkers,
|
|
},
|
|
label: `local ${onceLane} ${hostName} artifact contract`,
|
|
},
|
|
),
|
|
];
|
|
const darwinContractTasks =
|
|
!hostSchedule.darwinContracts
|
|
? []
|
|
: lanes.map((lane) =>
|
|
run(
|
|
"pnpm",
|
|
[
|
|
"test",
|
|
"--reporter=dot",
|
|
"-t",
|
|
hostLaneContractPattern,
|
|
...hostLaneContractFiles,
|
|
],
|
|
{
|
|
env: {
|
|
...sandboxLaneEnv(lane),
|
|
SCRIPTC_TEST_WORKERS: localTestWorkers,
|
|
},
|
|
label: `local ${lane} Darwin contract`,
|
|
},
|
|
),
|
|
);
|
|
const laneFileTasks = lanes.map((lane) =>
|
|
run(
|
|
"pnpm",
|
|
["test", "--reporter=dot", ...localLaneFiles],
|
|
{
|
|
env: {
|
|
...sandboxLaneEnv(lane),
|
|
SCRIPTC_TEST_RUN_ID: nonce,
|
|
SCRIPTC_TEST_WORKERS: "1",
|
|
},
|
|
label: `local ${lane} external files`,
|
|
},
|
|
),
|
|
);
|
|
const caseTasks = lanes.flatMap((lane) =>
|
|
Array.from({ length: localCaseShardCount }, (_, offset) => {
|
|
const shard = offset + 1;
|
|
return run(
|
|
"pnpm",
|
|
["test", "--reporter=dot", ...localCaseShardedFiles],
|
|
{
|
|
env: {
|
|
...sandboxLaneEnv(lane),
|
|
SCRIPTC_TEST_SHARD: `${shard}/${localCaseShardCount}`,
|
|
SCRIPTC_TEST_RUN_ID: nonce,
|
|
SCRIPTC_TEST_WORKERS: "1",
|
|
},
|
|
label: `local ${lane} external ${shard}/${localCaseShardCount}`,
|
|
},
|
|
);
|
|
}),
|
|
);
|
|
const results = await Promise.allSettled(
|
|
[
|
|
...nativeHostTasks,
|
|
...artifactContractTasks,
|
|
...darwinContractTasks,
|
|
...laneFileTasks,
|
|
...caseTasks,
|
|
],
|
|
);
|
|
const failures = results.filter((result) => result.status === "rejected");
|
|
if (failures.length) {
|
|
for (const result of failures) console.error(result.reason);
|
|
throw new Error(`${failures.length} local test lane${failures.length === 1 ? "" : "s"} failed`);
|
|
}
|
|
})();
|
|
|
|
const results = await Promise.allSettled([remote, local]);
|
|
const failures = results.filter((result) => result.status === "rejected");
|
|
if (failures.length) {
|
|
for (const result of failures) console.error(result.reason);
|
|
throw new Error(`${failures.length} test path${failures.length === 1 ? "" : "s"} failed`);
|
|
}
|
|
|
|
console.log(
|
|
`\n${lanes.length === 2 ? "Both test lanes" : `${lanes[0]} test lane`} passed in ${((Date.now() - suiteStarted) / 60_000).toFixed(1)} minutes.`,
|
|
);
|
|
} catch (error) {
|
|
failure = error;
|
|
} finally {
|
|
await cleanup();
|
|
await rm(temp, { recursive: true, force: true });
|
|
if (values.keep && created.size) {
|
|
console.log(`Kept sandboxes:\n${[...created].map((name) => ` ${name}`).join("\n")}`);
|
|
}
|
|
}
|
|
|
|
if (failure) throw failure;
|