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

- 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.

- Publish ready POSIX subprocess exits and drain their owned pipes before stalled-host retirement observers, without changing the global event-loop phase order; wait for extra stdio pipes before emitting child `close`.
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 @@ -318,6 +318,8 @@ Valid `vm.Script` cached data is accepted when JSC runs without a JIT. Validatio

### [`node:worker_threads`](https://nodejs.org/api/worker_threads.html)

Worker shutdown releases the thread's native event-name cache after destroying its VM.

`resourceLimits` reports resolved heap, nursery, code-range, and stack settings. With the pinned OpenClaw WebKit, managed-heap exhaustion terminates only the affected worker with `ERR_WORKER_OUT_OF_MEMORY` and exit code 1. After a full GC confirms the cap is exceeded, pending termination is delivered at an existing JavaScript safe point without waiting for repeated full collections. ArrayBuffer backing stores are excluded. `codeRangeSizeMb` is reporting-only because JSC shares its executable allocator. See [worker resource limits](/runtime/workers#node-worker-resource-limits) for defaults and limitations.

`worker.getHeapStatistics()` takes an owning-thread snapshot while a worker is idle, executing JavaScript, or blocked in `Atomics.wait()`. The snapshot includes freshly allocated external storage. Inspection preserves the atomic wait and pending requests settle if the worker exits.
Expand Down
6 changes: 3 additions & 3 deletions src/jsc/bindings/c-bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -422,9 +422,9 @@ static char* shared_header_buffer_get()
return buffer.get();
}

// A Worker returns out of shutdown() instead of calling pthread_exit, and the process can exit
// before its TLS destructors run, so the worker releases this explicitly at teardown. The getter
// re-allocates on demand.
// Bun compiles with -fno-c++-static-destructors, so the thread_local above is never destroyed when
// a worker thread exits; the worker releases it explicitly at teardown (WebWorker::shutdown). The
// getter re-allocates on demand.
extern "C" void Bun__freeSharedHeaderBufferForThreadExit()
{
shared_header_buffer_slot().reset();
Expand Down
6 changes: 6 additions & 0 deletions src/jsc/bindings/webcore/EventNames.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ const EventNames& eventNames()
return *eventNames_;
}

// C++ TLS destructors are disabled; release atoms before the worker thread's AtomStringTable dies.
extern "C" void Bun__destroyEventNamesForThreadExit()
{
eventNames_.reset();
}

enum class DOMEventName : uint8_t {
rename = 0,
change = 1,
Expand Down
7 changes: 4 additions & 3 deletions src/jsc/web_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ unsafe extern "C" {
);
safe fn WebWorker__disarmHeapLimitObserver(proxy: *mut c_void);
safe fn WebWorker__stackSize(proxy: *mut c_void) -> usize;
safe fn Bun__destroyEventNamesForThreadExit();
safe fn Bun__freeSharedHeaderBufferForThreadExit();
// Raw FFI (no RAII guard) so `thread_main` can take the API lock and abandon
// it with the VM — see the note there.
Expand Down Expand Up @@ -1365,9 +1366,9 @@ impl WebWorker {
// gone so its raw `transpiler.env` borrow is dead.
drop(unsafe { bun_core::heap::take(env_loader) });
}
// This thread's C++ thread_local destructors are not guaranteed to run
// before the process exits, so free the HPACK scratch buffer that any
// http2 session on this thread allocated.
// C++ TLS destructors are disabled; release these after the VM, while
// the thread's atom table is still alive.
Bun__destroyEventNamesForThreadExit();
Bun__freeSharedHeaderBufferForThreadExit();
drop(arena.take());
log!(
Expand Down
84 changes: 82 additions & 2 deletions test/js/node/worker_threads/worker-shutdown-post-leak.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isASAN, isWindows } from "harness";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness";
import { join } from "path";

// A worker's shutdown used to drain its concurrent queue and only then mark
Expand Down Expand Up @@ -47,3 +47,83 @@ test.skipIf(!isASAN || isWindows)(
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 });
},
);

// LSan sees the TLS table when WTF uses the system allocator.
describe.concurrent("a worker thread frees its event name table when it exits", () => {
const lsanEnv = {
...bunEnv,
BUN_DESTRUCT_VM_ON_EXIT: "1",
Malloc: "1",
ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"),
LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../../leaksan.supp")}`,
};

test.skipIf(!isASAN || isWindows).each([
{
route: "worker_threads Worker whose event loop drains",
files: {
"main.mjs": `
import { Worker } from "node:worker_threads";
new Worker("", { eval: true }).on("exit", code => console.log("exit", code));
`,
},
stdout: "exit 0\n",
},
{
route: "worker_threads Worker that calls process.exit()",
files: {
"main.mjs": `
import { Worker } from "node:worker_threads";
new Worker("setImmediate(() => process.exit(7));", { eval: true }).on("exit", code => console.log("exit", code));
`,
},
stdout: "exit 7\n",
},
{
route: "worker_threads Worker terminated by its parent while it listens on parentPort",
files: {
"main.mjs": `
import { Worker } from "node:worker_threads";
const worker = new Worker(
\`
const { parentPort } = require("node:worker_threads");
setImmediate(() => {
parentPort.on("message", () => {});
parentPort.postMessage("listening");
});
\`,
{ eval: true },
);
worker.on("message", () => worker.terminate());
worker.on("exit", () => console.log("terminated"));
`,
},
stdout: "terminated\n",
},
{
route: "Web Worker whose event loop drains",
files: {
"main.mjs": `
new Worker(new URL("./worker.js", import.meta.url)).addEventListener("close", () => console.log("close"));
`,
"worker.js": "",
},
stdout: "close\n",
},
])(
"$route",
async ({ files, stdout: expectedStdout }) => {
using dir = tempDir("worker-event-names", files);
await using proc = Bun.spawn({
cmd: [bunExe(), "main.mjs"],
cwd: String(dir),
env: lsanEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: expectedStdout, stderr: "", exitCode: 0 });
},
90_000,
);
});
17 changes: 17 additions & 0 deletions test/napi/napi-app/test_worker_finalizers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ static void finalize(napi_env env, void *data, void *hint) {
delete item;
}
static void cleanup(void *data) { finalize(nullptr, data, nullptr); }
static napi_value makeEventName(napi_env env, napi_callback_info) {
const char name[] = "resourcetimingbufferfull";
auto *item = new Item{3, currentThread(), malloc(sizeof(name)), openFd()};
assert(item->bytes && item->fd >= 0);
memcpy(item->bytes, name, sizeof(name));
fds[3] = item->fd;
napi_value value;
bool copied = false;
assert(node_api_create_external_string_latin1(
env, static_cast<char *>(item->bytes), sizeof(name) - 1, finalize,
item, &value, &copied) == napi_ok);
assert(!copied);
return value;
}
static napi_value make(napi_env env, napi_callback_info info) {
napi_value args[1], array;
size_t argc = 1;
Expand Down Expand Up @@ -171,6 +185,9 @@ static napi_value stats(napi_env env, napi_callback_info) {
}
NAPI_MODULE_INIT() {
napi_value fn;
assert(napi_create_function(env, "makeEventName", NAPI_AUTO_LENGTH,
makeEventName, nullptr, &fn) == napi_ok);
assert(napi_set_named_property(env, exports, "makeEventName", fn) == napi_ok);
assert(napi_create_function(env, "make", NAPI_AUTO_LENGTH, make, nullptr,
&fn) == napi_ok);
assert(napi_set_named_property(env, exports, "make", fn) == napi_ok);
Expand Down
13 changes: 13 additions & 0 deletions test/napi/napi-app/worker-event-name-fixture.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions test/napi/napi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,44 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => {

describe("Worker native finalizer lifetime", () => {
const fixture = join(__dirname, "napi-app/worker-finalizers-fixture.mjs");
it.each(["terminate", "worker-exit"])(
"releases addon-owned event name storage after env cleanup at %s",
async mode => {
await using proc = spawn({
cmd: [
bunExe(),
"-e",
`
const assert = require("node:assert/strict");
const addon = require(${JSON.stringify(join(__dirname, "napi-app/build/Debug/test_worker_finalizers.node"))});
const worker = new Worker(${JSON.stringify(join(__dirname, "napi-app/worker-event-name-fixture.mjs"))});
let before;
await new Promise((resolve, reject) => {
worker.addEventListener("error", reject);
worker.addEventListener("message", event => {
before = event.data;
${mode === "terminate" ? "worker.terminate();" : 'worker.postMessage("exit");'}
});
worker.addEventListener("close", resolve);
});
const counts = Array(10).fill(0);
const expected = { counts, nullEnv: Array(10).fill(0), liveFds: 1, offThread: 0, reentered: 0, unexpectedStatus: 0 };
assert.deepEqual(before, expected);
counts[3] = 1;
expected.nullEnv[3] = 1;
expected.liveFds = 0;
assert.deepEqual(addon.stats(), expected);
console.log("released");
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "released\n", stderr: "", exitCode: 0 });
},
);
it("drains GC-queued finalizers exactly once before Worker exit", async () => {
await using proc = spawn({
cmd: [bunExe(), "--expose-gc", fixture, "worker-exit", "gc-before-exit", "3"],
Expand Down
Loading