Conversation
…ipeWriter Source::open_tty(fd=0) returns a process-static uv_tty_t singleton that is shared with every reader and writer on fd 0. WindowsBufferedReader already gates its close path on is_stdin_tty() and leaves the singleton open; the writer side did not, so a FileSink on Bun.file(0) closed it on Drop. The INITIALIZED flag is sticky, so the next open_tty(fd=0) handed out the closed handle without reinit. In debug builds the next writer's close_without_reporting() tripped Fd::from_system's FD_VALUE_MASK assert (uv_fileno returns INVALID_HANDLE_VALUE on a closed handle); in release builds later consumers such as process.stdin.setRawMode() failed with EBADF. Mirror the reader's guard in BaseWindowsPipeWriter::close() and keep the on_tty_close debug_assert in sync.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughChangesWindows stdin TTY lifecycle
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
LGTM — mirrors the existing reader-side guard for the stdin_tty singleton.
What was reviewed:
- Verified the new
close()guard andon_tty_closebody are byte-for-byte the same pattern asPipeReader.rs:1793/1873. - Confirmed the writer's
on_tty_closehas no other call sites, so replacing the runtime check withdebug_assert!is safe. - Checked
source.rs—is_stdin_ttyis a pointer-equality check against the process-static, and theSource::Ttydoc comment already mandates this gate on destroy paths.
Extended reasoning...
Overview
Two files: src/io/PipeWriter.rs adds an is_stdin_tty(p) guard in BaseWindowsPipeWriter::close() before calling uv_close on a Source::Tty, and tightens on_tty_close from a runtime branch to a debug_assert! now that its sole caller enforces the precondition. test/js/bun/util/filesink.test.ts gains a Windows-only regression test that spawns a child under Bun.Terminal (ConPTY stdin), cycles Bun.file(0).writer() create/end/GC, then asserts process.stdin.setRawMode still works.
Security risks
None. Windows-only libuv handle-lifecycle bookkeeping; no user-controlled input, no auth/crypto/network surface.
Level of scrutiny
Low-to-moderate. This is native handle-lifetime code, but the change is a mechanical copy of the guard already present in WindowsBufferedReader::close_impl (PipeReader.rs:1793-1801) and its close callback (PipeReader.rs:1873-1880). The Source::Tty variant docs in source.rs:20-27 explicitly state destroy paths must gate heap::take on !is_stdin_tty(); the writer was the outlier. The on_tty_close free function is referenced only from the one (*p).close(on_tty_close) call now inside the else branch, so the invariant it debug_asserts is guaranteed by construction.
Other factors
The PR description documents fail-before/pass-after on Windows for both debug (FD_VALUE_MASK panic) and release (EBADF from setRawMode). The test wires the terminal exit callback to a resolver so it can't hang if the child dies without emitting RESULT, and uses toContain on accumulated ConPTY output which tolerates escape-sequence noise. No CODEOWNERS cover src/io/. No outstanding review comments.
… the asserted tail
|
Updated 7:17 AM PT - Jul 16th, 2026
❌ @robobun, your commit 0ea6a28 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 34350That installs a local version of the PR into your bun-34350 --bun |
There was a problem hiding this comment.
Beyond the inline nit, I also checked that converting on_tty_close's runtime if !is_stdin_tty guard to a debug_assert! is safe — the only call site now gates before scheduling the close, and the result is byte-identical to the reader's callback at PipeReader.rs:1873. Also confirmed *self.source_mut() = None still runs on the stdin-tty branch, so the source is cleared as before.
Extended reasoning...
The native change is a mechanical mirror of the existing reader-side guard (PipeReader.rs:1793) and matches the contract documented in source.rs ("destroy paths gate heap::take on !is_stdin_tty()"). Deferring rather than approving because this is Windows-only uv handle lifecycle code that the author could not run locally (per the evidence footer), so CI on Windows is the actual verification.
There was a problem hiding this comment.
LGTM — the [END] sentinel in 0ea6a28 closes the ConPTY split-read window I flagged, and the guard now matches the reader side exactly.
Checked that on_tty_close has no other call sites in PipeWriter.rs, so converting its runtime is_stdin_tty check to a debug_assert! is safe — it mirrors PipeReader.rs:1876-1878 byte-for-byte. Also confirmed stdin_tty::get_stdin_tty never resets INITIALIZED on close, which is the mechanism the PR description describes.
Extended reasoning...
Overview
Two-file change: src/io/PipeWriter.rs adds an is_stdin_tty(p) guard in BaseWindowsPipeWriter::close() so the process-static uv_tty_t singleton for fd 0 is never uv_closed, and converts on_tty_close's runtime singleton check to a debug_assert!. test/js/bun/util/filesink.test.ts gains a Windows-only ConPTY test that cycles Bun.file(0).writer() under GC and asserts process.stdin.setRawMode still works afterward.
Security risks
None. Windows-only I/O teardown path; no user-controlled input reaches the changed branch. The guard is a pointer-equality check against a process-static.
Level of scrutiny
Moderate — this is libuv handle lifetime code, so a wrong change could UAF or double-close. But the fix is a line-for-line mirror of the existing reader-side guard (PipeReader.rs:1793 and :1876-1878), which has been in place and is the documented contract in source.rs ("destroy paths gate heap::take on !is_stdin_tty()"). I verified on_tty_close in PipeWriter.rs has exactly one caller (the newly-guarded site), so replacing its runtime branch with debug_assert! cannot reach heap::take on the static in release builds.
Other factors
My previous review flagged a ConPTY chunk-boundary race in the test's readiness gate; 0ea6a28 addressed it by appending an [END] sentinel after the asserted value on both output paths and gating on that — the asserted substring is now strictly before the gate token. The bug-hunting pass on the latest revision found nothing. The PR description documents fail-before/pass-after on both debug and release Windows builds.
|
CI summary for 0ea6a28 (build #73886, final: 284 passed / 2 failed):
Ready for review. |
|
Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-16, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main. |
What
On Windows,
Source::open_tty(fd=0)returns a process-staticuv_tty_tsingleton (src/io/source.rsstdin_tty::get_stdin_tty) that is shared by every reader and writer on fd 0.WindowsBufferedReader::close_implalready guards this case and skipsuv_closewhen the tty is the singleton (src/io/PipeReader.rs:1793).BaseWindowsPipeWriter::closedid not have the same guard and calleduv_closeon it unconditionally.Bun.file(0).writer()on a Windows console takes this path:Blob::get_writer(fd 0 is not stdout/stderr) callsstart(fd, true)which goes throughSource::openand, becauseuv_guess_handle(0) == UV_TTY, storesSource::Tty(<stdin_tty singleton>)on theWindowsStreamingWriter. The FileSink hasowns_fd = false, soend()leaves the source in place, and the writer'sDroplater runsclose_without_reporting()which reachesBaseWindowsPipeWriter::close()anduv_closes the singleton.Once that happens the
INITIALIZEDflag instdin_ttystays set, so every lateropen_tty(fd=0)hands out the closed static without reinitialising it.Repro
Run in a Windows console (so fd 0 is a TTY):
Debug build: the second iteration's
Dropcallsclose_without_reporting(), which callsself.get_fd()on the now-closed singleton.uv_filenoleaves the out-param atINVALID_HANDLE_VALUE, andFd::from_systemtrips:Release build: no assert, the loop completes, and the trailing
setRawMode(true)(which routes toSource__setRawModeStdinand therefore the same singleton) fails withsetRawMode failed with errno: 9(EBADF). Two concurrent writers would also hit libuv'suv_closedouble-closeassert(0)in debug.Fix
Mirror the reader's guard: in
BaseWindowsPipeWriter::close(), skipuv_close(and thedatapointer rewrite) whenis_stdin_tty(p).on_tty_closenowdebug_asserts the invariant instead of branching on it, matching the reader's callback.Test
filesink.test.tsgains a Windows-only test that spawns a child underBun.Terminal(so its stdin is a ConPTY tty), runs the create/end/GC cycle above, then assertsprocess.stdin.setRawModestill succeeds. Verified on Windows x64:FD_VALUE_MASKassertion, noRESULTlinesetRawErr=setRawMode failed with errno: 9RESULT ok setRawErr=noneFull
filesink.test.ts,terminal-spawn.test.tsandtty.test.tspass on Windows with the change; the test is skipped on POSIX.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/util/filesink.test.ts