-
Notifications
You must be signed in to change notification settings - Fork 5k
ai slop #29363
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 #29363
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 |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe } from "harness"; | ||
|
|
||
| // The Zig WebWorker struct is freed on the worker thread once the worker | ||
| // exits. These tests hammer ref()/unref()/terminate() from the parent | ||
| // thread while the worker thread is tearing down, which used to read the | ||
| // freed struct (ASAN use-after-poison in WebWorker__setRef / | ||
| // WebWorker__notifyNeedTermination). | ||
|
|
||
| async function run(src: string) { | ||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "-e", src], | ||
| env: bunEnv, | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
| expect(stdout).toBe(""); | ||
| if (exitCode !== 0) { | ||
| expect(stderr).toBe(""); | ||
| } | ||
| expect(exitCode).toBe(0); | ||
| } | ||
|
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 run() helper in worker-terminate-race.test.ts checks stderr with Extended reasoning...Bug description: The Specific code path: The assertion object is Why existing code doesn't prevent it: Jest/Bun test's Impact: The tests exist specifically to exercise race conditions during worker thread teardown (ref/unref/terminate while the worker exits). If the fix is incomplete or regresses, the process might print non-fatal diagnostic messages, ASAN symbolization notes, Zig panic-style warnings, or other runtime output to stderr before exiting cleanly with code 0. These would be silently swallowed. ASAN crashes are still caught via exit code, but softer issues are not. Step-by-step proof: Suppose a regression causes Fix: Change line 19 from
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 attempted fix (commit 1b946a8) improves failure diagnostics by splitting the combined assertion, but still does not fully address the original concern. The current code only checks The original comment's core concern was: if a regression causes a non-fatal warning to be printed to stderr but the process still exits with code 0, the test would still pass silently. That scenario is unchanged. The fix should unconditionally assert expect(stdout).toBe("");
expect(stderr).toBe("");
expect(exitCode).toBe(0);The empty
Collaborator
Author
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 conditional is intentional. Debug/ASAN builds print |
||
|
|
||
| test.concurrent("Worker: ref/unref after terminate does not use-after-free", async () => { | ||
| await run(` | ||
| const w = new Worker("data:text/javascript,", {}); | ||
| w.terminate(); | ||
| for (let i = 0; i < 100000; i++) { | ||
| w.unref(); | ||
| w.ref(); | ||
| } | ||
| w.terminate(); | ||
| w.unref(); | ||
| `); | ||
| }); | ||
|
|
||
| test.concurrent("Worker: ref/unref racing natural exit does not use-after-free", async () => { | ||
| await run(` | ||
| const w = new Worker("data:text/javascript,", {}); | ||
| const end = Date.now() + 2000; | ||
| while (Date.now() < end) { | ||
| w.unref(); | ||
| w.ref(); | ||
| } | ||
| w.unref(); | ||
| `); | ||
| }); | ||
|
|
||
| test.concurrent("Worker: terminate racing natural exit does not use-after-free", async () => { | ||
| await run(` | ||
| const w = new Worker("data:text/javascript,", {}); | ||
| const end = Date.now() + 2000; | ||
| while (Date.now() < end) { | ||
| w.terminate(); | ||
| } | ||
| `); | ||
| }); | ||
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.
🟣 Pre-existing ref count leak when Worker thread spawn fails. In Worker::create(), worker->ref() is called unconditionally before checking if impl is null; the matching worker->deref() only fires inside WebWorker__dispatchExit, which is never reached on the two early-failure paths (null impl from WebWorker__create, or thread spawn failure via WebWorker__updatePtr). The Worker object is permanently leaked in both cases. This PR correctly adds impl_=nullptr in the updatePtr() failure path (fixing the use-after-free), but the companion ref count imbalance is left unaddressed.
Extended reasoning...
What the bug is and how it manifests
In Worker::create() (Worker.cpp), worker->ref() is called unconditionally at line 222 with the comment "now referenced by Zig". This extra reference is the Zig-side ownership token that is balanced only by worker->deref() inside WebWorker__dispatchExit (Worker.cpp:493). There are two code paths where WebWorker__dispatchExit is never called, leaving the refcount permanently inflated by 1 and the Worker object unreachable for destruction.
The specific code paths that trigger it
Path A — WebWorker__create returns null: worker->ref() is called unconditionally, raising refcount to 2 (adoptRef starts it at 1). If WebWorker__create() returns null (e.g., a preload module URL fails to resolve), the code executes return Exception { TypeError, ... }. The local Ref destructs, decrementing refcount to 1. No code ever calls deref() again — the Worker leaks.
Path B — WebWorker__updatePtr fails (thread spawn fails): In web_worker.zig, when std.Thread.spawn() fails, the Zig side calls worker.deinit() (line 76) and returns false. The deinit() function only frees Zig-side memory; it does NOT call WebWorker__dispatchExit. Without dispatchExit, worker->deref() is never called. The JS wrapper is eventually GC'd (one deref: refcount 2 to 1), but it never reaches 0.
Why existing code does not prevent it
The ref/deref pattern relies entirely on WebWorker__dispatchExit being the single point of cleanup. There is no guard or RAII wrapper around the worker->ref() call in create() that would ensure a matching deref() on early-exit paths. The two failure paths diverge before a Zig thread is ever started, so the normal exit/deinit lifecycle never runs.
Impact
Every new Worker() call that fails at thread-spawn time permanently leaks a Worker C++ object (and all memory it holds: options, URL strings, pending task queue, etc.). In environments where worker creation can transiently fail (resource limits, preload module errors), this accumulates unboundedly.
How to fix it
For Path A: call worker->deref() before returning the Exception, or move worker->ref() to after the null check so it is only called when a thread is actually starting. For Path B: add worker->deref() in the updatePtr() failure branch in Worker::create(), mirroring what WebWorker__dispatchExit would have done.
Step-by-step proof for Path A
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.
Agreed this is a pre-existing leak — leaving it out of this PR to keep the diff focused on the use-after-free.