-
Notifications
You must be signed in to change notification settings - Fork 5k
ai slop #29322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ai slop #29322
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -402,16 +402,24 @@ | |
|
|
||
| /// Deinit will clean up vm and everything. | ||
| /// Early deinit may be called from caller thread, but full vm deinit will only be called within worker's thread. | ||
| /// The struct itself is freed separately by the owning C++ `Worker` via `WebWorker__destroy` so | ||
| /// that `WebWorker__notifyNeedTermination` never touches freed memory while JS still holds the | ||
| /// wrapper. | ||
| fn deinit(this: *WebWorker) void { | ||
| log("[{d}] deinit", .{this.execution_context_id}); | ||
| this.parent_poll_ref.unrefConcurrently(this.parent); | ||
| bun.default_allocator.free(this.unresolved_specifier); | ||
| this.unresolved_specifier = ""; | ||
| for (this.preloads) |preload| { | ||
| bun.default_allocator.free(preload); | ||
| } | ||
| bun.default_allocator.free(this.preloads); | ||
| this.preloads = &.{}; | ||
| } | ||
|
|
||
| export fn WebWorker__destroy(this: *WebWorker) void { | ||
| bun.default_allocator.destroy(this); | ||
| } | ||
|
Check failure on line 422 in src/bun.js/web_worker.zig
|
||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+418
to
422
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The thread-spawn failure path in Extended reasoning...What the bug is and how it manifests In The specific code path that triggers it
Why existing code doesn't prevent it
What the impact is Any OOM condition or OS thread-limit hit during How to fix it Option A (minimal, restores old behavior for this path): in the Option B (cleaner): in Step-by-step proof
|
||
|
|
||
| fn flushLogs(this: *WebWorker) void { | ||
| jsc.markBinding(@src()); | ||
|
|
@@ -644,7 +652,13 @@ | |
| } | ||
| var arena = this.arena; | ||
|
|
||
| // Release owned resources before dispatching exit. `WebWorker__dispatchExit` drops the | ||
| // Zig-held ref on the C++ `Worker`; once the parent processes the close task and GC | ||
| // collects the wrapper, `~Worker()` will free this struct, so it must not be touched | ||
| // afterwards. | ||
| this.deinit(); | ||
|
|
||
| WebWorker__dispatchExit(globalObject, cpp_worker, exit_code); | ||
|
Check failure on line 661 in src/bun.js/web_worker.zig
|
||
|
Comment on lines
+657
to
661
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 After this PR's reordering, Extended reasoning...What the bug is and how it manifests When a worker's event loop goes idle without an explicit The specific code path that triggers it When the close task runs on the parent thread and JS fires the Why existing code doesn't prevent it
Impact
How to fix it In pub fn setRef(this: *WebWorker, value: bool) callconv(.c) void {
if (this.hasRequestedTerminate()) return;
if (this.status.load(.acquire) == .terminated) return; // <-- add this
this.setRefInternal(value);
}
Step-by-step proof
|
||
| if (loop) |loop_| { | ||
| loop_.internal_loop_data.jsc_vm = null; | ||
| } | ||
|
|
@@ -659,8 +673,6 @@ | |
| bun.windows.libuv.Loop.shutdown(); | ||
| } | ||
|
|
||
| this.deinit(); | ||
|
|
||
| if (vm_to_deinit) |vm| { | ||
| vm.deinit(); // NOTE: deinit here isn't implemented, so freeing workers will leak the vm. | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe, isDebug } from "harness"; | ||
|
|
||
| // The Zig `WebWorker` struct used to be destroyed on the worker thread in | ||
| // `exitAndDeinit`, while `Worker::terminate()` on the main thread could still | ||
| // call `WebWorker__notifyNeedTermination(impl_)` afterwards, reading freed | ||
| // memory. Calling `terminate()` immediately sets `requested_terminate`, so the | ||
| // worker thread takes the fast early-exit path (before its VM is created) and | ||
| // the struct was freed right after the close event was posted. Calling | ||
| // `terminate()` again from the close handler then touched the freed struct. | ||
| test( | ||
| "Worker.terminate() after the worker thread has exited does not use freed memory", | ||
| async () => { | ||
| const code = ` | ||
| for (let i = 0; i < 10; i++) { | ||
| const w = new Worker("nonexistent-entrypoint-58146"); | ||
| const { promise, resolve } = Promise.withResolvers(); | ||
| w.addEventListener("close", resolve); | ||
| w.addEventListener("error", () => {}); | ||
| w.terminate(); | ||
| await promise; | ||
| w.terminate(); | ||
| } | ||
| Bun.gc(true); | ||
| `; | ||
| const concurrency = 5; | ||
| for (let batch = 0; batch < 4; batch++) { | ||
| const runs = Array.from({ length: concurrency }, async () => { | ||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "-e", code], | ||
| env: bunEnv, | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
| const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); | ||
| return { stderr, exitCode }; | ||
| }); | ||
| for (const { stderr, exitCode } of await Promise.all(runs)) { | ||
| if (exitCode !== 0) { | ||
| expect(stderr).toBe(""); | ||
| } | ||
|
Comment on lines
+39
to
+41
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The assertion at lines 39-41 is semantically inverted: when the process exits with a non-zero code (e.g., ASAN abort with exit code 134), stderr holds the crash trace, so Extended reasoning...What the bug is and how it manifests In the new test file if (exitCode \!== 0) {
expect(stderr).toBe("");
}
expect(exitCode).toBe(0);When the worker process crashes (e.g., due to ASAN detecting a use-after-poison with exit code 134), stderr contains the full ASAN trace. Because The specific code path that triggers it A developer running the test against an unpatched ASAN build sees the test runner report a failure about unexpected stderr content rather than a clear 'process crashed with exit code 134' message. The intent appears to have been to surface stderr as diagnostic context when the process fails — but it is expressed as an assertion rather than a message parameter. Why existing code doesn't prevent it The test framework evaluates assertions eagerly and stops at the first failure. Since the stderr assertion is placed before the exit-code assertion inside the conditional block, it wins the race to produce the failure output whenever exitCode is non-zero. Impact No false passes are produced — every crash scenario is caught. However, a developer debugging a failure sees 'stderr should be empty' and may spend time investigating spurious output rather than immediately identifying the root cause as a use-after-free crash. How to fix it Replace the inverted assertion block with: expect(exitCode, `stderr: ${stderr}`).toBe(0);This makes the exit code the subject of the assertion (so the failure message clearly states 'Expected 0, received 134') and attaches the ASAN trace as context, which is exactly what a developer needs when diagnosing a crash. Step-by-step proof
|
||
| expect(exitCode).toBe(0); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| }, | ||
| isDebug ? 60_000 : undefined, | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 The
namefield is heap-allocated increate()when a non-empty name is provided, butdeinit()never frees it, leaking the string for every named worker. This is a pre-existing issue, but the PR explicitly restructuresdeinit()as the canonical resource-release function (adding idempotency guards forunresolved_specifierandpreloads), making the omission ofnamea conspicuous gap worth fixing at this point.Extended reasoning...
What the bug is: In
WebWorker.create(), thenamefield is conditionally heap-allocated usingstd.fmt.allocPrintSentinel(bun.default_allocator, ...)whenname_stris non-empty, producing a[:0]const u8slice owned by the default allocator. However,deinit()— now the canonical resource-release function per this PR — never callsbun.default_allocator.free(this.name). Similarly,WebWorker__destroyonly callsbun.default_allocator.destroy(this), which frees the struct allocation itself but leaves the bytes pointed to by thenamefield unreleased.The specific code path: In
create()(web_worker.zig ~line 246):The non-empty branch produces a heap allocation. In
deinit()(lines 408–418), the function freesunresolved_specifierand eachpreloadsentry (with idempotency zero-guards), but has no correspondingbun.default_allocator.free(this.name).Why existing code does not prevent it:
WebWorker__destroyis the sole remaining deallocation site (called from~Worker()) and only issues a struct-leveldestroy, not a field-levelfreeof the name slice. There is no other caller ofbun.default_allocator.freeonthis.nameanywhere in web_worker.zig.Impact: Every
new Worker(url, { name: "something" })call leaks the name string allocation for the lifetime of the process. In long-running servers or test suites that spawn many named workers, this accumulates unboundedly.How to fix it: Add a free in
deinit()guarded to avoid freeing the static""literal used for unnamed workers. Since only non-emptyname_strproduces a heap allocation, checkingthis.name.len > 0is sufficient:A more robust alternative is a dedicated
name_is_owned: boolfield.Step-by-step proof:
new Worker("./worker.js", { name: "my-worker" })callsWorker::create→WebWorker__create→WebWorker.create().name_stris non-empty, sostd.fmt.allocPrintSentinel(bun.default_allocator, ...)allocates N+1 bytes on the heap.this.namenow points to that allocation.exitAndDeinit()→this.deinit().deinit()freesunresolved_specifierandpreloads, but returns without touchingthis.name.~Worker()callsWebWorker__destroy(impl_)→bun.default_allocator.destroy(this). This frees theWebWorkerstruct bytes but the name string bytes remain allocated and unreachable — a confirmed leak.