diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index 40100e113..63745404d 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -7,18 +7,30 @@ ARG PNPM_VERSION=11.1.3 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates curl gnupg \ + && curl --fail --silent --show-error --location https://apt.llvm.org/llvm-snapshot.gpg.key \ + | gpg --dearmor --output /usr/share/keyrings/llvm-snapshot.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/llvm-snapshot.gpg] https://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" \ + > /etc/apt/sources.list.d/llvm22.list \ + && apt-get update \ && apt-get install --yes --no-install-recommends \ build-essential \ ca-certificates \ ccache \ - clang \ + clang-22 \ cmake \ curl \ git \ - libclang-rt-dev \ - llvm \ + libclang-rt-22-dev \ + libzstd-dev \ + llvm-22-dev \ + ninja-build \ xz-utils \ zlib1g-dev \ + && ln -sf clang-22 /usr/bin/clang \ + && ln -sf clang++-22 /usr/bin/clang++ \ + && test "$(llvm-config-22 --version)" = 22.1.8 \ && rm -rf /var/lib/apt/lists/* RUN curl --fail --silent --show-error --location \ diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index 8307f8b3d..111fbf922 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -75,7 +75,7 @@ const who = process.argv.length > 2 ? process.argv[2] : "world"; **Process shape** — `process.argv[0]` is `"scriptc"` and `argv[1]` is the binary's path (positions line up with Node; `argv[2]` onward are your args). The uncaught-exception stderr line reads `Uncaught ` instead of Node's stack-trace block (exit code and pre-throw stdout are identical). Runtime errors carry `message` and Node's `code`, but not `errno`/`syscall`/`path`. -**Comparator call sequences differ in `sort` and `toSorted`** (stable insertion sort here, TimSort in V8 — sorted results are byte-identical for consistent comparators), and **`localeCompare` compares code units**, not ICU collation. +**Comparator call sequences differ in `sort` and `toSorted`** (scriptc uses a stable bottom-up merge sort, while V8 uses TimSort). Sorted results are byte-identical for consistent comparators. Ordered inputs use a linear number of comparator calls, but merge-buffer movement remains O(n log n) even when every boundary is already ordered. **`localeCompare` compares code units**, not ICU collation. ## Dynamic-tier limits diff --git a/packages/cli/test/bootstrap.test.ts b/packages/cli/test/bootstrap.test.ts index 420f4761c..e30c02b64 100644 --- a/packages/cli/test/bootstrap.test.ts +++ b/packages/cli/test/bootstrap.test.ts @@ -4,12 +4,16 @@ import { release as osRelease, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { expect, test } from "vitest"; +import { precompiledRuntimePackTarget } from "../../compiler/src/startup-cache.js"; +import { platformLinkerSupportsPersistentCache } from "../../compiler/src/backend/linker.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../../.."); const bootstrap = join(repoRoot, "packages/cli/dist/bootstrap.js"); const runtimePackHost = process.platform === "darwin" && process.arch === "arm64" && Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) >= 24; +const helperTarget = precompiledRuntimePackTarget(); +const persistentExecutable = helperTarget === null || platformLinkerSupportsPersistentCache(process.env, helperTarget); test("bootstrap serves version and help without loading the compiler graph", async () => { const preloadDir = await mkdtemp(join(tmpdir(), "scriptc-bootstrap-preload-")); @@ -83,7 +87,15 @@ test("bootstrap exact builds use the routed cache and source edits fall through" rm(join(cacheRoot, "early-exe-implementation"), { recursive: true, force: true }), ]); expect((await build()).stderr).not.toContain("scriptc lowering"); - await expect(build(true)).resolves.toMatchObject({ stderr: "" }); + if (persistentExecutable) { + await expect(build(true)).resolves.toMatchObject({ stderr: "" }); + } else { + // This target intentionally lacks a persistent native dependency proof. + // It must enter the full compiler to relink, even on a frontend hit. + await expect(build(true)).rejects.toMatchObject({ stderr: expect.stringContaining("compiler graph loaded") }); + expect((await build()).stderr).not.toContain("scriptc lowering"); + expect((await execFileAsync(outPath)).stdout).toBe("one\n"); + } await writeFile(entry, 'console.log("two");\n'); expect((await build()).stderr).toContain("scriptc lowering"); diff --git a/packages/cli/test/executable-cache.test.ts b/packages/cli/test/executable-cache.test.ts index be3113d8a..0dd75f3fb 100644 --- a/packages/cli/test/executable-cache.test.ts +++ b/packages/cli/test/executable-cache.test.ts @@ -5,6 +5,8 @@ import { release as osRelease, tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { promisify } from "node:util"; import { expect, test } from "vitest"; +import { precompiledRuntimePackTarget } from "../../compiler/src/startup-cache.js"; +import { platformLinkerSupportsPersistentCache } from "../../compiler/src/backend/linker.js"; const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); @@ -13,6 +15,8 @@ const cliEntry = join(repoRoot, "packages/cli/src/main.ts"); const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs"); const runtimePackHost = process.platform === "darwin" && process.arch === "arm64" && Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) >= 24; +const helperTarget = precompiledRuntimePackTarget(); +const persistentExecutable = helperTarget === null || platformLinkerSupportsPersistentCache(process.env, helperTarget); test("exact executable repeats skip lowering while edits and damaged outputs stay correct", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-cli-executable-cache-")); @@ -50,10 +54,12 @@ test("exact executable repeats skip lowering while edits and damaged outputs sta const originalBinaryTime = (await stat(outPath)).mtimeMs; const originalTuTime = (await stat(tuPath)).mtimeMs; - // The exact repeat returns from the whole-program cache before tsgo and - // leaves already-correct caller outputs on their existing inodes. + // Every route reuses the frontend. Only routes with a complete native + // dependency proof can also retain the executable inode; other targets + // deliberately relink their cached translation unit. expect((await build()).stderr).not.toContain("scriptc lowering"); - expect((await stat(outPath)).mtimeMs).toBe(originalBinaryTime); + if (persistentExecutable) expect((await stat(outPath)).mtimeMs).toBe(originalBinaryTime); + expect(await run()).toBe("one\n"); expect((await stat(tuPath)).mtimeMs).toBe(originalTuTime); // Output-kind cleanup is honest even on an exact early hit: an IR file diff --git a/packages/cli/test/native-output.test.ts b/packages/cli/test/native-output.test.ts index e4c3c1fce..943d09f1e 100644 --- a/packages/cli/test/native-output.test.ts +++ b/packages/cli/test/native-output.test.ts @@ -35,11 +35,13 @@ function cli(args: string[], env: NodeJS.ProcessEnv = process.env) { }); } -test("unsupported hosts fail with SC3002 before creating an artifact", async () => { - if (supported) return; +test("unsupported targets fail with SC3002 before creating an artifact", async () => { const { dir, entry } = await fixture(); const output = join(dir, "hello.o"); - await expect(cli(["build", entry, "--emit=obj", "-o", output])).rejects.toMatchObject({ + await expect(cli(["build", entry, "--emit=obj", "-o", output], { + ...process.env, + SCRIPTC_TARGET: "scriptc-unsupported-target", + })).rejects.toMatchObject({ code: 1, stderr: expect.stringContaining("SC3002"), }); diff --git a/packages/compiler/src/backend/native-codegen.test.ts b/packages/compiler/src/backend/native-codegen.test.ts index 5c092d0c3..4c842fa92 100644 --- a/packages/compiler/src/backend/native-codegen.test.ts +++ b/packages/compiler/src/backend/native-codegen.test.ts @@ -71,6 +71,7 @@ function request(root: string, packageJson: string, output = join(root, "program outputKind: "obj" as const, sourcePath: "/source/app.ts", target, + helperHost: { platform: "darwin" as const, arch: "arm64" }, resolvePackageJson: () => packageJson, cacheRoot: join(root, "cache"), }; diff --git a/packages/compiler/src/backend/native-toolchain.test.ts b/packages/compiler/src/backend/native-toolchain.test.ts index 2c57839ac..ce68d5f35 100644 --- a/packages/compiler/src/backend/native-toolchain.test.ts +++ b/packages/compiler/src/backend/native-toolchain.test.ts @@ -4178,7 +4178,11 @@ test.skipIf( probeSource, "long long public_value(void);\nint main(void) { return public_value() != 7; }\n", ); - execFileSync(clangExecutable!, [probeSource, outPath, "-lm", "-o", probe]); + // The failed linker belongs only to the shard-merge probe. Link the + // resulting archive with the real host tools to verify its fallback. + execFileSync(clangExecutable!, [probeSource, outPath, "-lm", "-o", probe], { + env: { ...process.env, PATH: oldPath }, + }); expect(execFileSync(probe, { encoding: "utf8" })).toBe(""); // The canonical retry is valid for this invocation, but must not occupy a // key describing merged shard bytes. A repaired merge tool retries the diff --git a/packages/compiler/src/frontend/lowering/lower-array-sort.ts b/packages/compiler/src/frontend/lowering/lower-array-sort.ts new file mode 100644 index 000000000..37bea5022 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/lower-array-sort.ts @@ -0,0 +1,587 @@ +import { + BOOL, + BYTES_U8, + F64, + IrExpr, + IrFunction, + IrLocal, + IrParam, + IrStmt, + IrType, + JSVAL, + SrcLoc, + arrayOf, + funcOf, +} from "../../ir/ir.js"; +import { numLit, varRef } from "../../ir/build.js"; + +function readArrayLength(arrT: IrType, loc: SrcLoc): IrStmt { + return { + kind: "varDecl", + localId: "n.0", + init: { + kind: "arrIntrinsic", + method: "length", + receiver: { kind: "varRef", localId: "a.0", type: arrT, loc }, + args: [], + type: F64, + loc, + }, + loc, + }; +} + +/** A stable bottom-up merge sort built from existing IR nodes. + * + * The first implementation used insertion sort because it was compact and + * made the stable tie rule obvious. Its worst case was quadratic, though, + * which made an ordinary descending input unusable. This implementation + * snapshots the receiver, merges runs between two buffers, and copies the + * final snapshot back into the receiver. The boundary check skips a merge + * when adjacent runs are already ordered, so naturally ordered inputs use + * only a linear number of comparator calls; the buffer copies still do + * O(n log n) data movement. Arbitrary inputs have an O(n log n) bound. + * + * The snapshot is also important for sort mutation behavior: comparator + * calls see the values captured before sorting starts, while the final copy + * preserves Array.sort receiver identity. toSorted starts by copying the + * receiver and therefore leaves that receiver untouched. The merge schedule + * intentionally differs from V8 TimSort, so exact comparator call order and + * count parity is not claimed; stable results, comparator exceptions, and + * mutations of referenced values still follow the normal IR call/ownership + * rules. */ +export function buildArraySortFn( + name: string, + elem: IrType, + arity: number, + copyFirst: boolean, + undefinedTag: number | null, + loc: SrcLoc, +): IrFunction { + const arrT = arrayOf(elem); + const fnT = funcOf([elem, elem].slice(0, arity), F64); + + const a = varRef("a.0", arrT, loc); + const src = varRef("src.0", arrT, loc); + const dst = varRef("dst.0", arrT, loc); + const n = varRef("n.0", F64, loc); + const width = varRef("width.0", F64, loc); + const start = varRef("start.0", F64, loc); + const mid = varRef("mid.0", F64, loc); + const right = varRef("right.0", F64, loc); + const left = varRef("left.0", F64, loc); + const r = varRef("r.0", F64, loc); + const k = varRef("k.0", F64, loc); + const add = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "+", left, right, type: F64, loc }); + const sub = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "-", left, right, type: F64, loc }); + const mul = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "*", left, right, type: F64, loc }); + const lt = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "<", left, right, type: BOOL, loc }); + const not = (value: IrExpr): IrExpr => ({ kind: "unary", op: "!", operand: value, type: BOOL, loc }); + const at = (index: IrExpr): IrExpr => ({ kind: "arrayGet", arr: src, index, type: elem, loc }); + const isUndefined = (value: IrExpr): IrExpr | null => { + if (elem.kind === "union" && undefinedTag !== null) { + return { + kind: "unionIsTag", + unionId: elem.unionId, + tag: undefinedTag, + negated: false, + value, + type: BOOL, + loc, + }; + } + if (elem.kind === "jsval") { + return { + kind: "jsOp", + op: "eq", + args: [ + value, + { kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc }, + ], + type: BOOL, + loc, + }; + } + return null; + }; + const shouldTakeRight = (leftValue: IrExpr, rightValue: IrExpr): IrExpr => { + const compareGreater: IrExpr = { + kind: "bin", + op: ">", + left: { + kind: "callValue", + callee: varRef("f.0", fnT, loc), + args: [leftValue, rightValue].slice(0, arity), + type: F64, + loc, + }, + right: numLit(0, loc), + type: BOOL, + loc, + }; + const leftUndefined = isUndefined(leftValue); + const rightUndefined = isUndefined(rightValue); + // CompareArrayElements: undefined always sinks and never reaches the + // user comparator. Ternaries preserve that callback suppression. + return leftUndefined !== null && rightUndefined !== null + ? { + kind: "ternary", + cond: leftUndefined, + then: not(rightUndefined), + else_: { + kind: "ternary", + cond: rightUndefined, + then: { kind: "boolLit", value: false, type: BOOL, loc }, + else_: compareGreater, + type: BOOL, + loc, + }, + type: BOOL, + loc, + } + : compareGreater; + }; + const copyRange: IrStmt = { + kind: "while", + cond: lt(k, right), + body: [ + { kind: "varDecl", localId: "v.0", init: at(k), loc }, + { kind: "arraySet", arr: dst, index: k, value: varRef("v.0", elem, loc), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }; + const mergeBody: IrStmt[] = [ + { + kind: "while", + cond: { + kind: "logical", + op: "&&", + left: lt(left, mid), + right: lt(r, right), + type: BOOL, + loc, + }, + body: [ + { kind: "varDecl", localId: "vL.0", init: at(left), loc }, + { kind: "varDecl", localId: "vR.0", init: at(r), loc }, + { + kind: "if", + cond: shouldTakeRight(varRef("vL.0", elem, loc), varRef("vR.0", elem, loc)), + then: [ + { kind: "arraySet", arr: dst, index: k, value: varRef("vR.0", elem, loc), loc }, + { kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc }, + ], + else_: [ + { kind: "arraySet", arr: dst, index: k, value: varRef("vL.0", elem, loc), loc }, + { kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc }, + ], + loc, + }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + { + kind: "while", + cond: lt(left, mid), + body: [ + { kind: "varDecl", localId: "v.0", init: at(left), loc }, + { kind: "arraySet", arr: dst, index: k, value: varRef("v.0", elem, loc), loc }, + { kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + { + kind: "while", + cond: lt(r, right), + body: [ + { kind: "varDecl", localId: "v.0", init: at(r), loc }, + { kind: "arraySet", arr: dst, index: k, value: varRef("v.0", elem, loc), loc }, + { kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + ]; + const boundaryLeft = varRef("boundaryL.0", elem, loc); + const boundaryRight = varRef("boundaryR.0", elem, loc); + const mergeOrCopy: IrStmt = { + kind: "if", + cond: lt(mid, right), + then: [ + { kind: "varDecl", localId: "boundaryL.0", init: at(sub(mid, numLit(1, loc))), loc }, + { kind: "varDecl", localId: "boundaryR.0", init: at(mid), loc }, + { + kind: "if", + cond: shouldTakeRight(boundaryLeft, boundaryRight), + then: mergeBody, + else_: [copyRange], + loc, + }, + ], + else_: [copyRange], + loc, + }; + const mergePass: IrStmt = { + kind: "for", + init: { kind: "varDecl", localId: "start.0", init: numLit(0, loc), loc }, + cond: lt(start, n), + update: { kind: "assign", localId: "start.0", value: add(start, add(width, width)), loc }, + body: [ + { + kind: "varDecl", + localId: "mid.0", + init: { + kind: "ternary", + cond: lt(add(start, width), n), + then: add(start, width), + else_: n, + type: F64, + loc, + }, + loc, + }, + { + kind: "varDecl", + localId: "right.0", + init: { + kind: "ternary", + cond: lt(add(add(start, width), width), n), + then: add(add(start, width), width), + else_: n, + type: F64, + loc, + }, + loc, + }, + { kind: "varDecl", localId: "left.0", init: start, loc }, + { kind: "varDecl", localId: "r.0", init: mid, loc }, + { kind: "varDecl", localId: "k.0", init: start, loc }, + mergeOrCopy, + ], + loc, + }; + const body: IrStmt[] = [ + ...(copyFirst + ? [{ + kind: "assign" as const, + localId: "a.0", + value: { + kind: "arrIntrinsic" as const, + method: "slice" as const, + receiver: varRef("a.0", arrT, loc), + args: [], + type: arrT, + loc, + }, + loc, + }] + : []), + readArrayLength(arrT, loc), + { kind: "varDecl", localId: "src.0", init: { kind: "arrIntrinsic", method: "slice", receiver: a, args: [], type: arrT, loc }, loc }, + // The destination is filled from index zero upward on every merge pass. + // An empty literal is therefore dense and works for scalar arrays too; + // arrayNewLen is reserved for the separate absent-slot semantics. + { kind: "varDecl", localId: "dst.0", init: { kind: "arrayLit", elems: [], type: arrT, loc }, loc }, + { kind: "varDecl", localId: "width.0", init: numLit(1, loc), loc }, + { + kind: "while", + cond: lt(width, n), + body: [ + mergePass, + { kind: "varDecl", localId: "tmp.0", init: src, loc }, + { kind: "assign", localId: "src.0", value: dst, loc }, + { kind: "assign", localId: "dst.0", value: varRef("tmp.0", arrT, loc), loc }, + { kind: "assign", localId: "width.0", value: mul(width, numLit(2, loc)), loc }, + ], + loc, + }, + { + kind: "for", + init: { kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc }, + cond: lt(varRef("i.0", F64, loc), n), + update: { kind: "assign", localId: "i.0", value: add(varRef("i.0", F64, loc), numLit(1, loc)), loc }, + body: [ + { kind: "varDecl", localId: "v.0", init: { kind: "arrayGet", arr: src, index: varRef("i.0", F64, loc), type: elem, loc }, loc }, + { kind: "arraySet", arr: a, index: varRef("i.0", F64, loc), value: varRef("v.0", elem, loc), loc }, + ], + loc, + }, + { kind: "return", value: a, loc }, + ]; + return { + name, + params: [ + { localId: "a.0", name: "a", type: arrT }, + { localId: "f.0", name: "f", type: fnT }, + ], + returnType: arrT, + locals: [ + { id: "a.0", name: "a", type: arrT, mutable: true }, + { id: "f.0", name: "f", type: fnT, mutable: true }, + { id: "n.0", name: "n", type: F64, mutable: false }, + { id: "src.0", name: "src", type: arrT, mutable: true }, + { id: "dst.0", name: "dst", type: arrT, mutable: true }, + { id: "width.0", name: "width", type: F64, mutable: true }, + { id: "start.0", name: "start", type: F64, mutable: true }, + { id: "mid.0", name: "mid", type: F64, mutable: true }, + { id: "right.0", name: "right", type: F64, mutable: true }, + { id: "left.0", name: "left", type: F64, mutable: true }, + { id: "r.0", name: "r", type: F64, mutable: true }, + { id: "k.0", name: "k", type: F64, mutable: true }, + { id: "i.0", name: "i", type: F64, mutable: true }, + { id: "tmp.0", name: "tmp", type: arrT, mutable: true }, + { id: "v.0", name: "v", type: elem, mutable: false }, + { id: "vL.0", name: "vL", type: elem, mutable: false }, + { id: "vR.0", name: "vR", type: elem, mutable: false }, + { id: "boundaryL.0", name: "boundaryL", type: elem, mutable: false }, + { id: "boundaryR.0", name: "boundaryR", type: elem, mutable: false }, + ], + body, + loc, + }; +} + + +export function buildBytesSortFn( + name: string, + arity: number, + hasComparator: boolean, + loc: SrcLoc, +): IrFunction { + const bytesT = BYTES_U8; + const fnT = funcOf([F64, F64].slice(0, arity), F64); + const a = varRef("a.0", bytesT, loc); + const src = varRef("src.0", bytesT, loc); + const dst = varRef("dst.0", bytesT, loc); + const n = varRef("n.0", F64, loc); + const width = varRef("width.0", F64, loc); + const start = varRef("start.0", F64, loc); + const mid = varRef("mid.0", F64, loc); + const right = varRef("right.0", F64, loc); + const left = varRef("left.0", F64, loc); + const r = varRef("r.0", F64, loc); + const k = varRef("k.0", F64, loc); + const add = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "+", left, right, type: F64, loc }); + const sub = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "-", left, right, type: F64, loc }); + const mul = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "*", left, right, type: F64, loc }); + const lt = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "<", left, right, type: BOOL, loc }); + const at = (receiver: IrExpr, index: IrExpr): IrExpr => ({ + kind: "bytesIntrinsic", + method: "get", + receiver, + args: [index], + type: F64, + loc, + }); + const shouldTakeRight = (leftValue: IrExpr, rightValue: IrExpr): IrExpr => ({ + kind: "bin", + op: ">", + left: hasComparator + ? { + kind: "callValue", + callee: varRef("f.0", fnT, loc), + args: [leftValue, rightValue].slice(0, arity), + type: F64, + loc, + } + : { + kind: "bin", + op: "-", + left: leftValue, + right: rightValue, + type: F64, + loc, + }, + right: numLit(0, loc), + type: BOOL, + loc, + }); + const copyRange: IrStmt = { + kind: "while", + cond: lt(k, right), + body: [ + { kind: "varDecl", localId: "v.0", init: at(src, k), loc }, + { kind: "bytesSet", arr: dst, index: k, value: varRef("v.0", F64, loc), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }; + const mergeBody: IrStmt[] = [ + { + kind: "while", + cond: { + kind: "logical", + op: "&&", + left: lt(left, mid), + right: lt(r, right), + type: BOOL, + loc, + }, + body: [ + { kind: "varDecl", localId: "vL.0", init: at(src, left), loc }, + { kind: "varDecl", localId: "vR.0", init: at(src, r), loc }, + { + kind: "if", + cond: shouldTakeRight(varRef("vL.0", F64, loc), varRef("vR.0", F64, loc)), + then: [ + { kind: "bytesSet", arr: dst, index: k, value: varRef("vR.0", F64, loc), loc }, + { kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc }, + ], + else_: [ + { kind: "bytesSet", arr: dst, index: k, value: varRef("vL.0", F64, loc), loc }, + { kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc }, + ], + loc, + }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + { + kind: "while", + cond: lt(left, mid), + body: [ + { kind: "varDecl", localId: "v.0", init: at(src, left), loc }, + { kind: "bytesSet", arr: dst, index: k, value: varRef("v.0", F64, loc), loc }, + { kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + { + kind: "while", + cond: lt(r, right), + body: [ + { kind: "varDecl", localId: "v.0", init: at(src, r), loc }, + { kind: "bytesSet", arr: dst, index: k, value: varRef("v.0", F64, loc), loc }, + { kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + ]; + const mergeOrCopy: IrStmt = { + kind: "if", + cond: lt(mid, right), + then: [ + { kind: "varDecl", localId: "boundaryL.0", init: at(src, sub(mid, numLit(1, loc))), loc }, + { kind: "varDecl", localId: "boundaryR.0", init: at(src, mid), loc }, + { + kind: "if", + cond: shouldTakeRight(varRef("boundaryL.0", F64, loc), varRef("boundaryR.0", F64, loc)), + then: mergeBody, + else_: [copyRange], + loc, + }, + ], + else_: [copyRange], + loc, + }; + const mergePass: IrStmt = { + kind: "for", + init: { kind: "varDecl", localId: "start.0", init: numLit(0, loc), loc }, + cond: lt(start, n), + update: { kind: "assign", localId: "start.0", value: add(start, add(width, width)), loc }, + body: [ + { + kind: "varDecl", + localId: "mid.0", + init: { + kind: "ternary", + cond: lt(add(start, width), n), + then: add(start, width), + else_: n, + type: F64, + loc, + }, + loc, + }, + { + kind: "varDecl", + localId: "right.0", + init: { + kind: "ternary", + cond: lt(add(add(start, width), width), n), + then: add(add(start, width), width), + else_: n, + type: F64, + loc, + }, + loc, + }, + { kind: "varDecl", localId: "left.0", init: start, loc }, + { kind: "varDecl", localId: "r.0", init: mid, loc }, + { kind: "varDecl", localId: "k.0", init: start, loc }, + mergeOrCopy, + ], + loc, + }; + const params: IrParam[] = [ + { localId: "a.0", name: "a", type: bytesT }, + ...(hasComparator ? [{ localId: "f.0", name: "f", type: fnT }] : []), + ]; + const locals: IrLocal[] = [ + { id: "a.0", name: "a", type: bytesT, mutable: true }, + ...(hasComparator ? [{ id: "f.0", name: "f", type: fnT, mutable: true }] : []), + { id: "n.0", name: "n", type: F64, mutable: false }, + { id: "src.0", name: "src", type: bytesT, mutable: true }, + { id: "dst.0", name: "dst", type: bytesT, mutable: true }, + { id: "width.0", name: "width", type: F64, mutable: true }, + { id: "start.0", name: "start", type: F64, mutable: true }, + { id: "mid.0", name: "mid", type: F64, mutable: true }, + { id: "right.0", name: "right", type: F64, mutable: true }, + { id: "left.0", name: "left", type: F64, mutable: true }, + { id: "r.0", name: "r", type: F64, mutable: true }, + { id: "k.0", name: "k", type: F64, mutable: true }, + { id: "i.0", name: "i", type: F64, mutable: true }, + { id: "tmp.0", name: "tmp", type: bytesT, mutable: true }, + { id: "v.0", name: "v", type: F64, mutable: false }, + { id: "vL.0", name: "vL", type: F64, mutable: false }, + { id: "vR.0", name: "vR", type: F64, mutable: false }, + { id: "boundaryL.0", name: "boundaryL", type: F64, mutable: false }, + { id: "boundaryR.0", name: "boundaryR", type: F64, mutable: false }, + ]; + const slice = (receiver: IrExpr): IrExpr => ({ + kind: "bytesIntrinsic", + method: "slice", + receiver, + args: [], + type: bytesT, + loc, + }); + const body: IrStmt[] = [ + { kind: "assign", localId: "a.0", value: slice(a), loc }, + { kind: "varDecl", localId: "n.0", init: { kind: "bytesIntrinsic", method: "length", receiver: a, args: [], type: F64, loc }, loc }, + { kind: "varDecl", localId: "src.0", init: slice(a), loc }, + { kind: "varDecl", localId: "dst.0", init: slice(a), loc }, + { kind: "varDecl", localId: "width.0", init: numLit(1, loc), loc }, + { + kind: "while", + cond: lt(width, n), + body: [ + mergePass, + { kind: "varDecl", localId: "tmp.0", init: src, loc }, + { kind: "assign", localId: "src.0", value: dst, loc }, + { kind: "assign", localId: "dst.0", value: varRef("tmp.0", bytesT, loc), loc }, + { kind: "assign", localId: "width.0", value: mul(width, numLit(2, loc)), loc }, + ], + loc, + }, + { + kind: "for", + init: { kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc }, + cond: lt(varRef("i.0", F64, loc), n), + update: { kind: "assign", localId: "i.0", value: add(varRef("i.0", F64, loc), numLit(1, loc)), loc }, + body: [ + { kind: "varDecl", localId: "v.0", init: at(src, varRef("i.0", F64, loc)), loc }, + { kind: "bytesSet", arr: a, index: varRef("i.0", F64, loc), value: varRef("v.0", F64, loc), loc }, + ], + loc, + }, + { kind: "return", value: a, loc }, + ]; + return { name, params, returnType: bytesT, locals, body, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index f3f57c7eb..fa57e8e6c 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -11,6 +11,7 @@ import { droppableStatic, isRequireMainFilename, lowerDynObjectLiteral, probeLow import { forOfVarTarget, lowerDestructuringAssign } from "./lower-stmts.js"; import { isJsSourceFile, locOf } from "../program.js"; import { islandPrimitiveExit, lowerDynDispatchMethodCall } from "./lower-calls.js"; +import { buildArraySortFn, buildBytesSortFn } from "./lower-array-sort.js"; import { typeKey } from "../type-mapper.js"; import { dynUndefinedExpr, own, WidthLift } from "./lowerer.js"; import { boolLit, countedFor, numLit, varRef } from "../../ir/build.js"; @@ -1618,19 +1619,20 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr { * function like the other array HOFs. sort mutates and returns the receiver; * toSorted takes its shallow snapshot INSIDE the helper, after the receiver * and comparator expressions have both been evaluated, then sorts and - * returns that copy without touching the receiver. The loop is a - * binary-free INSERTION sort: stable (equal-comparing elements keep their - * source order, which is what Node's stable TimSort produces for any - * consistent comparator) and JS-faithful on the comparator contract — an - * element moves left only while cmp(left, v) > 0, so a NaN or 0 result holds - * position exactly like the spec's "treat as equal". The SEQUENCE of - * comparator calls differs from V8's TimSort (SEMANTICS.md); results are - * identical for consistent comparators. The comparator-less form lowers for - * STRING elements only — JS's default converts every element to string and - * compares UTF-16 units, so the interned synthesized comparator selects the - * runtime's code-unit ordering rather than scriptc's documented code-point - * relational operators. For numbers that default is the notorious string - * sort ([10, 9, 1] → [1, 10, 9]), deliberately fenced toward an explicit + * returns that copy without touching the receiver. The helper uses a stable + * bottom-up merge sort with an ordered-boundary check: ordered inputs use a + * linear number of comparator calls, but buffer movement remains O(n log n) + * even when every boundary is already ordered. During a merge, an element + * moves right only while cmp(left, right) > 0, so a NaN or 0 result keeps + * the left element first. Undefined values sink without reaching the user + * comparator. The callback sequence differs from V8's TimSort, so exact + * order and count parity are not claimed; results agree for consistent + * comparators. The comparator-less form lowers for STRING elements only — + * JS's default converts every element to string and compares UTF-16 units, + * so the interned synthesized comparator selects the runtime's code-unit + * ordering rather than scriptc's documented code-point relational + * operators. For numbers that default is the notorious string sort + * ([10, 9, 1] → [1, 10, 9]), deliberately fenced toward an explicit * comparator. */ function lowerArraySortCall(lowerer: Lowerer, call: ts.CallExpression, access: ts.PropertyAccessExpression, @@ -1745,183 +1747,11 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr { return name; } -/** The insertion-sort loop, from existing IR nodes: - * - * n = a.length; - * for (i = 1; i < n; i++) { - * v = a[i]; j = i - 1; - * while (j >= 0) { - * if (CompareArrayElements(a[j], v, f) > 0) { - * a[j + 1] = a[j]; j = j - 1; - * } else break; - * } - * a[j + 1] = v; - * } - * return a; - */ - function buildArraySortFn( - name: string, - elem: IrType, - arity: number, - copyFirst: boolean, - undefinedTag: number | null, - loc: SrcLoc, - ): IrFunction { - const arrT = arrayOf(elem); - const fnT = funcOf([elem, elem].slice(0, arity), F64); - - const j = varRef("j.0", F64, loc); - const at = (index: IrExpr): IrExpr => ({ kind: "arrayGet", arr: varRef("a.0", arrT, loc), index, type: elem, loc }); - const jPlus1: IrExpr = { kind: "bin", op: "+", left: j, right: numLit(1, loc), type: F64, loc }; - const isUndefined = (value: IrExpr): IrExpr | null => { - if (elem.kind === "union" && undefinedTag !== null) { - return { - kind: "unionIsTag", - unionId: elem.unionId, - tag: undefinedTag, - negated: false, - value, - type: BOOL, - loc, - }; - } - if (elem.kind === "jsval") { - return { - kind: "jsOp", - op: "eq", - args: [ - value, - { kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc }, - ], - type: BOOL, - loc, - }; - } - return null; - }; - const compareGreater: IrExpr = { - kind: "bin", - op: ">", - left: { - kind: "callValue", - callee: varRef("f.0", fnT, loc), - args: [at(j), varRef("v.0", elem, loc)].slice(0, arity), - type: F64, - loc, - }, - right: numLit(0, loc), - type: BOOL, - loc, - }; - const leftUndefined = isUndefined(at(j)); - const valueUndefined = isUndefined(varRef("v.0", elem, loc)); - // CompareArrayElements: undefined always sinks and never reaches the - // user comparator. Ternaries preserve that callback suppression. - const shouldShift: IrExpr = - leftUndefined !== null && valueUndefined !== null - ? { - kind: "ternary", - cond: leftUndefined, - then: { - kind: "unary", - op: "!", - operand: valueUndefined, - type: BOOL, - loc, - }, - else_: { - kind: "ternary", - cond: valueUndefined, - then: { kind: "boolLit", value: false, type: BOOL, loc }, - else_: compareGreater, - type: BOOL, - loc, - }, - type: BOOL, - loc, - } - : compareGreater; - const shiftLoop: IrStmt = { - kind: "while", - cond: { kind: "bin", op: ">=", left: j, right: numLit(0, loc), type: BOOL, loc }, - body: [ - { - kind: "if", - cond: shouldShift, - then: [ - { kind: "arraySet", arr: varRef("a.0", arrT, loc), index: jPlus1, value: at(j), loc }, - { kind: "assign", localId: "j.0", value: { kind: "bin", op: "-", left: j, right: numLit(1, loc), type: F64, loc }, loc }, - ], - else_: [{ kind: "break", loc }], - loc, - }, - ], - loc, - }; - const body: IrStmt[] = [ - ...(copyFirst - ? [{ - kind: "assign" as const, - localId: "a.0", - value: { - kind: "arrIntrinsic" as const, - method: "slice" as const, - receiver: varRef("a.0", arrT, loc), - args: [], - type: arrT, - loc, - }, - loc, - }] - : []), - readLenStmt(arrT, loc), - countedFor( - loc, - { kind: "bin", op: "-", left: varRef("n.0", F64, loc), right: numLit(1, loc), type: F64, loc }, - (index) => [ - { - kind: "varDecl", - localId: "v.0", - init: { - kind: "arrayGet", - arr: varRef("a.0", arrT, loc), - index: { kind: "bin", op: "+", left: index, right: numLit(1, loc), type: F64, loc }, - type: elem, - loc, - }, - loc, - }, - { kind: "varDecl", localId: "j.0", init: index, loc }, - shiftLoop, - { kind: "arraySet", arr: varRef("a.0", arrT, loc), index: jPlus1, value: varRef("v.0", elem, loc), loc }, - ], - ), - { kind: "return", value: varRef("a.0", arrT, loc), loc }, - ]; - return { - name, - params: [ - { localId: "a.0", name: "a", type: arrT }, - { localId: "f.0", name: "f", type: fnT }, - ], - returnType: arrT, - locals: [ - { id: "a.0", name: "a", type: arrT, mutable: true }, - { id: "f.0", name: "f", type: fnT, mutable: true }, - { id: "n.0", name: "n", type: F64, mutable: false }, - { id: "i.0", name: "i", type: F64, mutable: true }, - { id: "v.0", name: "v", type: elem, mutable: false }, - { id: "j.0", name: "j", type: F64, mutable: true }, - ], - body, - loc, - }; - } /** Uint8Array.prototype.toSorted. The receiver/comparator expressions are * evaluated before entering the helper; the helper snapshots with * TypedArray.prototype.slice before its first comparison, then performs - * the same stable insertion walk as Array.toSorted. Uint8Array's default + * the same stable merge walk as Array.toSorted. Uint8Array's default * comparator is numeric ascending, so its comparator-less form needs no * string-conversion machinery. */ function lowerBytesToSortedCall( @@ -2003,211 +1833,6 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr { }; } - function buildBytesSortFn( - name: string, - arity: number, - hasComparator: boolean, - loc: SrcLoc, - ): IrFunction { - const bytesT = BYTES_U8; - const fnT = funcOf([F64, F64].slice(0, arity), F64); - - const j = varRef("j.0", F64, loc); - const at = (index: IrExpr): IrExpr => ({ - kind: "bytesIntrinsic", - method: "get", - receiver: varRef("a.0", bytesT, loc), - args: [index], - type: F64, - loc, - }); - const jPlus1: IrExpr = { - kind: "bin", - op: "+", - left: j, - right: numLit(1, loc), - type: F64, - loc, - }; - const compare: IrExpr = hasComparator - ? { - kind: "callValue", - callee: varRef("f.0", fnT, loc), - args: [at(j), varRef("v.0", F64, loc)].slice(0, arity), - type: F64, - loc, - } - : { - kind: "bin", - op: "-", - left: at(j), - right: varRef("v.0", F64, loc), - type: F64, - loc, - }; - const shiftLoop: IrStmt = { - kind: "while", - cond: { - kind: "bin", - op: ">=", - left: j, - right: numLit(0, loc), - type: BOOL, - loc, - }, - body: [ - { - kind: "if", - cond: { - kind: "bin", - op: ">", - left: compare, - right: numLit(0, loc), - type: BOOL, - loc, - }, - then: [ - { - kind: "bytesSet", - arr: varRef("a.0", bytesT, loc), - index: jPlus1, - value: at(j), - loc, - }, - { - kind: "assign", - localId: "j.0", - value: { - kind: "bin", - op: "-", - left: j, - right: numLit(1, loc), - type: F64, - loc, - }, - loc, - }, - ], - else_: [{ kind: "break", loc }], - loc, - }, - ], - loc, - }; - const params: IrParam[] = [ - { localId: "a.0", name: "a", type: bytesT }, - ...(hasComparator - ? [{ localId: "f.0", name: "f", type: fnT }] - : []), - ]; - const locals: IrLocal[] = [ - { id: "a.0", name: "a", type: bytesT, mutable: true }, - ...(hasComparator - ? [{ id: "f.0", name: "f", type: fnT, mutable: true }] - : []), - { id: "n.0", name: "n", type: F64, mutable: false }, - { id: "i.0", name: "i", type: F64, mutable: true }, - { id: "v.0", name: "v", type: F64, mutable: false }, - { id: "j.0", name: "j", type: F64, mutable: true }, - ]; - const body: IrStmt[] = [ - { - kind: "assign", - localId: "a.0", - value: { - kind: "bytesIntrinsic", - method: "slice", - receiver: varRef("a.0", bytesT, loc), - args: [], - type: bytesT, - loc, - }, - loc, - }, - { - kind: "varDecl", - localId: "n.0", - init: { - kind: "bytesIntrinsic", - method: "length", - receiver: varRef("a.0", bytesT, loc), - args: [], - type: F64, - loc, - }, - loc, - }, - { - kind: "for", - init: { - kind: "varDecl", - localId: "i.0", - init: numLit(1, loc), - loc, - }, - cond: { - kind: "bin", - op: "<", - left: varRef("i.0", F64, loc), - right: varRef("n.0", F64, loc), - type: BOOL, - loc, - }, - update: { - kind: "assign", - localId: "i.0", - value: { - kind: "bin", - op: "+", - left: varRef("i.0", F64, loc), - right: numLit(1, loc), - type: F64, - loc, - }, - loc, - }, - body: [ - { - kind: "varDecl", - localId: "v.0", - init: at(varRef("i.0", F64, loc)), - loc, - }, - { - kind: "varDecl", - localId: "j.0", - init: { - kind: "bin", - op: "-", - left: varRef("i.0", F64, loc), - right: numLit(1, loc), - type: F64, - loc, - }, - loc, - }, - shiftLoop, - { - kind: "bytesSet", - arr: varRef("a.0", bytesT, loc), - index: jPlus1, - value: varRef("v.0", F64, loc), - loc, - }, - ], - loc, - }, - { kind: "return", value: varRef("a.0", bytesT, loc), loc }, - ]; - return { - name, - params, - returnType: bytesT, - locals, - body, - loc, - }; - } /** `n = a.length` — the once-up-front length read every array HOF loop * starts with (locals a.0/n.0 by convention). */ diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 332f88d22..9866a3590 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -5682,12 +5682,24 @@ ], "diags": [] }, + "/tests/corpus/2700-array-sort-complexity.ts": { + "order": [ + "/tests/corpus/2700-array-sort-complexity.ts" + ], + "diags": [] + }, "/tests/corpus/2700-wasi-core.ts": { "order": [ "/tests/corpus/2700-wasi-core.ts" ], "diags": [] }, + "/tests/corpus/2701-array-sort-snapshot.ts": { + "order": [ + "/tests/corpus/2701-array-sort-snapshot.ts" + ], + "diags": [] + }, "/tests/corpus/2701-import-meta/main.mjs": { "order": [ "/tests/corpus/2701-import-meta/module with space.mjs", @@ -5695,6 +5707,12 @@ ], "diags": [] }, + "/tests/corpus/2702-uint8array-to-sorted.ts": { + "order": [ + "/tests/corpus/2702-uint8array-to-sorted.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" diff --git a/scripts/sandbox-bootstrap.sh b/scripts/sandbox-bootstrap.sh index 07aa5e5bc..fe8186ac5 100644 --- a/scripts/sandbox-bootstrap.sh +++ b/scripts/sandbox-bootstrap.sh @@ -9,30 +9,35 @@ if [ -z "$node_version" ] || [ -z "$pnpm_version" ]; then fi # The managed image intentionally floats its distro and language runtimes. -# Install the same LLVM major and exact Node/pnpm tuple as the custom image so +# Install the same LLVM and exact Node/pnpm tuple as the custom image so # differential oracles do not change when the managed image is refreshed. +. /etc/os-release +llvm_distro=${VERSION_CODENAME:?the Sandbox image must identify its Ubuntu release} curl --fail --silent --show-error --location \ https://apt.llvm.org/llvm-snapshot.gpg.key \ --output /tmp/llvm-snapshot.gpg.key gpg --dearmor --yes --output /tmp/llvm-snapshot.gpg /tmp/llvm-snapshot.gpg.key sudo install -D -m 0644 /tmp/llvm-snapshot.gpg /etc/apt/keyrings/llvm-snapshot.gpg -echo "deb [signed-by=/etc/apt/keyrings/llvm-snapshot.gpg] https://apt.llvm.org/noble/ llvm-toolchain-noble-18 main" \ - | sudo tee /etc/apt/sources.list.d/llvm18.list >/dev/null +echo "deb [signed-by=/etc/apt/keyrings/llvm-snapshot.gpg] https://apt.llvm.org/${llvm_distro}/ llvm-toolchain-${llvm_distro}-22 main" \ + | sudo tee /etc/apt/sources.list.d/llvm22.list >/dev/null sudo apt-get update --quiet=2 sudo env DEBIAN_FRONTEND=noninteractive apt-get install --quiet=2 --yes --no-install-recommends \ build-essential \ ca-certificates \ ccache \ - clang-18 \ + clang-22 \ cmake \ - libclang-rt-18-dev \ - llvm-18 \ + libclang-rt-22-dev \ + libzstd-dev \ + llvm-22-dev \ + ninja-build \ xz-utils \ zlib1g-dev sudo rm -f /usr/local/bin/clang /usr/local/bin/clang++ -sudo ln -sf clang-18 /usr/bin/clang -sudo ln -sf clang++-18 /usr/bin/clang++ +sudo ln -sf clang-22 /usr/bin/clang +sudo ln -sf clang++-22 /usr/bin/clang++ +test "$(llvm-config-22 --version)" = 22.1.8 node_archive="node-v${node_version}-linux-x64.tar.xz" curl --fail --silent --show-error --location \ diff --git a/scripts/sandbox-command.mjs b/scripts/sandbox-command.mjs new file mode 100644 index 000000000..9d00e592f --- /dev/null +++ b/scripts/sandbox-command.mjs @@ -0,0 +1,22 @@ +export const MAX_INLINE_SANDBOX_COMMAND_BYTES = 768; + +export const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; + +/** Keep long shell programs out of the local CLI's argument vector. The + * same script records the remote exit status for either transport. */ +export function sandboxCommand(command, args, exitMarker) { + const statusPath = `/tmp/${exitMarker}.status`; + const script = + `${[command, ...args].map(shellQuote).join(" ")}; scriptc_status=$?; ` + + `printf '%s\\n' "$scriptc_status" > ${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; + return { + script, + scriptPath, + statusPath, + file, + argv: file ? ["sh", scriptPath] : ["sh", "-c", script], + }; +} diff --git a/scripts/sandbox-test.mjs b/scripts/sandbox-test.mjs index 5b79432a2..d7b624265 100644 --- a/scripts/sandbox-test.mjs +++ b/scripts/sandbox-test.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pipeline } from "node:stream/promises"; @@ -23,6 +23,7 @@ import { filterExistingWorktreePaths, workspaceResetCommand, } from "./worktree-files.mjs"; +import { sandboxCommand, shellQuote } from "./sandbox-command.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const laneCaseShardedFiles = [ @@ -371,7 +372,6 @@ const vercel = ([group, command, ...args], options) => }); // `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 shellQuote = (value) => `'${value.replaceAll("'", `'\"'\"'`)}'`; const execIn = async ( worker, command, @@ -384,12 +384,22 @@ const execIn = async ( ) => { const envArgs = Object.entries(env).flatMap(([key, value]) => ["--env", `${key}=${value}`]); const exitMarker = `__SCRIPTC_REMOTE_EXIT_${randomBytes(12).toString("hex")}__`; - const statusPath = `/tmp/${exitMarker}.status`; - const script = - `${[command, ...args].map(shellQuote).join(" ")}; scriptc_status=$?; ` + - `printf '%s\\n' "$scriptc_status" > ${shellQuote(statusPath)}; ` + - `printf '\\n${exitMarker}%s\\n' "$scriptc_status"`; + 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", @@ -399,9 +409,7 @@ const execIn = async ( workdir, ...envArgs, worker.name, - "sh", - "-c", - script, + ...prepared.argv, ]; try { await vercel(commandArgs, { @@ -411,7 +419,7 @@ const execIn = async ( timeoutMs: wallTimeoutMs, }); } catch (error) { - console.warn(`[${label}] CLI completion was not confirmed; checking the remote command status...`); + 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)} || ` + @@ -714,6 +722,10 @@ try { } 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: "clang-22", AR: "llvm-ar-22" }, "runtime pack", 5 * 60_000); }, imageConfig.custom ? workers.length : 8); await allWorkers("Testing", async (worker) => { diff --git a/tests/corpus/2700-array-sort-complexity.ts b/tests/corpus/2700-array-sort-complexity.ts new file mode 100644 index 000000000..6efaf473b --- /dev/null +++ b/tests/corpus/2700-array-sort-complexity.ts @@ -0,0 +1,44 @@ +// Stable sort must stay scalable across ordered, adversarial, random, and +// duplicate-heavy inputs. The budget is deliberately loose for a merge sort +// but rejects the old quadratic insertion sort on the larger cases. +function checkBudget(label: string, input: number[], budget: number): void { + let comparisons = 0; + const sorted = input.slice(); + sorted.sort((a, b) => { + comparisons++; + if (comparisons > budget) throw new Error(label + " exceeded comparison budget"); + return a - b; + }); + for (let i = 1; i < sorted.length; i++) { + if (sorted[i - 1]! > sorted[i]!) throw new Error(label + " was not sorted"); + } + console.log(label, sorted.length, sorted[0], sorted[sorted.length - 1]); +} + +const size = 4096; +const ascending: number[] = []; +const descending: number[] = []; +const random: number[] = []; +const duplicates: number[] = []; +let seed = 123456789; +for (let i = 0; i < size; i++) { + ascending.push(i); + descending.push(size - i - 1); + seed = (seed * 1664525 + 1013904223) % 4294967296; + random.push(seed % size); + duplicates.push((i * 17) % 31); +} + +checkBudget("ascending", ascending, size * 4); +checkBudget("descending", descending, size * 32); +checkBudget("random", random, size * 32); +checkBudget("duplicates", duplicates, size * 32); + +const ties = [ + { key: 1, order: "a" }, + { key: 0, order: "b" }, + { key: 1, order: "c" }, + { key: 0, order: "d" }, +]; +ties.sort((a, b) => a.key - b.key); +console.log(ties.map((entry) => entry.order).join(",")); diff --git a/tests/corpus/2701-array-sort-snapshot.ts b/tests/corpus/2701-array-sort-snapshot.ts new file mode 100644 index 000000000..6cf598392 --- /dev/null +++ b/tests/corpus/2701-array-sort-snapshot.ts @@ -0,0 +1,46 @@ +const receiver = [3, 1, 2]; +const returned = receiver.sort((a, b) => a - b); +console.log(returned === receiver, receiver.join(","), returned.join(",")); + +const mutatedReceiver = [3, 1, 2]; +let changedReceiver = false; +mutatedReceiver.sort((a, b) => { + if (!changedReceiver) { + changedReceiver = true; + mutatedReceiver[0] = 99; + } + return a - b; +}); +console.log(mutatedReceiver.join(",")); + +const source = [3, 1, 2]; +let changed = false; +const sorted = source.toSorted((a, b) => { + if (!changed) { + changed = true; + source[0] = 99; + source.push(4); + } + return a - b; +}); +console.log(sorted.join(",")); +console.log(source.join(",")); + +const stable = [ + { key: "b", order: 1 }, + { key: "a", order: 2 }, + { key: "b", order: 3 }, + { key: "a", order: 4 }, +]; +const byKey = stable.toSorted((a, b) => a.key.localeCompare(b.key)); +console.log(byKey.map((entry) => entry.order).join(",")); +console.log(stable.map((entry) => entry.order).join(",")); + +const throwing = [3, 1, 2]; +try { + throwing.sort((a, b): number => { + throw new Error("stop"); + }); +} catch (error) { + console.log(error instanceof Error, throwing.join(",")); +} diff --git a/tests/corpus/2702-uint8array-to-sorted.ts b/tests/corpus/2702-uint8array-to-sorted.ts new file mode 100644 index 000000000..325a3a67a --- /dev/null +++ b/tests/corpus/2702-uint8array-to-sorted.ts @@ -0,0 +1,16 @@ +const source = new Uint8Array(256); +for (let i = 0; i < source.length; i++) source[i] = 255 - i; + +let comparisons = 0; +const sorted = source.toSorted((a, b) => { + comparisons++; + if (comparisons > 5000) throw new Error("Uint8Array sort exceeded comparison budget"); + return a - b; +}); +console.log(sorted[0], sorted[255], source[0], source[255]); + +const descending = source.toSorted((a, b) => b - a); +console.log(descending[0], descending[255], source[0], source[255]); + +const defaults = new Uint8Array([9, 1, 7, 1, 5, 2]).toSorted(); +console.log(defaults[0], defaults[1], defaults[2], defaults[3], defaults[4], defaults[5]); diff --git a/tests/harness/sandbox-command.test.ts b/tests/harness/sandbox-command.test.ts new file mode 100644 index 000000000..6003fe09e --- /dev/null +++ b/tests/harness/sandbox-command.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { sandboxCommand } from "../../scripts/sandbox-command.mjs"; + +const roots: string[] = []; +const statuses: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + await Promise.all(statuses.splice(0).map((path) => rm(path, { force: true }))); +}); + +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); + + 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(await readFile(prepared.statusPath, "utf8")).toBe("7\n"); +}); + +test("uploaded scripts preserve long and shell-sensitive argument bytes", async () => { + const root = await mkdtemp("/tmp/scriptc-sandbox-command-test-"); + roots.push(root); + const sentinel = join(root, "unexpected"); + const args = [ + "x".repeat(2048), + "quote' and spaces", + `$(touch ${sentinel})`, + "backtick` dollar$ slash\\ newline\nend", + "é".repeat(512), + ]; + const marker = `__SCRIPTC_COMMAND_TEST_${process.pid}_LONG__`; + const prepared = sandboxCommand("printf", ["<%s>\n", ...args], marker); + statuses.push(prepared.statusPath); + expect(prepared.file).toBe(true); + expect(prepared.argv).toEqual(["sh", prepared.scriptPath]); + expect(prepared.argv.every((arg) => arg.length < 128)).toBe(true); + + const localScript = join(root, "command.sh"); + 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`); + 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 e26ea4b00..71f0b4a7c 100644 --- a/tests/harness/sandbox-config.test.ts +++ b/tests/harness/sandbox-config.test.ts @@ -66,11 +66,12 @@ test("the managed-image bootstrap pins the custom image's Node, pnpm, and LLVM", const bootstrap = readFileSync(new URL("../../scripts/sandbox-bootstrap.sh", import.meta.url), "utf8"); expect(bootstrap).toContain("< .node-version"); expect(bootstrap).toContain("ARG PNPM_VERSION="); - expect(bootstrap).toContain("llvm-toolchain-noble-18"); + expect(bootstrap).toContain("llvm-toolchain-${llvm_distro}-22"); expect(bootstrap).toContain("install -D -m 0644"); - expect(bootstrap).toContain("clang-18"); - expect(bootstrap).toContain("libclang-rt-18-dev"); - expect(bootstrap).toContain("llvm-18"); + expect(bootstrap).toContain("clang-22"); + expect(bootstrap).toContain("libclang-rt-22-dev"); + expect(bootstrap).toContain("llvm-22-dev"); + expect(bootstrap).toContain("ninja-build"); }); test("a custom image does not select the Sandbox team or project", () => {