Skip to content

terminal: take the reader's ref before the reader starts - #42654

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/59301123/terminal-reader-start-uaf
Sep 14, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/59301123/terminal-reader-start-uaf

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

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 spawn/shell/Terminal: remove unsafe from subprocess.rs, shell/subproc.rs and Terminal.rs #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 (io: leave the fd with the caller when a POSIX pipe writer fails to start #38354). The reader's ADD fails alone only with exactly one epoll watch left (fs.epoll.max_user_watches), or on a transient ENOMEM.
Notes
  • 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 spawn/shell/Terminal: remove unsafe from subprocess.rs, shell/subproc.rs and Terminal.rs #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 spawn/shell/Terminal: remove unsafe from subprocess.rs, shell/subproc.rs and Terminal.rs #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.

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

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

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How I reproduced it, on main 09bb546 with the debug ASAN build:

  1. Compile the LD_PRELOAD shim from test/js/bun/spawn/spawn-pipe-start-error.test.ts (cc -shared -fPIC -o shim.so shim.c -ldl).
  2. Run FAIL_EPOLL_CTL=pty-reader-add LD_PRELOAD=./shim.so bun-debug -e 'new Bun.Terminal({})'. The shim fails only the EPOLL_CTL_ADD that registers the pty master for reading.
  3. Result on main: AddressSanitizer: heap-use-after-free in RefCount<Terminal>::ref_ from Terminal::init_terminal, freed by Terminal::on_reader_finished. The release canary 1.4.3-canary.1+b99371011 aborts with panic: usockets_loop: uws_loop not initialized.
  4. Result with this branch: the constructor throws Failed to start terminal reader, and no fd or wrapper is left behind.

bun bd test test/js/bun/spawn/spawn-pipe-start-error.test.ts: the 4 new cases fail on main and pass on this branch. The 3 existing cases pass on both.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: f70aebb8-b465-472f-98a6-dabeb66601d6

📥 Commits

Reviewing files that changed from the base of the PR and between 09bb546 and d284743.

📒 Files selected for processing (2)
  • src/runtime/api/bun/Terminal.rs
  • test/js/bun/spawn/spawn-pipe-start-error.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

Terminal reader startup

Layer / File(s) Summary
Reader startup teardown
src/runtime/api/bun/Terminal.rs
init_terminal retains the reader reference before startup, detects early completion, and uses fail_reader_start to close resources and release the initial reference.
PTY startup failure tests
test/js/bun/spawn/spawn-pipe-start-error.test.ts
The epoll shim injects pty reader registration failures. Tests cover direct and spawned terminal startup for add and re-arm failures without leaked descriptors or wrappers.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to d2847

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)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary fix: acquiring the terminal reader reference before reader startup.
Description check ✅ Passed The description explains the problem, fix, verification, affected tests, and platform-specific limitations. It does not use the template headings exactly, but it provides the required information in e…

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_start call sites: Err arm (3 refs → read_fd close + reader deref + close_internal's writer deref + initial deref), Ok+READER_DONE arm (finish_io_after_eof already released reader+writer, close_internal's on_writer_close no-ops on WRITER_DONE, initial deref frees), and post-IOReader::read arm (same, with READER_STARTED set) — each reaches zero with no use of terminal after the final deref.
  • Confirmed fail_reader_start's deref_() is tail-position per the file's existing convention (Terminal.rs:397-398), and no new provenance pattern is introduced beyond what deref_(&self) already does.
  • Test shim: should_fail with FAIL_EPOLL_CTL unset preserves the original EPOLLOUT-ADD behavior, so the three existing writer tests are unaffected; the hoisted runFixture is only reachable from describe.skipIf(!isLinux || !cc) so dir is always defined when used.
  • Fixture baseline: JSON.stringify drops code: undefined, so the new { message: "Failed to start terminal reader" } assertion matches exactly under toEqual.
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.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:47 PM PT - Sep 13th, 2026

✅ @robobun, your commit d2847434c34e1072594ad702529d9e52dee4914d passed in Build #115282! 🎉


🧪   To try this PR locally:

bunx bun-pr 42654

That installs a local version of the PR into your bun-42654 executable, so you can run:

bun-42654 --bun

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

On the Windows Err arm: I ran it on a Windows x64 debug build of this branch (d284743).

WindowsBufferedReader::start_with_current_pipe has a debug-only injection point, BUN_INTERNAL_FAIL_PIPE_READER_START=1, that makes reader.start() return Err with no callback. With it set:

  • new Bun.Terminal({ data() {}, exit() {} }) throws Failed to start terminal reader.
  • The process exits 0 with nothing on stderr. The debug RefCount assertions do not fire.
  • heapStats().objectTypeCounts.Terminal is back at its baseline after Bun.gc(true), so no wrapper is left.

Without the injection the same script constructs the terminal, closes it, gets the exit callback, and also leaves no wrapper.

The state after finish_io_after_eof is what the two pty-reader-add cases run on Linux: reader closed, writer ended, both refs released, close_internal closes the master and slave fds, and the initial ref is the last one.

@Jarred-Sumner
Jarred-Sumner merged commit 44c528d into main Sep 14, 2026
11 of 12 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/59301123/terminal-reader-start-uaf branch September 14, 2026 06:02
Jarred-Sumner pushed a commit that referenced this pull request Sep 15, 2026
…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 -->
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
### 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 -->
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…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 -->
springmin pushed a commit to springmin/bun that referenced this pull request Sep 15, 2026
…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, ...)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants