Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Avoid an extra tracing collection when destroying Workers while preserving native-resource finalizers and the mandatory final heap sweep.

- Release each Worker thread's event-name cache after VM teardown, preventing native memory growth during repeated Worker creation. Ports [oven-sh/bun#38164](https://github.com/oven-sh/bun/pull/38164); thanks @robobun!

- Release process stdio polling registrations between isolated test files so Linux parallel test runs can initialize stdout and stderr in each file. Adapts oven-sh/bun#38008 to the fork's borrowed stdio descriptors.
Expand Down
2 changes: 2 additions & 0 deletions docs/runtime/nodejs-compat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ Worker shutdown releases the thread's native event-name cache after destroying i

Immediate engine GC timers wake idle workers when scheduled. These native wakeups do not keep a worker alive.

Worker teardown avoids an extra full collection before final heap destruction. Native cleanup hooks and registered finalizers still run before the exit is acknowledged.

Budgeted workers can use young-generation collections within their managed heap budgets; full collections still confirm heap-limit exhaustion. Synchronous GC wakes a passive collector even while another heap occupies the shared helpers.

File workers select `--require` and `--import` preloads from their environment independently of `execArgv`. Explicit `env` or `execArgv` options re-read the worker's `NODE_OPTIONS` and `BUN_OPTIONS` before applying the selected CLI arguments. `execArgv: []` removes CLI preloads while environment preloads still apply. Require preloads run before import preloads.
Expand Down
2 changes: 1 addition & 1 deletion src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4777,7 +4777,6 @@ extern "C" void Bun__GlobalObject__clearExceptionsForExit(Zig::GlobalObject* glo

static void destroyVM(JSC::VM& vm)
{
vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full);
// Every JSLockHolder still on the native stack (process.exit() from inside a JS callback,
// the worker thread's manual API lock) holds a RefPtr<VM> that will never destruct because
// this path does not return through them; release on their behalf so ~VM — and with it
Expand Down Expand Up @@ -4835,6 +4834,7 @@ extern "C" void Zig__GlobalObject__destructOnExit(Zig::GlobalObject* globalObjec
gcUnprotect(globalObject);
globalObject = nullptr;

vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full);
destroyVM(vm);
runLoop->threadWillExit();
// `context` is released here, after ~VM: contextDestroyed() reaches observers at a defined
Expand Down
49 changes: 49 additions & 0 deletions test/js/node/worker_threads/worker_destruction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,53 @@ describe("Worker destruction", () => {
expect(stdout).toBe("worker exit 0\n");
expect(exitCode).toBe(0);
});

test.each(["terminate", "worker-exit"])("%s uses the final heap sweep without another tracing GC", async mode => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const { Worker } = require("node:worker_threads");
const { writeSync } = require("node:fs");
(async () => {
const worker = new Worker(\`
const { parentPort } = require("node:worker_threads");
parentPort.on("message", () => process.exit(0));
Bun.gc(true);
parentPort.postMessage("ready");
\`, { eval: true });
const exited = new Promise((resolve, reject) => {
worker.once("exit", resolve);
worker.once("error", reject);
});
await Promise.race([
new Promise(resolve => worker.once("message", resolve)),
exited.then(code => { throw new Error("early exit " + code); }),
]);
writeSync(2, "\\nWORKER_TEARDOWN_BEGIN\\n");
const code = ${JSON.stringify(mode)} === "terminate"
? await worker.terminate()
: (worker.postMessage("exit"), await exited);
writeSync(2, "\\nWORKER_TEARDOWN_END\\n");
console.log(code);
})().catch(error => { console.error(error); process.exitCode = 1; });
`,
],
env: { ...bunEnv, BUN_JSC_logGC: "1", BUN_JSC_useConcurrentGC: "false" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe(mode === "terminate" ? "1" : "0");
const parts = stderr.split("WORKER_TEARDOWN_BEGIN\n");
expect(parts).toHaveLength(2);
const teardown = parts[1].split("WORKER_TEARDOWN_END\n");
expect(teardown).toHaveLength(2);
const shutdowns = [...teardown[0].matchAll(/\[GC<([^>]+)>: shutdown [^\]]*ms\]/g)];
expect(shutdowns).toHaveLength(1);
const collections = [...teardown[0].matchAll(/\[GC<([^>]+)>: START [^\n]*=> FullCollection/g)];
expect(collections.filter(match => match[1] === shutdowns[0][1])).toHaveLength(0);
});
});
Loading