Files

121 lines
5.8 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
// The door out of a VM's thread (src/jsc/VmHandle.rs).
//
// A `VirtualMachine` is destroyed only after everything that left its thread
// has come back: work that runs on, or is referenced from, another thread on a
// VM's behalf holds a `bun_jsc::Ticket`, and the VM's teardown waits for every
// ticket. That is only a guarantee if there is no way *around* the door, so
// this lint freezes, for the VM crates (`jsc`, `runtime`, `event_loop`,
// `sql_jsc`, `http_jsc`), the two things that could open one:
//
// 1. `unsafe impl Send` / `unsafe impl Sync`, the `owned_task!` macro
// (which emits one) and `intrusive_work_task!` (which is what makes a
// type schedulable on the pool). `VirtualMachine`,
// `EventLoop` and `JSGlobalObject` are `!Send + !Sync`, so VM state can
// only reach another thread inside a type someone declared `Send` by hand.
// Every such type must either carry a `Ticket` for the VM whose state it
// holds, or hold no VM/JS state at all.
// 2. Direct calls to the raw thread-crossing primitives — the work pool, the
// HTTP thread, thread spawning, `uv_queue_work`. VM code reaches these
// through `bun_jsc::Job` or through a struct that holds a `Ticket` for
// its in-flight duration; a new direct call site is a new path that has
// to be shown to do the same.
//
// If this fails because you ADDED one: make the payload carry a `Ticket`
// (taken on the JS thread with `vm.ticket()`, dropped after the completion is
// posted) or show it is VM-free, say so in a `// SAFETY:` comment, then
// regenerate the inventory:
// bun ./test/internal/source-lints/vm-thread-door.test.ts --update
// If it fails because you REMOVED one: regenerate the same way.
import { file } from "bun";
import { describe, expect, test } from "bun:test";
import { realpathSync } from "fs";
import path from "path";
import { globAllSources } from "../../../scripts/glob-sources.ts";
const root = path.resolve(import.meta.dir, "..", "..", "..");
const INVENTORY = import.meta.dir + "/vm-thread-door.inventory.json";
const SCOPED = ["src/jsc/", "src/runtime/", "src/event_loop/", "src/sql_jsc/", "src/http_jsc/"];
// The door itself.
const DOOR = new Set(["src/jsc/VmHandle.rs", "src/jsc/job.rs"]);
const PATTERNS: [name: string, re: RegExp][] = [
["unsafe impl Send", /\bunsafe\s+impl(?:\s*<[^>]*>)?\s+Send\s+for\s+([A-Za-z_][\w:<>, ']*)/g],
["unsafe impl Sync", /\bunsafe\s+impl(?:\s*<[^>]*>)?\s+Sync\s+for\s+([A-Za-z_][\w:<>, ']*)/g],
["owned_task!", /\b(?:bun_threading::)?owned_task!\s*\(\s*([A-Za-z_]\w*)/g],
["intrusive_work_task!", /\b(?:bun_threading::)?intrusive_work_task!\s*\(\s*([A-Za-z_]\w*)/g],
["WorkPool::schedule*", /\bWorkPool::(?:schedule|schedule_new|schedule_owned|go)\b/g],
["worker_pool.schedule(Batch)", /\)\s*\.schedule\(\s*(?:bun_threading::thread_pool::)?Batch::from/g],
["HTTPThread::schedule", /\bHTTPThread::schedule\s*\(/g],
["thread spawn", /\bthread::(?:Builder::new\s*\(|spawn\s*\()/g],
["uv_queue_work", /(?<!fn\s)\buv_queue_work\s*\(/g],
];
const tracked: Set<string> | null = (() => {
const r = Bun.spawnSync({
cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"],
stdout: "pipe",
stderr: "ignore",
});
if (!r.success) return null;
return new Set(r.stdout.toString().split("\0").filter(Boolean));
})();
type Inventory = Record<string, Record<string, string[] | number>>;
const found: Inventory = {};
for (const abs of globAllSources().rust.filter(p => p.endsWith(".rs"))) {
const source = path.relative(root, abs).replaceAll(path.sep, "/");
if (path.relative(root, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue;
if (!SCOPED.some(p => source.startsWith(p)) || DOOR.has(source)) continue;
if (tracked !== null && !tracked.has(source)) continue;
const stripped = (await file(abs).text()).replace(/^\s*\/\/.*$/gm, "");
for (const [name, re] of PATTERNS) {
const matches = [...stripped.matchAll(re)];
if (matches.length === 0) continue;
const entry = (found[source] ??= {});
if (matches[0].length > 1) {
entry[name] = matches.map(m => m[1].trim().replace(/\s+/g, " ")).sort();
} else {
entry[name] = matches.length;
}
}
}
const sortKeys = <T>(o: Record<string, T>): Record<string, T> =>
Object.fromEntries(Object.entries(o).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
const normalized: Inventory = sortKeys(Object.fromEntries(Object.entries(found).map(([k, v]) => [k, sortKeys(v)])));
if (process.argv.includes("--update")) {
await Bun.write(INVENTORY, JSON.stringify(normalized, null, 2) + "\n");
console.log(`Wrote ${Object.keys(normalized).length} files to ${path.basename(INVENTORY)}`);
process.exit(0);
}
const inventory: Inventory = await Bun.file(INVENTORY).json();
describe("VM thread door", () => {
const files = [...new Set([...Object.keys(inventory), ...Object.keys(normalized)])].sort();
test.each(files)("%s", source => {
const expected = inventory[source] ?? {};
const actual = normalized[source] ?? {};
if (!Bun.deepEquals(actual, expected)) {
throw new Error(
`${source}: thread-crossing inventory changed.\n` +
` expected: ${JSON.stringify(expected)}\n` +
` actual: ${JSON.stringify(actual)}\n` +
`A new \`unsafe impl Send/Sync\` or a new direct call to a thread-crossing primitive in a VM crate ` +
`must carry a \`bun_jsc::Ticket\` for the VM whose state it holds (or hold none) — see src/jsc/VmHandle.rs. ` +
`Then regenerate: bun ./test/internal/source-lints/vm-thread-door.test.ts --update`,
);
}
});
test("VirtualMachine stays !Send + !Sync", async () => {
const vm = await file(path.join(root, "src/jsc/VirtualMachine.rs")).text();
expect(vm).not.toMatch(/unsafe\s+impl\s+(?:Send|Sync)\s+for\s+VirtualMachine\b/);
expect(vm).toContain("<VirtualMachine as AmbiguousIfImpl<_>>::some_item");
});
});