diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fef759efb85..5663a7df1809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 5da618c79ea5..d5cfc5d363ad 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -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. diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 2e6aefc3c124..f870d63060e8 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -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 that will never destruct because // this path does not return through them; release on their behalf so ~VM — and with it @@ -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 diff --git a/test/js/node/worker_threads/worker_destruction.test.ts b/test/js/node/worker_threads/worker_destruction.test.ts index 3a54646ace91..faa7193aadca 100644 --- a/test/js/node/worker_threads/worker_destruction.test.ts +++ b/test/js/node/worker_threads/worker_destruction.test.ts @@ -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); + }); });