Skip to content

shell: fold write_no_io ENOSPC into builtin exit codes - #34699

Closed
robobun wants to merge 2 commits into
mainfrom
farm/ae78f663/ignored-fallible-returns
Closed

shell: fold write_no_io ENOSPC into builtin exit codes#34699
robobun wants to merge 2 commits into
mainfrom
farm/ae78f663/ignored-fallible-returns

Conversation

@robobun

@robobun robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

From the static pattern audit of ignored fallible returns.

Repro

import { $ } from "bun";
$.nothrow();
const r = await $`echo hello > ${new Uint8Array(0)}`.quiet();
console.log(r.exitCode);  // 0, output silently dropped

Same for seq, pwd, basename, dirname, export, which, and cat < ${blob} > ${buf}. Only yes handled it.

Cause

BuiltinIO::write_no_io_to returns Err(ENOSPC) when an ArrayBuffer sink can't accept more bytes, but every single-write builtin did let _ = write_no_io(...); done(0). The fd path (on_io_writer_chunk) already turns write errors into a nonzero exit, so this only bit the no-io path (ArrayBuffer/Blob/captured-pipe sinks).

Separately, the ArrayBuf arm only returned Err when the cursor was already at the end; a single write that didn't fit returned Ok(partial), so a too-small-but-nonzero buffer truncated silently with no way for the caller to notice.

Fix

  • write_no_io_to ArrayBuf arm: write whatever fits, then return Err(ENOSPC) if the write was truncated.
  • New Builtin::write_no_io_exit helper that folds the result into an ExitCode.
  • echo, basename, dirname, export, seq, pwd, cat (stdin-sync path), which (sync-loop path): use the helper instead of discarding.

Stderr writes on error paths still discard (exit is already nonzero). The verbose-output sites in touch -v/mkdir -v/ls/rm -v/cp -v are left alone; threading a write error through their multi-task state machines is a larger change and the primary output is the filesystem side effect, not stdout.

Also in this PR (same audit, both cheap)

  • packages/bun-usockets/src/loop.c: zero-initialize the timespec read by us_internal_monotonic_ns so a failed clock_gettime cannot feed stack garbage into the sweep-timer deadline. CLOCK_MONOTONIC can't realistically fail on supported platforms; this is defense-in-depth at zero cost.
  • src/jsc/bindings/vm/SigintWatcher.cpp: null-guard m_thread in uninstall(). WTF::Thread::create returns Ref<> (RELEASE_ASSERT on failure) so the assignment itself is never null, but install() sets m_installed = true before assigning m_thread, and m_thread is a RefPtr.

The "fallible us_create_loop" item from the same audit is already covered by #33850, which does the full graceful-recovery version; not duplicated here.

Verification

test/js/bun/shell/bunshell.test.ts "redirect to ArrayBuffer that is too small": for each of echo/seq/basename/dirname/pwd, redirects to zero-length, 2-byte, and 1KB buffers and asserts exit 1/1/0 respectively, plus that the 2-byte buffer holds the first two bytes of output.

Fails on main with exitCode: 0 for the zero-length and 2-byte cases; passes with this change. Existing yes > ${buffer} and ArrayBuffer leak tests continue to pass.

This is orthogonal to #32278, which fixes the exit code on the fd path's on_io_writer_chunk callback (negated-errno wrapping); this PR is the no-io path.


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/shell/bunshell.test.ts

When a shell builtin redirects stdout to an ArrayBuffer (`> ${buf}`)
that is too small to hold the output, `write_no_io` returns
Err(ENOSPC) but every builtin except `yes` discarded the error and
exited 0, hiding the data loss.

`write_no_io_to` now returns ENOSPC whenever the write is truncated
(not only when the buffer was already full on entry), and a new
`write_no_io_exit` helper folds that into the exit code. Applied to
echo, basename, dirname, export, seq, pwd, cat (stdin-sync path), and
which (sync-loop path).

Also in this change:
- usockets: zero-initialize the monotonic timespec read by
  `us_internal_monotonic_ns` so a failed clock_gettime cannot feed
  stack garbage into the sweep-timer deadline.
- SigintWatcher: null-guard `m_thread` in `uninstall()`; the field
  is a RefPtr and `install()` sets `m_installed` before assigning
  it.
@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:20 AM PT - Jul 19th, 2026

@autofix-ci[bot], your commit 9f083a1 is building: #75747

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Gate check (local):

=== without fix (src/ stashed) ===
(fail) redirect to ArrayBuffer that is too small > echo
(fail) redirect to ArrayBuffer that is too small > seq
(fail) redirect to ArrayBuffer that is too small > basename
(fail) redirect to ArrayBuffer that is too small > dirname
(fail) redirect to ArrayBuffer that is too small > pwd
 0 pass, 5 fail

=== with fix ===
 5 pass, 0 fail

Full bunshell.test.ts: 417 pass, 0 fail. yes.test.ts / echo.test.ts / seq.test.ts / basename.test.ts / dirname.test.ts / which.test.ts: 78 pass, 0 fail.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5491063c-b8bd-4654-a00f-f099a4b6a455

📥 Commits

Reviewing files that changed from the base of the PR and between a9ee41a and 9f083a1.

📒 Files selected for processing (1)
  • src/runtime/shell/builtin/which.rs

Walkthrough

The change updates shell ArrayBuffer writes to support partial output and propagate ENOSPC into builtin exit codes, adds redirect coverage, and hardens SIGINT watcher shutdown and monotonic timestamp initialization.

Changes

Shell output status propagation

Layer / File(s) Summary
ArrayBuffer write contract
src/runtime/shell/Builtin.rs
ArrayBuffer writes now write available bytes, report ENOSPC only for incomplete writes, and provide write_no_io_exit for exit-code conversion.
Builtin completion exit propagation
src/runtime/shell/builtin/*.rs
Builtin no-I/O paths propagate write-derived exit codes; which also reports stdout write failures.
ArrayBuffer redirect coverage
test/js/bun/shell/bunshell.test.ts
Tests cover zero-length, undersized, and sufficiently sized ArrayBufferView redirects across multiple builtins.

SIGINT watcher lifecycle

Layer / File(s) Summary
Watcher uninstall guards
src/jsc/bindings/vm/SigintWatcher.cpp
Uninstall now permits a missing thread handle and conditionally waits for thread completion.

Monotonic timestamp initialization

Layer / File(s) Summary
Monotonic timestamp initialization
packages/bun-usockets/src/loop.c
The monotonic clock timestamp structure is zero-initialized before use.

Possibly related PRs

  • oven-sh/bun#34698: Related ArrayBuffer write_no_io overflow handling and builtin exit-status propagation changes.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: folding ENOSPC from no-IO writes into builtin exit codes.
Description check ✅ Passed The description covers what changed and how it was verified, though it uses custom headings instead of the template's exact section titles.
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.

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

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/shell/builtin/cat.rs`:
- Around line 150-151: Update Cat::on_io_reader_chunk to capture errors from
write_no_io, including ENOSPC, instead of ignoring them; record the failure,
stop or cancel further reads, and ensure the reader-completion path returns a
nonzero exit status while preserving successful streaming behavior.

In `@test/js/bun/shell/bunshell.test.ts`:
- Around line 479-505: Expand the cases matrix and related assertions around the
concurrent shell tests to include cat with both file and file-descriptor input,
export, and which. Add buffer-view cases covering exact-fit capacity and nonzero
byte offsets, ensuring the tests verify the streaming cat success-on-ENOSPC
behavior and ArrayBufferView bounds beyond zero-offset Uint8Array views.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 76ae3d70-a11c-4ae1-b6be-c5c8d15b1868

📥 Commits

Reviewing files that changed from the base of the PR and between 98f6649 and a9ee41a.

📒 Files selected for processing (12)
  • packages/bun-usockets/src/loop.c
  • src/jsc/bindings/vm/SigintWatcher.cpp
  • src/runtime/shell/Builtin.rs
  • src/runtime/shell/builtin/basename.rs
  • src/runtime/shell/builtin/cat.rs
  • src/runtime/shell/builtin/dirname.rs
  • src/runtime/shell/builtin/echo.rs
  • src/runtime/shell/builtin/export.rs
  • src/runtime/shell/builtin/pwd.rs
  • src/runtime/shell/builtin/seq.rs
  • src/runtime/shell/builtin/which.rs
  • test/js/bun/shell/bunshell.test.ts

Comment thread src/runtime/shell/builtin/cat.rs
Comment thread test/js/bun/shell/bunshell.test.ts
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. shell: fail the command when a > ${buf} redirect overflows the target Buffer #34698 - Both PRs modify BuiltinIO::write_no_io_to in Builtin.rs to return Err(ENOSPC) on partial ArrayBuffer writes and add a write_no_io_exit() helper to propagate exit code 1 from shell builtins on truncated output

🤖 Generated with Claude Code

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #34698, which takes a cleaner approach (latch the write error on Builtin, fold it in done()) that covers every builtin including the streaming cat/ls/mkdir -v paths this PR scoped out, plus the subprocess > ${buf} path. Closing this one.

The two unrelated defense-in-depth changes here (timespec zero-init, SigintWatcher null-guard) are posted on #34698 as a diff.

The us_create_loop fd check from the same audit is #33850.

@robobun robobun closed this Jul 19, 2026
Comment on lines +150 to +151
let code = Builtin::write_no_io_exit(interp, cmd, IoKind::Stdout, &buf, 0);
return Builtin::done(interp, cmd, code);

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.

🔴 The stdin-sync path here is fixed, but on_io_reader_chunk (the streaming path — hit by cat file.txt > ${arraybuf}, or cat > ${arraybuf} with a real-fd stdin) still ends with let _ = Builtin::write_no_io(...); Yield::done(). When the ArrayBuffer fills, ENOSPC is dropped, on_io_reader_done fires with err=None, and cat exits 0 with truncated output — the same bug this PR fixes elsewhere. cat isn't covered by the stated exclusion rationale (its primary output is stdout) and this path is reachable on Windows without a feature flag; it should either be fixed (set *remove = true, stash a nonzero errno on the state — ExecStdin already has an errno field — and exit nonzero from on_io_reader_done) or explicitly listed as deferred.

Extended reasoning...

What the bug is

This PR folds write_no_io ENOSPC into builtin exit codes and, in cat.rs, fixes the stdin-sync path (line ~150: cat < ${blob} > ${buf}). But the streaming path in the same file — Cat::on_io_reader_chunk — still discards the error:

let _ = Builtin::write_no_io(interp, cmd, IoKind::Stdout, chunk);
Yield::done()

This is the async twin of the path the PR did fix.

Code path that triggers it

on_io_reader_chunk is invoked whenever cat reads from an IOReader (real fd) and stdout has needs_io() == None — i.e. stdout is BuiltinIO::ArrayBuf/Blob/Buf/Ignore. Two user-reachable shapes:

  • cat file.txt > ${arraybuf}ExecFilepathArgs state: next() opens the file, creates an IOReader, and each read chunk lands in on_io_reader_chunk.
  • cat > ${arraybuf} with a real-fd stdin (e.g. inside a pipeline: somecmd | cat > ${buf}) — ExecStdin with stdin.needs_io() == true.

In both, stdout.needs_io() is None (ArrayBuffer sink), so the function falls through to the write_no_io call and returns Yield::done().

Why nothing catches it

With this PR's own tightening of write_no_io_to (any truncated write to an ArrayBuffer now returns Err(ENOSPC)), the error is produced but discarded by let _ =. Then on_io_reader_done fires with err = None (the read succeeded), so:

  • ExecStdin: errno stays 0, !stdout_needs_ioStep::Done(0).
  • ExecFilepathArgs: errno = 0, !stdout_needs_ioStep::Nextnext() sees idx >= n_filesBuiltin::done(interp, cmd, 0).

Exit code 0, output silently truncated.

Step-by-step proof

On Windows (cat builtin always enabled — Kind::DISABLED_ON_POSIX is only consulted when !cfg!(windows)), or on POSIX with BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1:

import { $ } from "bun";
await Bun.write("big.txt", "a".repeat(100));
const buf = new Uint8Array(2);
const r = await $`cat big.txt > ${buf}`.nothrow().quiet();
console.log(r.exitCode, new TextDecoder().decode(buf));
// → 0 "aa"   (should be 1 "aa", like echo/seq/pwd now do)
  1. Builtin::init sets stdout = BuiltinIO::ArrayBuf { buf, i: 0 } (jsbuf redirect).
  2. Cat::startExecFilepathArgs { args_start: 0, idx: 0, ... }next().
  3. next() opens big.txt, creates an IOReader, calls reader.start().
  4. Reader delivers a 100-byte chunk to on_io_reader_chunk. stdout_needs_io is None, so it falls through to write_no_io, which writes 2 bytes into the ArrayBuffer and returns Err(ENOSPC). The let _ = drops it. Returns Yield::done().
  5. Reader hits EOF → on_io_reader_done(err = None). errno = 0, in_done = true, !stdout_needs_ioStep::Next.
  6. next(): idx (1) >= n_files (1)Builtin::done(interp, cmd, 0).

Why this belongs in scope

Per REVIEW.md: "Fix the whole class in the same PR … sync/async twins … If a site is intentionally excluded, say so in the PR." The PR description lists cat under fixed builtins as "cat (stdin-sync path)", and its exclusion list (touch -v/mkdir -v/ls/rm -v/cp -v) is justified by "the primary output is the filesystem side effect, not stdout". That rationale doesn't apply to cat — its primary output is stdout. So the streaming path is neither fixed nor covered by the stated exclusion.

How to fix

The plumbing already exists. In on_io_reader_chunk, when write_no_io returns Err:

  • Set *remove = true so the IOReader stops delivering chunks.
  • Stash a nonzero errno on the state — ExecStdin already has an errno: ExitCode field; ExecFilepathArgs would need one (or set in_done/out_done and reuse a shared field).
  • Have on_io_reader_done (which will fire after the reader is removed) exit with that errno instead of 0 / advancing to the next file.

Alternatively, if threading it through the multi-chunk state machine is out of scope for this PR, add cat's streaming path to the explicit exclusion list in the PR description alongside the -v builtins.

Comment on lines +479 to +485
const cases: Array<[string, Run, string | undefined]> = [
["echo", b => $`echo hello > ${b}`, "hello\n"],
["seq", b => $`seq 1 3 > ${b}`, "1\n2\n3\n"],
["basename", b => $`basename /a/b/cde > ${b}`, "cde\n"],
["dirname", b => $`dirname /a/b/cde > ${b}`, "/a/b\n"],
["pwd", b => $`pwd > ${b}`, undefined],
];

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.

🟡 The test.each table covers echo/seq/basename/dirname/pwd but not the other three sites this PR modifies: export (print-all path), which (sync loop), and cat (stdin-sync). which in particular uses bespoke had_write_err flag logic rather than the shared write_no_io_exit helper, so reverting that hunk would break no test. export and which are cheap to add as extra rows (export needs a big-buffer sized from the actual env; which needs a resolvable command like bunExe() so had_write_err is isolated from had_not_found); cat is in DISABLED_ON_POSIX so is reasonable to defer with a comment.

Extended reasoning...

What's missing

The PR folds ENOSPC into the exit code at 8 call sites — echo, seq, basename, dirname, pwd, export (print-all), which (sync loop), and cat (stdin-sync) — but the new describe("redirect to ArrayBuffer that is too small") block at test/js/bun/shell/bunshell.test.ts:479-485 only exercises the first 5. The changes at export.rs:73, which.rs:52-78, and cat.rs:150 have no assertion of the new nonzero exit.

REVIEW.md is direct on this: "Cover the variant matrix, not just the repro. Every sibling entry point receiving the same fix" and "Confirm deleting each load-bearing clause of your fix breaks at least one test."

Why which is the important one

Seven of the eight sites route through the new shared Builtin::write_no_io_exit helper, so the 5 tested builtins give reasonable transitive confidence for those. But which does not use the helper — it has a bespoke had_write_err boolean threaded through a per-arg loop and OR'd into the final exit code:

if Builtin::write_no_io(interp, cmd, IoKind::Stdout, &buf).is_err() {
    had_write_err = true;
}
...
return Builtin::done(interp, cmd, if had_not_found || had_write_err { 1 } else { 0 });

Reverting this hunk (dropping had_write_err and going back to let _ = ...) would not break a single test in the PR.

Step-by-step: adding the missing rows

which — needs a command that resolves so the exit code is driven by had_write_err, not had_not_found. bunExe() is guaranteed on PATH in the harness. The resolved path is dynamic, so treat it like pwd:

["which", b => $`which ${bunExe()} > ${b}`, undefined],

With a zero-length or 2-byte buffer, the resolved path (>2 bytes) truncates → had_write_err = true → exit 1. With a 1KB buffer it fits → exit 0. That isolates the new flag.

export — the print-all path is the same shape as pwd (dynamic output, single write_no_io_exit call), so:

["export", b => $`FOO=bar export > ${b}`, undefined],

One caveat: the process env is unbounded in CI, so the fixed 1 << 10 "big" buffer may itself overflow. Either size big per-case (e.g. run once into a large buffer to measure, then allocate that + slack), or for the dynamic-output rows only assert the zero/2-byte cases and skip the big check.

cat — the stdin-sync path at cat.rs:150 is only reachable when cat runs as a builtin. Kind::DISABLED_ON_POSIX includes Cat, so on Linux/macOS cat falls through to a subprocess and cat.rs never runs. Adding it to the same table would need BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 in a subprocess or a Windows-only gate. That's enough friction that deferring it with a comment is reasonable — but export and which have no such excuse.

Impact and severity

This is a coverage gap, not a correctness bug — the code as written is correct, and the shared helper is well-exercised by the 5 covered builtins. But per the repo's own review rules, untested behavioral changes are a review concern, and the which change in particular is standalone logic with zero coverage. Nit-level: worth adding two rows before merge, but not blocking.

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