-
Notifications
You must be signed in to change notification settings - Fork 4.9k
fs.watch: fix PathWatcher double-free race between close() and DirectoryRegisterTask #29936
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
Closed
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
7bcb021
fs.watch: fix PathWatcher double-free race between close() and Direct…
robobun cd1d311
[autofix.ci] apply automated fixes
autofix-ci[bot] 224a696
address review: remove now-dead setClosed/hasPendingDirectories, filt…
robobun 8725d03
Merge remote-tracking branch 'origin/main' into farm/e59010f8/fix-pat…
robobun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe, isMacOS, isWindows, tempDir } from "harness"; | ||
|
|
||
| // Regression test for a PathWatcher double-free when `fs.watch(dir).close()` | ||
| // races the work-pool directory scan. | ||
| // | ||
| // On Linux/FreeBSD, watching a directory schedules a `DirectoryRegisterTask` | ||
| // on the work pool (refPendingDirectory → pending_directories = 1, | ||
| // has_pending_directories = true). When the watcher is closed: | ||
| // | ||
| // main: PathWatcher.deinit() | ||
| // setClosed() // lock; closed = true; unlock | ||
| // if (hasPendingDirectories()) // ← lock-free atomic load | ||
| // return; | ||
| // ...destroy(this) | ||
| // | ||
| // worker: unrefPendingDirectory() // lock; pending = 0; | ||
| // if (closed && pending == 0) // sees closed == true | ||
| // has_pending = false // (store) | ||
| // should_deinit = true // unlock | ||
| // → deinit() → ...destroy(this) | ||
| // | ||
| // If the worker's critical section lands between main's setClosed() unlock | ||
| // and main's hasPendingDirectories() load, main observes has_pending == false | ||
| // and *also* proceeds to destroy — two `bun.default_allocator.destroy()` on | ||
| // the same PathWatcher. In release builds this corrupts mimalloc's | ||
| // cross-thread free list; on alpine aarch64 CI it surfaced as a segfault at | ||
| // address 0x75622F706D742F (ASCII `/tmp/bu`) inside `PathWatcher.init()`'s | ||
| // next allocation. Under ASAN it reports use-after-poison on `this`. | ||
| // | ||
| // The fix merges `closed = true` and the `has_pending_directories` check | ||
| // into a single critical section so the worker cannot interleave. | ||
| // | ||
| // Windows uses win_watcher.zig and macOS directories use FSEvents; neither | ||
| // schedules a DirectoryRegisterTask, so the race does not exist there. | ||
| test.skipIf(isWindows || isMacOS)( | ||
| "close() racing DirectoryRegisterTask completion does not double-free PathWatcher", | ||
| async () => { | ||
| // One file is the sweet spot: processWatcher() has just enough work that | ||
| // close() on the main thread lands while the worker is finishing, so the | ||
| // worker observes closed == true in unrefPendingDirectory(). An empty | ||
| // directory finishes too fast (worker always wins → no race); more files | ||
| // make the worker finish after main has already returned early. | ||
| using dir = tempDir("fswatch-close-race", { "f.txt": "x" }); | ||
|
|
||
| const fixture = /* js */ ` | ||
| const fs = require("fs"); | ||
| const dir = process.argv[1]; | ||
| const ITERS = 3000; | ||
| for (let i = 0; i < ITERS; i++) { | ||
| const w = fs.watch(dir, { persistent: false }, () => {}); | ||
| w.close(); | ||
| } | ||
| console.log("ok " + ITERS); | ||
| `; | ||
|
|
||
| // The race is timing-dependent (~90% hit rate per run under ASAN on the | ||
| // unpatched build); run a handful of attempts so an unpatched build fails | ||
| // with overwhelming probability while a patched build stays fast. | ||
| const ATTEMPTS = 4; | ||
| const results: Array<{ attempt: number; stdout: string; stderr: string; exitCode: number }> = []; | ||
| for (let attempt = 0; attempt < ATTEMPTS; attempt++) { | ||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "-e", fixture, String(dir)], | ||
| env: bunEnv, | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
| // ASAN builds emit a benign "WARNING: ASAN interferes with JSC signal | ||
| // handlers..." line on startup; strip it so it doesn't fail the test. | ||
| const filteredStderr = stderr | ||
| .split("\n") | ||
| .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) | ||
| .join("\n"); | ||
| results.push({ attempt, stdout: stdout.trim(), stderr: filteredStderr, exitCode }); | ||
| } | ||
|
|
||
| // Every attempt must have completed the full loop cleanly. Comparing the | ||
| // whole array at once surfaces every failing attempt's stderr/exitCode | ||
| // in a single diff instead of stopping at the first. | ||
| expect(results).toEqual( | ||
| Array.from({ length: ATTEMPTS }, (_, attempt) => ({ attempt, stdout: "ok 3000", stderr: "", exitCode: 0 })), | ||
| ); | ||
| }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 60000, | ||
| ); | ||
|
robobun marked this conversation as resolved.
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.