terminal: take the reader's ref before the reader starts - #42654
Conversation
When the poll registration fails, the POSIX PosixBufferedReader::start() calls on_reader_error and still returns Ok. Terminal's on_reader_error ends by releasing the reader's ref, but init_terminal took that ref only after start() returned. The release dropped the last ref, and the constructor then used the freed Terminal and handed it to JS. init_terminal now takes the reader's ref before start(). A reader that is already done after start(), or after the constructor's first read, makes the constructor throw "Failed to start terminal reader" and release the pty, the same as a writer that fails to start.
|
Status: ready for review. How I reproduced it, on main 09bb546 with the debug ASAN build:
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review. WalkthroughChangesTerminal reader startup
Suggested reviewers: Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to The terminal startup failure paths now clean up safely and are covered by targeted tests, with no identified merge-blocking risk. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reorders refcount acquisition against a synchronous-teardown callback and adds two new free-on-error paths in Terminal::init_terminal, a human familiar with the Terminal refcount lifecycle should confirm the balance — particularly the Windows Err arm and the state finish_io_after_eof leaves behind.
What was reviewed:
- Traced refcounts through all three
fail_reader_startcall sites:Errarm (3 refs → read_fd close + reader deref + close_internal's writer deref + initial deref),Ok+READER_DONEarm (finish_io_after_eof already released reader+writer, close_internal's on_writer_close no-ops onWRITER_DONE, initial deref frees), and post-IOReader::readarm (same, withREADER_STARTEDset) — each reaches zero with no use ofterminalafter the final deref. - Confirmed
fail_reader_start'sderef_()is tail-position per the file's existing convention (Terminal.rs:397-398), and no new provenance pattern is introduced beyond whatderef_(&self)already does. - Test shim:
should_failwithFAIL_EPOLL_CTLunset preserves the original EPOLLOUT-ADD behavior, so the three existing writer tests are unaffected; the hoistedrunFixtureis only reachable fromdescribe.skipIf(!isLinux || !cc)sodiris always defined when used. - Fixture baseline:
JSON.stringifydropscode: undefined, so the new{ message: "Failed to start terminal reader" }assertion matches exactly undertoEqual.
Extended reasoning...
Overview
The PR fixes a use-after-free in Bun.Terminal initialization: on POSIX, PosixBufferedReader::start() reports a failed epoll registration by synchronously invoking on_reader_error (which releases the reader's ref via on_reader_finished → deref_()) while still returning Ok. The old code took the reader's ref only after start() returned, so the callback's deref dropped the count to zero and freed the Terminal mid-constructor. The fix moves terminal.ref_() before start(), adds a READER_DONE check after start() and again after the constructor's first IOReader::read, and centralizes the error-path teardown in fail_reader_start() (close_internal + drop initial ref). The test file extends the existing LD_PRELOAD syscall() shim with FAIL_EPOLL_CTL=pty-reader-add|pty-reader-mod modes gated on ioctl(fd, TIOCGPTN) to target only the pty master's readable registration, and adds four cases (2 entry points × 2 injection modes) asserting the throw, zero leaked fds, and zero leaked wrappers.
Security risks
None user-facing. This is an internal refcount-ordering fix on an error path reachable only under epoll ENOSPC/ENOMEM. No new input parsing, no auth/crypto/permissions surface.
Level of scrutiny
High. This is intrusive-refcount native code where the fix itself introduces two new paths that free self before init_terminal returns. I traced each arm's refcount from the initial heap::into_raw (1) through writer start (2), reader ref (3), and back down to zero, cross-referencing on_reader_finished, finish_io_after_eof, on_writer_close, and close_internal to confirm each deref is paired and WRITER_DONE gates the double-deref in on_writer_close. The Err arm (Windows-only per the reader contract) now does deref_() + fail_reader_start(), which is three total derefs matching three refs — correct only because no callback ran, which the PR's comment states and the reader contract supports. The &self-then-free pattern in fail_reader_start mirrors the file's documented deref_() convention and introduces nothing new provenance-wise. That said, refcount fixes in this file are exactly what REVIEW.md calls the most-blocked category, and the Windows arm is untested by the Linux-only shim.
Other factors
Test quality is strong: fault injection via LD_PRELOAD targets the exact syscall, TIOCGPTN scoping avoids perturbing unrelated fds, the fixture measures both fd and wrapper leaks against a baseline that anchors the relevant prototypes, and the PR states all four cases fail on main (2 ASAN UAF, 2 leaked wrappers). The hoist of beforeAll/runFixture to module scope is a mechanical refactor guarded by the same !isLinux || !cc predicate. The PR description's audit of other start() callers and the note on FileResponseStream show the whole-class check was done. No CODEOWNERS conflict apparent. Deferring because a human should independently confirm the three-arm refcount balance and the Windows Err path assumption.
|
Updated 1:47 PM PT - Sep 13th, 2026
✅ @robobun, your commit d2847434c34e1072594ad702529d9e52dee4914d passed in 🧪 To try this PR locally: bunx bun-pr 42654That installs a local version of the PR into your bun-42654 --bun |
|
On the Windows
Without the injection the same script constructs the terminal, closes it, gets the The state after |
…write() (#42662) ### Problem - On Windows, since v1.4.0, `terminal.write()` after the child of `Bun.spawn({ terminal })` exited leaks the `Terminal`. The write throws `EPIPE`, the terminal closes, but the wrapper, its callbacks and the native object stay rooted. 1.3.14 collects 4 of 4 terminals, 1.4.0 and main collect 0. - The writer fails inside `write()` and calls `Terminal::on_writer_error`, which closes the terminal and sets `WRITER_DONE`. The writer keeps the unsent bytes. - In `Terminal::write` (`src/runtime/api/bun/Terminal.rs:1478`), `has_pending_data()` is still true, so `write()` upgrades `this_value` again (#36962). The writer is done, so nothing downgrades it. ### Fix - `write()` checks `WRITER_DONE` after the writer returns. If set, the kept bytes do not count as pending: no root, no `drain` dispatch. - A done writer sends no more callbacks, so nothing can release a root taken for a pending drain. #42150 added the same check before the write. - With no `drain` dispatch, `exit` stays the last callback when an earlier write had bytes queued. - Verified: `test/js/bun/terminal/terminal.test.ts` (new test: fails on the Windows canary, passes with the fix). Two fault-injected Linux cases in `test/js/bun/spawn/spawn-pipe-start-error.test.ts` fail on main. Also `test/js/bun/terminal/` on both. ### Background - `Terminal` holds its JS wrapper in a `JsRef` (`this_value`), strong while a callback can fire: the wrapper's cached slots are the callbacks' only GC root. `maybe_downgrade_after_eof` makes it weak once `exit` has run and no `drain` is owed. - The streaming writer queues bytes it cannot send at once. `has_pending_data()` reports them, also after the writer closed. - `writer_has_buffered` records that a `drain` is owed. `on_writer_close`, the writer's last callback, sets `WRITER_DONE`. <details><summary>Notes</summary> **Windows reproduction, no fault injection.** Four inline terminals, each child is `cmd.exe /c exit 0`. After `proc.exited` and the `exit` callback, call `terminal.write("x")`, drop every reference, then run `Bun.gc(true)` until a `FinalizationRegistry` has seen all four. | build | collected | runs | | --- | --- | --- | | 1.3.14 | 4 of 4 | 3 of 3 | | 1.4.0 | 0 of 4 | 3 of 3 | | 1.4.3-canary.1+3f7f046cd (main) | 0 of 4 | 5 of 5 | | this branch, debug build | 4 of 4 | 3 of 3 | Every post-exit write threw `EPIPE` (20 of 20). Without the write, every build collects 4 of 4. #36962 (first released in v1.4.0) added the upgrade in `write()`. **Windows chain.** The child's exit does not end the writer on Windows (`finish_io_after_eof` is POSIX only), so `write()` reaches `WindowsStreamingWriter::process_send` (`src/io/PipeWriter.rs:2351`). `uv_write` fails at once, because conhost closed the input pipe. `process_send` already swapped the bytes into `current_payload` and does not clear it on this arm, so `has_pending_data()` stays true. `close()` reports `on_close` synchronously (`PipeWriter.rs:1152`), so `on_writer_close` sets `WRITER_DONE` inside the call. `EXIT_DISPATCHED` is already set at that point, so `maybe_downgrade_after_eof` never runs again. **POSIX chain.** `PosixStreamingWriter::write` queues the bytes and calls `register_poll` (`PipeWriter.rs:787`). Its error arm calls `on_error`, then `close()`. Unlike `_on_error` (`PipeWriter.rs:741`) it does not set `is_done` or reset `outgoing`, so `has_pending_data()` stays true. On Linux only fault injection reaches this arm. The re-arm is an `EPOLL_CTL_MOD`, and the kernel's `ep_modify` does not fail when watches or memory run out. What the kernel can return there is `EBADF` or `ENOENT`, after other code closed the writer's private fd by number. I found the POSIX path by a read of the code after #42150. It costs one `Terminal` object graph. It holds no fds and does not spin. **Why the check is in `Terminal::write`.** A change in the writer (reset `outgoing` in the `register_poll` error arm) does not remove the need for it. With `has_pending_data()` false, `write()` takes the `had_buffered && !has_pending` branch and dispatches `drain` after `exit`. The second Linux case covers that: an earlier write is still queued when the re-arm fails. The Windows path goes through a different writer. `this_value.upgrade()` has one call site, and `WRITER_DONE` is the condition that `maybe_downgrade_after_eof` already uses. **Not in this PR.** - `FileSink` is the other parent of the streaming writer, and `FileSink::to_result` has a similar shape. It takes a keep-alive ref for a `Pending` result only. On Windows a `uv_write` that fails at once makes the writer return `Err`, so the trigger above takes no ref there. On POSIX a write that gets `EAGAIN` and then fails to re-arm its poll returns `Pending` after `on_close` already ran, and nothing releases that ref. Only the failed registration reaches that case, so on Linux it needs fault injection (see the POSIX chain). I excluded it on purpose: I did not change or measure it here. - A caller that still holds a leaked terminal can release it. `Symbol.asyncDispose` downgrades `this_value` unconditionally. - #42654 (merged) fixed the reader's failed registration in `init_terminal` and added the `FAIL_EPOLL_CTL` shim modes. This branch is rebased on it. The test file change here only adds to it: the `pty-writer-mod` mode, a skip count, two fixture kinds and one `describe`. **Tests.** - The Linux cases assert `closed`, `drains`, `leakedFds` and `leakedWrappers` only. They do not pin the return value of the failing `write()` or the `exit` code. - Linux, debug ASAN build, after the rebase on #42654: `spawn-pipe-start-error.test.ts` 9 pass. With `src/` at main the two new cases fail with `leakedWrappers: 1` and every other field equal. 1.4.3-canary.1 fails them the same way. `test/js/bun/terminal/` 134 pass, 2 skip. - Windows Server 2019, debug build: `test/js/bun/terminal/` 114 pass, 10 skip, 12 todo, 0 fail. I measured this on the first shape of the patch (the same check, wrapped around the block). The current shape folds the check into `has_pending` and is the same logic. - The new `terminal.test.ts` test passes on Linux with and without the fix. On POSIX the check that #42150 added drops the write. Self-reviewed: 6 concerns raised, 6 addressed (Linux trigger stated as fault injection only, Windows trigger confirmed and tested, writer-level cause and `FileSink` sibling named, shim aligned with #42654, fewer pinned fields in the assertions, wording on collection). One part not done: no follow-up issue for `FileSink`, because I did not measure that leak myself. </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/terminal/terminal.test.ts, test/js/bun/spawn/spawn-pipe-start-error.test.ts <!-- robobun:evidence:end -->
### Problem
- `new Bun.Terminal()` and `Bun.spawn({ terminal: {...} })` use a freed
`Terminal` when the pty reader's `epoll_ctl(EPOLL_CTL_ADD)` fails. ASAN:
`heap-use-after-free` in `RefCount<Terminal>::ref_` from
`Terminal::init_terminal` (`Terminal.rs:492`), freed by
`Terminal::on_reader_finished` (`Terminal.rs:1829`). JS receives the
dangling pointer.
- `PosixBufferedReader::start()` (`src/io/PipeReader.rs:547`) reports a
failed poll registration by calling `on_reader_error`, and still returns
`Ok`. `Terminal::on_reader_error` releases the reader's ref.
`init_terminal` took that ref only after `start()` returned, so the
release dropped the last ref.
### Fix
- `init_terminal` takes the reader's ref before `start()`, as
`SubprocessPipeReader::start` does for the same contract.
- If the reader is done after `start()`, `init_terminal` releases the
pty and the constructor throws `Failed to start terminal reader`, as the
writer arm does.
- The same check runs after the constructor's first `read()`. On Linux
only fault injection reaches it (see Notes).
- Verified: `test/js/bun/spawn/spawn-pipe-start-error.test.ts` (4 new
cases, main fails all 4: 2 ASAN reports, 2 leaked wrappers). Also
`test/js/bun/terminal/`. Self-reviewed: 3 concerns raised, 3 addressed
(all on the overlap with oven-sh#40204, see Notes).
### Background
- `Terminal` is intrusively refcounted. The JS wrapper, the pty writer
and the pty reader hold one ref each. The reader and the writer release
theirs in their close or error callback.
- `PosixBufferedReader` (`src/io`) is the poll-driven pipe reader.
`start()` registers the fd with epoll or kqueue.
- Reach: the writer's ADD comes first and already throws (oven-sh#38354). The
reader's ADD fails alone only with exactly one epoll watch left
(`fs.epoll.max_user_watches`), or on a transient `ENOMEM`.
<details><summary>Notes</summary>
- Reported via a fuzz ledger (entry 50336): one injected `epoll_ctl`
failure under ASAN on main 09bb546, reproduced with `strace -f -qq -o
/dev/null -e trace=epoll_ctl -e inject=epoll_ctl:error=ENOMEM:when=4 bun
t.mjs`. Repro without strace: compile the shim from the test file, then
run `FAIL_EPOLL_CTL=pty-reader-add LD_PRELOAD=./shim.so bun -e 'new
Bun.Terminal({})'`.
- Overlap with oven-sh#40204 (open, conflicting with main): that refactor also
moves the reader's ref before `reader.start()` and lists it under
"pre-existing bugs fixed in passing". It has only the reorder: no
`READER_DONE` check, no throw, no test. With the reorder alone the
constructor returns a `Terminal` that is already dead (no `exit`
callback, wrapper never collected) where main frees it, and the 4 new
cases still fail. This PR adds the checks, the teardown and the tests.
When oven-sh#40204 rebases over this, `init_terminal` must keep both
`READER_DONE` checks and the `ReaderStartFailed` teardown.
- The post-read check is a guard for the same contract, not a second
observed failure. Without it, a reader that ends during the
constructor's first `read()` leaves that same dead `Terminal`. The
re-arm after the first read is an `EPOLL_CTL_MOD`. The kernel's
`ep_modify` does not return `ENOSPC` or `ENOMEM`, and a fresh pty master
with its slave open reads `EAGAIN`, not EOF or an error. The
`pty-reader-mod` shim mode injects a failure there to exercise the
guard.
- The release canary `1.4.3-canary.1+b99371011` also misbehaves under
the ADD injection: it aborts with `panic: usockets_loop: uws_loop not
initialized (call ensure_waker first)`, which is a read of the freed
`Terminal`'s event loop handle.
- State after `on_reader_error` runs inside `start()` on POSIX, with the
fix: `finish_io_after_eof` has closed the reader and ended the writer,
so both of their refs are released and only the initial ref is left.
`fail_reader_start` closes the master and slave fds through
`close_internal` and drops the initial ref. The `Err` arm of `start()`
(reachable on Windows only) returns the reader's ref itself, because no
callback ran.
- Windows: the `Err` arm of `start()` is not reachable through the Linux
shim. I ran it by hand on a Windows x64 debug build with the debug-only
`BUN_INTERNAL_FAIL_PIPE_READER_START=1` injection: the constructor
throws `Failed to start terminal reader`, the debug `RefCount`
assertions stay quiet, and no wrapper is left after GC.
- I did not change `PosixBufferedReader::start()` to return `Err` for a
failed registration. Every other POSIX caller depends on the current
contract, and `SubprocessPipeReader::start` documents why it does not
want `Err` there.
- Other callers of `start()`: `filter_run`, `multi_run`, `cron`, shell
`IOReader`, `git_runner`, `lifecycle_script_runner`, `security_scanner`
and `FileReader` take their ref or counter before `start()`, or are not
refcounted. `FileResponseStream::start`
(`src/runtime/server/FileResponseStream.rs:276`) is the exception in
shape: it takes its read ref after `start()` returns `Ok` and does not
check for a finished stream. Its release is gated on a flag, so it
cannot free early. I served a FIFO through `Bun.serve` with the same
injection and saw no leaked fd: the second failed registration releases
the stream. That site is not part of this PR.
- The test extends the `LD_PRELOAD` shim in that file. The shim
interposes `syscall()` because Bun registers file polls through
`syscall(SYS_epoll_ctl, ...)`. The new modes fail only a readable
registration of a pty master (`TIOCGPTN` succeeds on that fd only).
- The test file change moves the shim build and `runFixture` from the
first `describe` to module scope so that the new `describe` can share
them. The three existing tests are unchanged.
</details>
<!-- robobun:evidence:begin -->
---
**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/bun/spawn/spawn-pipe-start-error.test.ts
<!-- robobun:evidence:end -->
…write() (oven-sh#42662) ### Problem - On Windows, since v1.4.0, `terminal.write()` after the child of `Bun.spawn({ terminal })` exited leaks the `Terminal`. The write throws `EPIPE`, the terminal closes, but the wrapper, its callbacks and the native object stay rooted. 1.3.14 collects 4 of 4 terminals, 1.4.0 and main collect 0. - The writer fails inside `write()` and calls `Terminal::on_writer_error`, which closes the terminal and sets `WRITER_DONE`. The writer keeps the unsent bytes. - In `Terminal::write` (`src/runtime/api/bun/Terminal.rs:1478`), `has_pending_data()` is still true, so `write()` upgrades `this_value` again (oven-sh#36962). The writer is done, so nothing downgrades it. ### Fix - `write()` checks `WRITER_DONE` after the writer returns. If set, the kept bytes do not count as pending: no root, no `drain` dispatch. - A done writer sends no more callbacks, so nothing can release a root taken for a pending drain. oven-sh#42150 added the same check before the write. - With no `drain` dispatch, `exit` stays the last callback when an earlier write had bytes queued. - Verified: `test/js/bun/terminal/terminal.test.ts` (new test: fails on the Windows canary, passes with the fix). Two fault-injected Linux cases in `test/js/bun/spawn/spawn-pipe-start-error.test.ts` fail on main. Also `test/js/bun/terminal/` on both. ### Background - `Terminal` holds its JS wrapper in a `JsRef` (`this_value`), strong while a callback can fire: the wrapper's cached slots are the callbacks' only GC root. `maybe_downgrade_after_eof` makes it weak once `exit` has run and no `drain` is owed. - The streaming writer queues bytes it cannot send at once. `has_pending_data()` reports them, also after the writer closed. - `writer_has_buffered` records that a `drain` is owed. `on_writer_close`, the writer's last callback, sets `WRITER_DONE`. <details><summary>Notes</summary> **Windows reproduction, no fault injection.** Four inline terminals, each child is `cmd.exe /c exit 0`. After `proc.exited` and the `exit` callback, call `terminal.write("x")`, drop every reference, then run `Bun.gc(true)` until a `FinalizationRegistry` has seen all four. | build | collected | runs | | --- | --- | --- | | 1.3.14 | 4 of 4 | 3 of 3 | | 1.4.0 | 0 of 4 | 3 of 3 | | 1.4.3-canary.1+3f7f046cd (main) | 0 of 4 | 5 of 5 | | this branch, debug build | 4 of 4 | 3 of 3 | Every post-exit write threw `EPIPE` (20 of 20). Without the write, every build collects 4 of 4. oven-sh#36962 (first released in v1.4.0) added the upgrade in `write()`. **Windows chain.** The child's exit does not end the writer on Windows (`finish_io_after_eof` is POSIX only), so `write()` reaches `WindowsStreamingWriter::process_send` (`src/io/PipeWriter.rs:2351`). `uv_write` fails at once, because conhost closed the input pipe. `process_send` already swapped the bytes into `current_payload` and does not clear it on this arm, so `has_pending_data()` stays true. `close()` reports `on_close` synchronously (`PipeWriter.rs:1152`), so `on_writer_close` sets `WRITER_DONE` inside the call. `EXIT_DISPATCHED` is already set at that point, so `maybe_downgrade_after_eof` never runs again. **POSIX chain.** `PosixStreamingWriter::write` queues the bytes and calls `register_poll` (`PipeWriter.rs:787`). Its error arm calls `on_error`, then `close()`. Unlike `_on_error` (`PipeWriter.rs:741`) it does not set `is_done` or reset `outgoing`, so `has_pending_data()` stays true. On Linux only fault injection reaches this arm. The re-arm is an `EPOLL_CTL_MOD`, and the kernel's `ep_modify` does not fail when watches or memory run out. What the kernel can return there is `EBADF` or `ENOENT`, after other code closed the writer's private fd by number. I found the POSIX path by a read of the code after oven-sh#42150. It costs one `Terminal` object graph. It holds no fds and does not spin. **Why the check is in `Terminal::write`.** A change in the writer (reset `outgoing` in the `register_poll` error arm) does not remove the need for it. With `has_pending_data()` false, `write()` takes the `had_buffered && !has_pending` branch and dispatches `drain` after `exit`. The second Linux case covers that: an earlier write is still queued when the re-arm fails. The Windows path goes through a different writer. `this_value.upgrade()` has one call site, and `WRITER_DONE` is the condition that `maybe_downgrade_after_eof` already uses. **Not in this PR.** - `FileSink` is the other parent of the streaming writer, and `FileSink::to_result` has a similar shape. It takes a keep-alive ref for a `Pending` result only. On Windows a `uv_write` that fails at once makes the writer return `Err`, so the trigger above takes no ref there. On POSIX a write that gets `EAGAIN` and then fails to re-arm its poll returns `Pending` after `on_close` already ran, and nothing releases that ref. Only the failed registration reaches that case, so on Linux it needs fault injection (see the POSIX chain). I excluded it on purpose: I did not change or measure it here. - A caller that still holds a leaked terminal can release it. `Symbol.asyncDispose` downgrades `this_value` unconditionally. - oven-sh#42654 (merged) fixed the reader's failed registration in `init_terminal` and added the `FAIL_EPOLL_CTL` shim modes. This branch is rebased on it. The test file change here only adds to it: the `pty-writer-mod` mode, a skip count, two fixture kinds and one `describe`. **Tests.** - The Linux cases assert `closed`, `drains`, `leakedFds` and `leakedWrappers` only. They do not pin the return value of the failing `write()` or the `exit` code. - Linux, debug ASAN build, after the rebase on oven-sh#42654: `spawn-pipe-start-error.test.ts` 9 pass. With `src/` at main the two new cases fail with `leakedWrappers: 1` and every other field equal. 1.4.3-canary.1 fails them the same way. `test/js/bun/terminal/` 134 pass, 2 skip. - Windows Server 2019, debug build: `test/js/bun/terminal/` 114 pass, 10 skip, 12 todo, 0 fail. I measured this on the first shape of the patch (the same check, wrapped around the block). The current shape folds the check into `has_pending` and is the same logic. - The new `terminal.test.ts` test passes on Linux with and without the fix. On POSIX the check that oven-sh#42150 added drops the write. Self-reviewed: 6 concerns raised, 6 addressed (Linux trigger stated as fault injection only, Windows trigger confirmed and tested, writer-level cause and `FileSink` sibling named, shim aligned with oven-sh#42654, fewer pinned fields in the assertions, wording on collection). One part not done: no follow-up issue for `FileSink`, because I did not measure that leak myself. </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/terminal/terminal.test.ts, test/js/bun/spawn/spawn-pipe-start-error.test.ts <!-- robobun:evidence:end -->
…ch64 Upstream highlights: - js_printer: infallible writer ops, output-position fixes, minify fixes - mimalloc to the head of bun-dev3-v2; statx/faccessat2/prlimit64 handled like Node (oven-sh#42688) - Terminal: reader ref taken before start, writer-done inside write() (oven-sh#42654, oven-sh#42662) - build: post-link verify-binary checks, LTO default on for every release build, llvm-tools component, WebKit 9b02218df6 - fetch/http2/h3, streams, bundler, react-compiler, node compat fixes Conflicts (6), resolved keeping both sides: - scripts/build/flags.ts: keep `c.ohos` gnu++23, take upstream comment/desc - scripts/build/tools.ts: keep OHOS llvm-nm 15 guard, add upstream llvm-readobj/objdump/cxxfilt lookups - src/runtime/api/bun/Terminal.rs: keep the OHOS deferred-exit replay (read after callbacks); upstream's READER_DONE throw is applied on non-OHOS only, because on OHOS the pty reader's poll registration can fail in normal operation and the exit callback must still fire (T03b) - test/cli/test/isolation.test.ts: keep the isOhos skip on the socket test, add upstream's transpiler-cache namespace tests - heapStats-mimalloc/vm tests: import unions (isOhos + tempDir) OHOS adaptations: - every OHOS build entry point pins --lto=off: upstream now defaults ThinLTO on for all release builds, which flips WebKit's CMAKE_BUILD_TYPE (RelWithDebInfo -> Release) and invalidates the existing WebKit build dir. OHOS has never been validated with LTO; keeps the previous codegen. - scripts/build/source.ts: OHOS dependency objects get -fPIC like Android. Upstream extended the "-fno-pic -fno-pie on unix" default to every unix target; on OHOS (a PIE-only loader) that linked non-PIC dep objects into the PIE binary, producing R_AARCH64_COPY relocations for libc data that OHOS musl does not populate -> the freshly built binary aborted at startup (found by this merge's smoke test). - docs/ohos-adaptation-checklist.md: record the LTO default change, the PIC policy, and the removed node-gyp -Wl,--code-sign. Impact assessment (complete, not sampled): - all 347 files the merge touches were checked for lost `ohos` references; only the flags.ts desc reword and two intentional import merges differ - all 34 files both sides changed were checked line-by-line: every line the OHOS side added since 3f7f046 is still present - every marker in docs/ohos-adaptation-checklist.md verified present in the merged tree (spawn drain gates, memfd gates, syscall fallbacks, IS_NODE_ARG, lifecycle PATH injection, webkit OHOS cmake block, ...)
Problem
new Bun.Terminal()andBun.spawn({ terminal: {...} })use a freedTerminalwhen the pty reader'sepoll_ctl(EPOLL_CTL_ADD)fails. ASAN:heap-use-after-freeinRefCount<Terminal>::ref_fromTerminal::init_terminal(Terminal.rs:492), freed byTerminal::on_reader_finished(Terminal.rs:1829). JS receives the dangling pointer.PosixBufferedReader::start()(src/io/PipeReader.rs:547) reports a failed poll registration by callingon_reader_error, and still returnsOk.Terminal::on_reader_errorreleases the reader's ref.init_terminaltook that ref only afterstart()returned, so the release dropped the last ref.Fix
init_terminaltakes the reader's ref beforestart(), asSubprocessPipeReader::startdoes for the same contract.start(),init_terminalreleases the pty and the constructor throwsFailed to start terminal reader, as the writer arm does.read(). On Linux only fault injection reaches it (see Notes).test/js/bun/spawn/spawn-pipe-start-error.test.ts(4 new cases, main fails all 4: 2 ASAN reports, 2 leaked wrappers). Alsotest/js/bun/terminal/. Self-reviewed: 3 concerns raised, 3 addressed (all on the overlap with spawn/shell/Terminal: remove unsafe from subprocess.rs, shell/subproc.rs and Terminal.rs #40204, see Notes).Background
Terminalis intrusively refcounted. The JS wrapper, the pty writer and the pty reader hold one ref each. The reader and the writer release theirs in their close or error callback.PosixBufferedReader(src/io) is the poll-driven pipe reader.start()registers the fd with epoll or kqueue.fs.epoll.max_user_watches), or on a transientENOMEM.Notes
epoll_ctlfailure under ASAN on main 09bb546, reproduced withstrace -f -qq -o /dev/null -e trace=epoll_ctl -e inject=epoll_ctl:error=ENOMEM:when=4 bun t.mjs. Repro without strace: compile the shim from the test file, then runFAIL_EPOLL_CTL=pty-reader-add LD_PRELOAD=./shim.so bun -e 'new Bun.Terminal({})'.reader.start()and lists it under "pre-existing bugs fixed in passing". It has only the reorder: noREADER_DONEcheck, no throw, no test. With the reorder alone the constructor returns aTerminalthat is already dead (noexitcallback, wrapper never collected) where main frees it, and the 4 new cases still fail. This PR adds the checks, the teardown and the tests. When spawn/shell/Terminal: remove unsafe from subprocess.rs, shell/subproc.rs and Terminal.rs #40204 rebases over this,init_terminalmust keep bothREADER_DONEchecks and theReaderStartFailedteardown.read()leaves that same deadTerminal. The re-arm after the first read is anEPOLL_CTL_MOD. The kernel'sep_modifydoes not returnENOSPCorENOMEM, and a fresh pty master with its slave open readsEAGAIN, not EOF or an error. Thepty-reader-modshim mode injects a failure there to exercise the guard.1.4.3-canary.1+b99371011also misbehaves under the ADD injection: it aborts withpanic: usockets_loop: uws_loop not initialized (call ensure_waker first), which is a read of the freedTerminal's event loop handle.on_reader_errorruns insidestart()on POSIX, with the fix:finish_io_after_eofhas closed the reader and ended the writer, so both of their refs are released and only the initial ref is left.fail_reader_startcloses the master and slave fds throughclose_internaland drops the initial ref. TheErrarm ofstart()(reachable on Windows only) returns the reader's ref itself, because no callback ran.Errarm ofstart()is not reachable through the Linux shim. I ran it by hand on a Windows x64 debug build with the debug-onlyBUN_INTERNAL_FAIL_PIPE_READER_START=1injection: the constructor throwsFailed to start terminal reader, the debugRefCountassertions stay quiet, and no wrapper is left after GC.PosixBufferedReader::start()to returnErrfor a failed registration. Every other POSIX caller depends on the current contract, andSubprocessPipeReader::startdocuments why it does not wantErrthere.start():filter_run,multi_run,cron, shellIOReader,git_runner,lifecycle_script_runner,security_scannerandFileReadertake their ref or counter beforestart(), or are not refcounted.FileResponseStream::start(src/runtime/server/FileResponseStream.rs:276) is the exception in shape: it takes its read ref afterstart()returnsOkand does not check for a finished stream. Its release is gated on a flag, so it cannot free early. I served a FIFO throughBun.servewith the same injection and saw no leaked fd: the second failed registration releases the stream. That site is not part of this PR.LD_PRELOADshim in that file. The shim interposessyscall()because Bun registers file polls throughsyscall(SYS_epoll_ctl, ...). The new modes fail only a readable registration of a pty master (TIOCGPTNsucceeds on that fd only).runFixturefrom the firstdescribeto module scope so that the newdescribecan share them. The three existing tests are unchanged.no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/spawn/spawn-pipe-start-error.test.ts