shell: don't abort when a glob's directory prefix doesn't exist (#31367)
### 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:
```js
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. R
robobun committed
7bd0861fc6c1cd635bee9155527cbf96a8a0a274
Parent: 159a4b7
Committed by GitHub <noreply@github.com>
on 5/25/2026, 12:54:32 AM