Skip to content

shell: don't abort when a glob's directory prefix doesn't exist#31367

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/e25d5056/fix-shell-glob-enoent-crash
May 25, 2026
Merged

shell: don't abort when a glob's directory prefix doesn't exist#31367
Jarred-Sumner merged 5 commits into
mainfrom
farm/e25d5056/fix-shell-glob-enoent-crash

Conversation

@robobun

@robobun robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a process abort (SIGTRAP / exit 133 in release, assertNoException assertion in debug) when a Bun.$ glob pattern points into a directory that doesn't exist:

await Bun.$`echo /nonexistent-path-xyz/*`.nothrow().text();
console.log("survived"); // never reached — the process aborts

.nothrow() / try-catch cannot prevent it.

Cause

For echo /nonexistent/* the pattern's literal prefix (/nonexistent) becomes the glob walk root, and opening it fails with ENOENT. Expansion::on_glob_walk_done handled any walk error by calling interp.throw(...) — raising a JS exception from the glob task callback — and then kept running the interpreter with the expansion marked Done. The exception stayed pending on the VM with no JS frame above the task to observe it, so the next exception check aborted the process. (It also silently dropped the argument and let the command "succeed" with exit 0.)

Relative patterns never hit this path: echo ./nonexistent/* already reports bun: no matches found: ./nonexistent/* with exit code 1.

Fix

Stop throwing from the task callback, and handle walk errors through the shell's normal expansion-error machinery:

  • ENOENT / ENOTDIR (the pattern's literal directory prefix doesn't exist): treated exactly like a glob that matched nothing — bun: no matches found: <pattern> on stderr, exit code 1, catchable ShellError, .nothrow() respected. Matches what relative patterns already do and zsh.
  • Any other failure in command position (EACCES, EMFILE, internal errors): routed through ExpansionState::Err, the same path walker-init failures use, so the real error reaches stderr with exit code 1 (e.g. bun: Permission denied: /root/) instead of masquerading as "no matches found".
  • Assignment position (FOO=/nonexistent/*, FOO=/unreadable/*): every kind of walk failure falls back to the literal pattern, matching the existing no-match behavior and bash/zsh (scalar assignments don't glob). Assigns never prints expansion errors, so erroring there would mean exit 1 with empty stderr.

(The last two refinements came out of review on earlier revisions of this PR.)

How did you verify your code works?

  • Before: bun -e 'await Bun.$echo /nonexistent-path-xyz/*.nothrow().text(); console.log("survived")' aborts (exit 133/134). After: prints survived, the command fails with exit 1 and bun: no matches found: /nonexistent-path-xyz/*.
  • New test in test/js/bun/shell/bunshell.test.ts (glob on a nonexistent absolute directory does not crash the process) runs the repro in a subprocess and covers .nothrow(), the default-throws ShellError path, and assignment position. It fails (the child aborts) without the fix and passes with it.
  • New test glob over an unreadable directory reports the real error covers the EACCES path in command and assignment position (skipped when running as root); the behavior was also verified manually by running the debug build as an unprivileged user against a mode-000 directory.
  • Full test/js/bun/shell/bunshell.test.ts passes with a debug (ASAN) build.

A glob whose literal directory prefix is missing (e.g. `echo /nonexistent/*`)
makes the walker fail with ENOENT. Expansion::on_glob_walk_done raised a JS
exception from the glob task callback and kept running the interpreter, so the
exception stayed pending with no JS frame to observe it and the process
aborted; .nothrow()/try-catch never got a chance.

Treat a failed walk like an empty match set instead: command position fails
with "no matches found" (exit 1, catchable ShellError), assignment position
expands to the literal pattern — the same behavior relative patterns already
have when their directory is missing.

Also skip the cd-EACCES test when running as root, since root bypasses the
permission check it relies on.
@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:07 PM PT - May 24th, 2026

@robobun, your commit 2677fda has 1 failures in Build #57768 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31367

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

bun-31367 --bun

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've used your available PR reviews for now.

Your plan includes 5 reviews of capacity. Refill in 6 minutes and 22 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

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

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7a25a375-bf23-4f4f-9587-f75651520e79

📥 Commits

Reviewing files that changed from the base of the PR and between 4c1bb7f and 2677fda.

📒 Files selected for processing (2)
  • src/runtime/shell/states/Expansion.rs
  • test/js/bun/shell/bunshell.test.ts

Walkthrough

This PR changes glob expansion error handling in the shell runtime to log failures and continue to standard processing logic instead of immediately throwing. Regression tests validate the new behavior across execution contexts and update an existing test's platform gating.

Changes

Glob expansion error handling without throwing

Layer / File(s) Summary
Glob walk error logging and continuation
src/runtime/shell/states/Expansion.rs
Expansion::on_glob_walk_done logs glob-walk errors by variant (Syscall vs Unknown) and continues to existing empty-result and results-present handling instead of throwing immediately.
Regression tests for glob error handling and EACCES gating
test/js/bun/shell/bunshell.test.ts
New subprocess test validates glob expansion on non-existent absolute directories produces consistent "no matches found" behavior across command-position (throwing and .nothrow()) and assignment-position contexts. EACCES regression test is gated with root-detection to skip on root platforms.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the main change: preventing process abort when a glob's directory prefix doesn't exist, which is the core issue this PR addresses.
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.
Description check ✅ Passed The pull request description is comprehensive and well-structured, covering all required sections with clear explanations of the problem, cause, fix, and verification.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

Comment thread src/runtime/shell/states/Expansion.rs Outdated
…tches

Keep the ENOENT/ENOTDIR fallthrough (missing directory prefix behaves like a
no-match glob), but route other walk failures (EACCES, EMFILE, internal
errors) through ExpansionState::Err — the same path walker-init failures use —
so the real error reaches stderr with exit code 1.
Comment thread src/runtime/shell/states/Expansion.rs Outdated
robobun added 2 commits May 24, 2026 21:56
Assigns never prints expansion errors, so routing a non-ENOENT walk failure
through ExpansionState::Err in assignment position produced exit 1 with empty
stderr and left the variable unset. Fold the walk-error handling into the
no-match branch instead: assignments always fall back to the literal pattern
(scalar assignments don't glob), command position keeps "no matches found" for
ENOENT/ENOTDIR and the real error for anything else.
Comment thread src/runtime/shell/states/Expansion.rs
Comment thread src/runtime/shell/states/Expansion.rs

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

All my earlier feedback has been addressed and I have nothing further to flag — deferring to a maintainer mainly to sign off on the deliberate fail-loudly-on-partial-results choice (vs. bash/zsh's skip-and-continue), since that's a user-visible semantics call rather than a correctness question.

Extended reasoning...

Overview

This PR fixes a process abort in Bun.$ when a glob's literal directory prefix doesn't exist (e.g. echo /nonexistent/*). The change is confined to Expansion::on_glob_walk_done in src/runtime/shell/states/Expansion.rs (~30 lines net): instead of calling interp.throw() from the glob task callback (which left a pending JS exception with no observing frame and aborted), walk errors are now routed through the existing ExpansionState::Err / no-match machinery. ENOENT/ENOTDIR fall through to "no matches found"; other syscall errors and ShellGlobErr::Unknown set ExpansionState::Err so the real error reaches stderr in command position; assignment position always falls back to the literal pattern. Two new tests in bunshell.test.ts cover the crash repro (subprocess), the throwing/.nothrow()/assignment paths, and a root-gated EACCES case in both positions; an existing EACCES cd test also gained !isRoot gating.

Security risks

None. This is error-path handling for filesystem glob walks; no auth, crypto, parsing of untrusted input, or privilege boundaries are touched. The change strictly narrows behavior from "abort the process" to "exit 1 with a diagnostic", and the assignment-position literal fallback matches bash/zsh.

Level of scrutiny

Moderate. The shell interpreter state machine is intricate (NodeId trampoline, multiple parent kinds for Expansion), and getting the error-propagation contract right took three revisions here — the second of which fixed a real silent-exit-1 bug in assignment position. The final shape is sound and well-tested, but it encodes a design decision: when the walker yields some matches and then errors (e.g. one unreadable subdir among readable siblings), the partial results are discarded and the command fails, whereas bash/zsh silently skip the unreadable subdir and return the partial set. The author's rationale (deterministic, avoids readdir-order-dependent results, real parity belongs in GlobWalker) is reasonable, and I flagged it as non-blocking — but it's the kind of user-visible semantics call a maintainer should bless rather than a bot.

Other factors

  • Three prior review rounds from me; the one blocking finding (Assigns dropping the boxed error) was fixed in 720085a, and the two remaining non-blocking observations (partial-results discard; pre-existing CondExpr TODO) were acknowledged with sound rationale and resolved.
  • The two commits since my last review (6cdab3b5 ci retrigger, 2677fdae remove comments) are cosmetic.
  • Test coverage is good: subprocess-isolated crash repro plus EACCES coverage in both command and assignment position.
  • No CODEOWNERS entry for src/runtime/shell/.
  • Strict improvement over the pre-PR behavior (process abort) on every path.

@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI status summary for reviewers: the changed code is green everywhere — test/js/bun/shell/bunshell.test.ts (including the new glob tests) passed on every test lane in all three builds, and the macOS lanes also exercise the root-gated EACCES tests.

The red builds were each caused by unrelated, known-flaky areas:

  • Build 57729: test/js/node/http/node-http-backpressure-max.test.ts timeout on macOS 14 x64; complex-workspace install test on linux x64-asan (passed on retry).
  • Build 57749: test/js/bun/s3/s3-stream-cancel-leak.test.ts SIGABRT on debian x64-asan; bun-install.test.ts flake on macOS aarch64 (passed on retry).
  • Build 57768: test/js/web/streams/streams-leak.test.ts on alpine x64; hot.test.ts EPERM and bun-install.test.ts flakes on Windows (both passed on retry).

None of these touch the shell or glob expansion. One retrigger was already spent; leaving further retries/merge to a maintainer.

@Jarred-Sumner Jarred-Sumner merged commit 7bd0861 into main May 25, 2026
75 of 76 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/e25d5056/fix-shell-glob-enoent-crash branch May 25, 2026 00:54
springmin pushed a commit to springmin/bun that referenced this pull request May 25, 2026
* oven/main (20 new commits):
  webcore: free Blob's owned content type on drop (oven-sh#31358)
  Support cross-compiling macOS binaries from Linux (oven-sh#31303)
  test: forward keep-alive requests in proxy.test.ts's mock proxy (oven-sh#31352)
  Port Bun.stringWidth to C++ with explicit SIMD (oven-sh#31351)
  Fix quadratic hang reporting duplicate-binding parse errors in the transpiler (oven-sh#31341)
  shell: don't abort when a glob's directory prefix doesn't exist (oven-sh#31367)
  Error instead of crashing on deeply nested statements in the transpiler (oven-sh#31333)
  Fix JSX transform panic when a bare `key` prop precedes `key` with a value (oven-sh#31350)
  Cap ANSI markdown indentation so deeply nested lists render in linear time (oven-sh#31366)
  css: bound selector-list expansion when compiling nesting for older targets (oven-sh#31277)
  node:http2: reassemble HEADERS+CONTINUATION before HPACK decoding (oven-sh#31323)
  Fix `await using` expression printing `using` as `await` (oven-sh#31324)
  Parenthesize `async` when it starts a for-of loop initializer (oven-sh#31326)
  Print Infinity and negative numeric property keys as computed properties (oven-sh#31328)
  css: keep required grouping parens in @container conditions when minifying (oven-sh#31330)
  Fix panic on anonymous export default class with an auto-accessor field (oven-sh#31331)
  node:http2: send GOAWAY frames on stream 0 (oven-sh#31353)
  parser: fix Scope mismatch while visiting panic from decorators on dropped class members (oven-sh#31340)
  webcrypto: reject oversized BufferSource inputs instead of aborting (oven-sh#31356)
  Error instead of crashing on deeply nested TypeScript types in the transpiler (oven-sh#31361)

Resolved conflicts:
  - scripts/build.ts: kept both OHOS and macOS-cross argv entries
  - scripts/build/config.ts: kept both OHOS and macOS-cross config fields
  - scripts/build/deps/webkit.ts: kept OHOS fno-pic exclusion, adopted upstream -flto=thin
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