Skip to content

terminal: write() throws "Terminal is closed" when converting data closes the terminal - #47032

Open
robobun wants to merge 2 commits into
mainfrom
robobun/6d3d7da2/terminal-write-closed-recheck
Open

robobun wants to merge 2 commits into
mainfrom
robobun/6d3d7da2/terminal-write-closed-recheck

Conversation

@robobun

@robobun robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.Terminal#write(data) returns the byte length of data when converting data closes the terminal. It writes nothing. A closed terminal throws Terminal is closed.
  • Terminal::write (src/runtime/api/bun/Terminal.rs:1392) tests CLOSED only before the conversion, which runs toString of a String object. After it, :1418 tests only WRITER_DONE.
  • No user reported this. It needs a String object whose toString closes the terminal. Nothing crashes or leaks, so it is safe to decline.

Fix

  • write() keeps its entry test and reads the flags again after the conversion. CLOSED throws. WRITER_DONE alone still returns the byte length.
  • Verified: test/js/bun/terminal/terminal.test.ts (8 new tests, 5 fail on main), plus terminal-spawn.test.ts and terminal-platform-gaps.test.ts. Linux x64 only.
  • Self-reviewed: 7 concerns raised, 6 addressed. Not done: fixes for the same order in other subsystems.

Background

Downsides

  • A write() whose argument closes the terminal now throws where it returned a number.
  • Each write() runs 2 more instructions (6 for empty data) on 351 to 1329. Allocations and syscalls do not change. The function grows by 38 bytes.
Notes

Reproduction (bun 1.4.3-canary and main at 449877f, Linux x64, deterministic):

const t = new Bun.Terminal({ cols: 80, rows: 24 });
const s = new String("hello");
s.toString = () => {
  t.close();
  return "hello";
};
console.log(t.write(s)); // 5. With this PR: throws "Terminal is closed"
console.log(t.closed); // true
t.write("x"); // throws "Terminal is closed"

The same happens with a String subclass, with Symbol.toPrimitive or valueOf as the hook, and with [Symbol.asyncDispose]() as the closer. A hook that closes and returns "" makes write() return 0.

Outcome matrix. 23 argument kinds, each on an open and on an already closed terminal, on debug and release builds, without and with this change.

  • 4 of 46 rows change. Each is a hook that closes an open terminal: write() returned 5, 0, 4 or 5000 and now throws.
  • All 23 rows for a closed terminal throw Terminal is closed with 0 hook calls on both builds.
  • A hook that closes the terminal and then throws still propagates its own exception.

Outside the matrix: after PTY EOF (closed is false) write("x") returns 1 and write("") returns 0 on both builds.

Measurements. Release builds (ThinLTO) of main at 449877f, without and with this change, Linux x64. perf, valgrind and strace are not installed in this environment, so the counts come from gdb.

  • Accept path, non-empty write to an open terminal: +2 instructions, 1 flags test with mask 0x21 (1 with mask 0x20 on main) (objdump).
  • Instructions per write(): '' 351 to 357, 'x' 579 to 581, Uint8Array(1) 518 to 520, 16 non-ASCII characters 1329 to 1331, String object 1097 to 1099, 'x' after PTY EOF 390 to 392 (gdb stepi, JIT off, call 41 of each kind, 3 runs each, all equal).
  • Release binary: the write function grows by 38 bytes (4112 to 4150). Total text does not change (nm -S, size).
  • mi_malloc calls in 1000 write() calls: 'x' 1 to 1, 16 non-ASCII characters 1001 to 1001, 64 KB Uint8Array 1 to 1. A closing hook: 2057 to 5094, the thrown error. A write to a closed terminal: 3035 on both builds. mi_realloc calls: 7 to 7, 1008 to 1007 and 11 to 11 (gdb breakpoint counts, main thread, inside the call).
  • Syscalls for 1000 buffered writes and their drain: write 2 to 2, epoll_ctl 1002 to 1002. A closing-hook call issues 0 write(2) of its payload on both builds (gdb catch syscall).
  • A separate CLOSED test after the conversion, as in ServerWebSocket::send_message, was also measured: 3 more instructions on every path and 146 bytes.

Sites with the same order that this PR does not change

#46789 rewrites the type of the data argument and keeps this order on purpose. Its schema comment says: "toString of a String object runs once the terminal is known to be open". At its head 68867e8 the lines after the conversion are unchanged, so the symptom is still there. This commit applies to that head without a conflict (git merge-tree --merge-base).

Platforms. Built and tested on Linux x64 only. The edited lines have no cfg, and close() sets CLOSED before any platform code. The PTY EOF test does not run on Windows, where the child's exit leaves the writer open.

Docs and types. No change. bun.d.ts does not describe the throw on a closed terminal.


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

…closes the terminal

`Terminal::write` tested `CLOSED` only before it converted `data`. A
String object is converted with ToString, which runs user code. When
that code closed the terminal, the call returned the byte length and
wrote nothing.

Read the flags once after the conversion. A closed terminal throws. A
terminal whose write side ended at PTY EOF still returns the byte length.
@robobun

robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:19 PM PT - Oct 26th, 2026

✅ @robobun, your commit afecccfd534c7e1e5036fef8639c05e4be04fd43 passed in Build #133407! 🎉


🧪   To try this PR locally:

bunx bun-pr 47032

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

bun-47032 --bun

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the fix is ready for review.

How to reproduce (bun 1.4.3-canary and main, Linux x64, deterministic):

const t = new Bun.Terminal({ cols: 80, rows: 24 });
const s = new String("hello");
s.toString = () => {
  t.close();
  return "hello";
};
console.log(t.write(s)); // 5
console.log(t.closed); // true
t.write("x"); // throws "Terminal is closed"

On main the first write() returns 5 and writes nothing. With this PR it throws Terminal is closed, as the second call does.

@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 4 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between e1bd5ff and afecccf.

Files selected for processing: 1
  • test/js/bun/terminal/terminal.test.ts

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 99ee03e and e1bd5ff.

Files selected for processing: 2
  • src/runtime/api/bun/Terminal.rs
  • test/js/bun/terminal/terminal.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

Terminal.write now checks terminal state after converting its input. Tests cover closure during coercion, writes after PTY EOF, and writes to an open terminal.

Changes

Terminal write state handling

Layer / File(s) Summary
Write conversion and terminal-state handling
src/runtime/api/bun/Terminal.rs, test/js/bun/terminal/terminal.test.ts
Terminal.write throws “Terminal is closed” if conversion closes the terminal. If the writer is done but the terminal remains open, it returns the input length without queuing bytes. Tests cover coercion hooks, already-closed terminals, open-terminal writes, and PTY EOF.

Possibly related PRs

  • oven-sh/bun#45294: Closes graph-owned Bun.Terminal instances through the terminal close state that this change checks after input conversion.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: Normal

Merge Risk: Minimal · previous review e1bd5

Writes reject terminals closed during conversion while preserving EOF returns. No merge-blocking risk was identified.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: terminal.write() now throws when converting data closes the terminal.
Description check Passed The description explains the problem, fix, scope, testing, measurements, platform coverage, and limitations. It does not use the template headings exactly, but it provides the required change summary …
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.
Full details: Description check

Explanation

The description explains the problem, fix, scope, testing, measurements, platform coverage, and limitations. It does not use the template headings exactly, but it provides the required change summary and verification details.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked that bytes borrowed from StringOrBuffer stays valid after a closing hook (close_internal tears down the writer and fds but never detaches or frees the JS argument, which is still rooted on the call stack), and that the CLOSED check wins when close() sets both CLOSED and WRITER_DONE in the same hook. The sibling coerce-after-liveness-check in set_termios_flag (Terminal.rs:1321-1324) is pre-existing, already a documented no-op on a closed terminal, and explicitly left out of this PR.

Extended reasoning...

The change re-reads the Terminal flags after the user-reachable ToString coercion in Terminal::write (src/runtime/api/bun/Terminal.rs) so a hook that closes the terminal makes write() throw "Terminal is closed" instead of returning a byte count, and moves the empty-input early return below that check; it adds a test.each matrix and four tests in test/js/bun/terminal/terminal.test.ts. It touches no security-sensitive surface. The native change is ten lines and behavior-preserving on every path except the closing-hook one, but it is a user-facing semantic change in native runtime code and one test-hygiene finding was posted inline, so a human look is still worthwhile.

Comment thread test/js/bun/terminal/terminal.test.ts Resolved
The closing-hook tests closed the terminal only inside the hook. If
write() threw before it ran the hook, the terminal stayed open until
the garbage collector ran. `await using` now closes it in every case.

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

LGTM — the follow-up commit registers await using before the assertions in the new tests, and the re-check in write() is correct.

What was reviewed:

  • Terminal::write re-reads flags after StringOrBuffer::from_js and throws on CLOSED; close_internal sets CLOSED before anything else, so a toString hook that calls close()/[Symbol.asyncDispose]() is caught on every platform.
  • Moving the input_len == 0 short-circuit below the flag check preserves the old outcomes for open (Ok(0)) and PTY-EOF (Ok(input_len) == 0) terminals; only the closed case changes, to a throw.
  • Checked the sibling termios setter for the same close-after-check order: close_internal resets master_fd to INVALID so the post-coercion re-read falls into the existing no-op path, not a stale fd.
Extended reasoning...

The change is a 12-line re-entrancy fix in src/runtime/api/bun/Terminal.rs plus a self-contained test block in test/js/bun/terminal/terminal.test.ts; it touches no auth, crypto, or input-parsing surface, and the Rust edit holds no raw pointers across the user-code boundary. The only behavior change is that write() now throws "Terminal is closed" when converting the argument closes the terminal, matching the existing contract for a closed terminal. The one inline concern from the prior run (PTY leaked when assertions fail before the hook closes it) was addressed by commit afecccf, which adds await using before the assertions, and no third-party objections are outstanding. No CODEOWNERS entry covers the changed files.

This branch has not been deployed

No deployments
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.

1 participant