mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-23 06:10:02 +08:00
In worker executables the small-object allocator state, the collector's live and old-freed counts and the weak dispose hook were four separate thread-locals, and every allocation or free touched them all. On Darwin each distinct thread-local costs a _tlv_get_addr call per function, so a runtime free made four such calls. They now share one ScrThreadHot block (macros keep the runtime sources unchanged; ordinary and library builds keep separate globals). The emitted inline paths address the fields at fixed offsets from a single llvm.threadlocal.address, which LLVM shares across a function. tsc-ts self-check, macOS, Worker build vs non-worker build: 1.12x -> 1.10x.
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, expect, test, vi } from "vitest";
|
|
import { compile, compileLibrary } from "../src/index.js";
|
|
|
|
const directories: string[] = [];
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
async function fixture(source: string) {
|
|
const outDir = await mkdtemp(join(tmpdir(), "scriptc-worker-target-"));
|
|
directories.push(outDir);
|
|
const entry = join(outDir, "main.ts");
|
|
await writeFile(entry, source);
|
|
return { entry, outDir, outPath: join(outDir, "program.ll"), outputKind: "llvm" as const };
|
|
}
|
|
const worker =
|
|
'import { Worker } from "node:worker_threads"; new Worker(new URL(import.meta.url));';
|
|
|
|
test.each(["dynamic", "wasi"])(
|
|
"worker constructors refuse %s execution before code generation",
|
|
async (mode) => {
|
|
const request = await fixture(worker);
|
|
if (mode === "wasi") {
|
|
vi.stubEnv("SCRIPTC_CC", "zigcc");
|
|
vi.stubEnv("SCRIPTC_TARGET", "wasm32-wasi");
|
|
}
|
|
const result = await compile(request.entry, { ...request, dynamic: mode === "dynamic" });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: "SC2020",
|
|
message: expect.stringContaining("Worker execution"),
|
|
}),
|
|
]),
|
|
);
|
|
},
|
|
);
|
|
|
|
test("runtime worker filenames produce an actionable source diagnostic", async () => {
|
|
const request = await fixture(
|
|
'import { Worker } from "node:worker_threads"; new Worker(process.argv[2]);',
|
|
);
|
|
const result = await compile(request.entry, request);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: "SC2020",
|
|
hint: expect.stringContaining("statically compiled"),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
test("libraries refuse worker roots through the library diagnostic boundary", async () => {
|
|
const request = await fixture(worker + "\nexport function boot(): number { return 0; }");
|
|
const profilePath = join(request.outDir, "profile.json");
|
|
await writeFile(
|
|
profilePath,
|
|
JSON.stringify({
|
|
profile_format: 1,
|
|
name: "worker-library",
|
|
entry: "main.ts",
|
|
emission: "llvm",
|
|
abi: {
|
|
prefix: "w_",
|
|
init_symbol: "w_init",
|
|
sink_register_symbol: "w_panic",
|
|
collect_symbol: null,
|
|
result_reset_symbol: null,
|
|
},
|
|
exports: [{ export: "boot", symbol: "w_boot", params: [], returns: "f64" }],
|
|
}),
|
|
);
|
|
const result = await compileLibrary({ profilePath, outDir: request.outDir });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: "SC4005", message: expect.stringContaining("worker") }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
test("worker TLS arguments use local snapshots before native pointer expansion", async () => {
|
|
const request = await fixture(
|
|
worker +
|
|
'\nimport { createSecureContext } from "node:tls"; const cert = Buffer.from(new SharedArrayBuffer(4)); createSecureContext({cert, key: cert});',
|
|
);
|
|
const result = await compile(request.entry, request);
|
|
expect(result.ok, JSON.stringify(result)).toBe(true);
|
|
if (result.ok) {
|
|
const llvm = await readFile(request.outPath, "utf8");
|
|
expect(llvm).toContain("call ptr @scr_bytes_local_copy(");
|
|
expect(llvm).toContain("call ptr @scr_tls_create_secure_context(");
|
|
}
|
|
});
|
|
|
|
test("worker programs refuse foreign callbacks and guard byte-pointer FFI calls", async () => {
|
|
for (const foreign of [true, false]) {
|
|
const source = foreign
|
|
? "declare function nativeStart(cb: (value: number) => void): void; nativeStart(() => {});"
|
|
: "declare function consume(value: Uint8Array): number; consume(new Uint8Array(new SharedArrayBuffer(4)));";
|
|
const request = await fixture(worker + source);
|
|
const ffiProfilePath = join(request.outDir, "ffi.json");
|
|
await writeFile(
|
|
ffiProfilePath,
|
|
JSON.stringify({
|
|
ffi_format: 5,
|
|
functions: [
|
|
foreign
|
|
? {
|
|
name: "nativeStart",
|
|
symbol: "native_start",
|
|
returns: "void",
|
|
params: [
|
|
{
|
|
callback: {
|
|
id: "tick",
|
|
params: ["f64", { context: "tick" }],
|
|
returns: "void",
|
|
lifetime: "retained",
|
|
invoke: "foreign",
|
|
},
|
|
},
|
|
{ context: "tick" },
|
|
],
|
|
}
|
|
: { name: "consume", symbol: "consume", params: ["bytes"], returns: "f64" },
|
|
],
|
|
}),
|
|
);
|
|
const result = await compile(request.entry, { ...request, ffiProfilePath });
|
|
expect(result.ok, JSON.stringify(result)).toBe(!foreign);
|
|
if (result.ok)
|
|
expect(await readFile(request.outPath, "utf8")).toContain(
|
|
"call void @scr_bytes_require_unshared(",
|
|
);
|
|
else
|
|
expect(result.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: "SC2020",
|
|
message: expect.stringContaining("foreign-thread"),
|
|
}),
|
|
]),
|
|
);
|
|
}
|
|
});
|
|
|
|
test("worker programs inline the thread-local allocator and keep async state behind runtime calls", async () => {
|
|
const source =
|
|
worker +
|
|
`
|
|
class Cell {
|
|
constructor(public next: Cell | null, public value: number) {}
|
|
}
|
|
let head: Cell | null = null;
|
|
for (let i = 0; i < 3; i++) head = new Cell(head, i);
|
|
async function settle(): Promise<number> {
|
|
return head === null ? 0 : head.value;
|
|
}
|
|
settle().then((value) => console.log(value));
|
|
`;
|
|
const request = await fixture(source);
|
|
const result = await compile(request.entry, request);
|
|
expect(result.ok, JSON.stringify(result)).toBe(true);
|
|
if (result.ok) {
|
|
const llvm = await readFile(request.outPath, "utf8");
|
|
// Worker runtimes keep one small-object allocator per script thread:
|
|
// the inline paths address its state, live counts and dispose hook in
|
|
// the runtime's one thread-local ScrThreadHot block.
|
|
expect(llvm).toMatch(/@scr_thread_hot = external thread_local(\(initialexec\))? global /);
|
|
expect(llvm).not.toMatch(/@scr_(sa|cyc_live|cyc_old_freed|weak_dispose_hook) = external/);
|
|
expect(llvm).toContain("call ptr @llvm.threadlocal.address.p0(ptr @scr_thread_hot)");
|
|
expect(llvm).toMatch(/getelementptr inbounds i8, ptr %[\w.]+\.tl\.hot, i64 808/);
|
|
// Worker fibers own context termination; no fiberless async frames.
|
|
expect(llvm).not.toContain("@scr_async_inline_enter");
|
|
}
|
|
});
|
|
|
|
test("JavaScript worker entry spellings run their TypeScript sources", async () => {
|
|
const request = await fixture(`import { Worker } from "node:worker_threads";
|
|
function run(worker: Worker): Promise<void> {
|
|
return new Promise<void>((resolve) => {
|
|
worker.on("message", (text: string) => console.log(text));
|
|
worker.on("exit", () => resolve());
|
|
});
|
|
}
|
|
await run(new Worker(new URL("./task.js", import.meta.url)));
|
|
await run(new Worker(new URL("./module-task.mjs", import.meta.url)));
|
|
`);
|
|
for (const [file, text] of [
|
|
["task.ts", "script task"],
|
|
["module-task.mts", "module task"],
|
|
])
|
|
await writeFile(
|
|
join(request.outDir, file),
|
|
`import { parentPort } from "node:worker_threads";\nparentPort!.postMessage("${text}");\n`,
|
|
);
|
|
const outPath = join(request.outDir, "program");
|
|
const result = await compile(request.entry, { outDir: request.outDir, outPath });
|
|
expect(result.ok, JSON.stringify(result)).toBe(true);
|
|
const { stdout } = await promisify(execFile)(outPath, [], { encoding: "utf8" });
|
|
expect(stdout).toBe("script task\nmodule task\n");
|
|
});
|
|
|
|
test("worker entries without a source file stay refused", async () => {
|
|
const request = await fixture(
|
|
'import { Worker } from "node:worker_threads"; new Worker(new URL("./missing.js", import.meta.url));',
|
|
);
|
|
const result = await compile(request.entry, request);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ message: expect.stringContaining("missing.js") }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
test("worker programs poll inline and skip termination polls in bounded functions", async () => {
|
|
const request = await fixture(
|
|
worker +
|
|
`
|
|
function mix(a: number, b: number): number { return (a * 31 + b) % 1000003; }
|
|
function spin(n: number): number { return n < 2 ? mix(n, 1) : spin(n - 1) + spin(n - 2); }
|
|
console.log(mix(1, 2), spin(5));
|
|
`,
|
|
);
|
|
const result = await compile(request.entry, request);
|
|
expect(result.ok, JSON.stringify(result)).toBe(true);
|
|
if (result.ok) {
|
|
const llvm = await readFile(request.outPath, "utf8");
|
|
const body = (name: string): string => {
|
|
const start = llvm.search(new RegExp(`^define [^\\n]*@${name}\\(`, "m"));
|
|
expect(start).toBeGreaterThanOrEqual(0);
|
|
return llvm.slice(start, llvm.indexOf("\n}\n", start));
|
|
};
|
|
// A bounded helper neither polls on entry nor makes its callers poll.
|
|
expect(body("sc_f_mix")).not.toContain("@scr_exc_alert");
|
|
// Recursion polls on entry: the inline test reads the process-wide
|
|
// alert word (no thread-local access), and only the slow path calls into
|
|
// the runtime.
|
|
expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/);
|
|
expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()");
|
|
expect(body("sc_f_spin")).not.toContain("@scr_exc_active");
|
|
expect(llvm).toMatch(/^@scr_exc_alert = external global i32, align 64$/m);
|
|
}
|
|
});
|
|
|
|
test.each([
|
|
["x86_64-unknown-linux-gnu", true],
|
|
["aarch64-unknown-linux-gnu", true],
|
|
["arm64-apple-macosx14.0.0", false],
|
|
])("worker executables for %s use the executable TLS models: %s", async (target, models) => {
|
|
vi.stubEnv("SCRIPTC_TARGET", target);
|
|
const request = await fixture(worker + "\nlet count = 0;\ncount++;\nconsole.log(count);\n");
|
|
const result = await compile(request.entry, request);
|
|
expect(result.ok, JSON.stringify(result)).toBe(true);
|
|
if (result.ok) {
|
|
const llvm = await readFile(request.outPath, "utf8");
|
|
// Every thread-local of a worker executable lives in its own TLS block:
|
|
// ELF uses local-exec for the program's and initial-exec for the
|
|
// runtime's; Darwin's thread-local variables have one model.
|
|
const plain = /^@[-$._A-Za-z0-9]+ = (internal|external) thread_local global /m;
|
|
if (models) {
|
|
expect(llvm).not.toMatch(plain);
|
|
expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = internal thread_local\(localexec\) global /m);
|
|
expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = external thread_local\(initialexec\) global /m);
|
|
} else {
|
|
expect(llvm).toMatch(plain);
|
|
expect(llvm).not.toContain("thread_local(");
|
|
}
|
|
}
|
|
});
|