Skip to content

sys: add the ENOEXEC row to the libuv error text table - #39318

Open
robobun wants to merge 1 commit into
mainfrom
farm/671a33c9/libuv-error-map-enoexec
Open

sys: add the ENOEXEC row to the libuv error text table#39318
robobun wants to merge 1 commit into
mainfrom
farm/671a33c9/libuv-error-map-enoexec

Conversation

@robobun

@robobun robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Spawning a file the kernel cannot exec (a script with no shebang line, a file in no known binary format) reports ENOEXEC: unknown error, posix_spawn '/path/to/file' from Bun.spawn, Bun.spawnSync, and the node:child_process functions built on them (the "unknown error in execFileSync" of unknown error in execFileSync from node:child_process #31710). libuv's text for ENOEXEC is exec format error (util.getSystemErrorMessage(-8) returns it), so the message should read ENOEXEC: exec format error, posix_spawn '/path/to/file', as every other spawn failure does (ENOENT: no such file or directory, posix_spawn '...').
  • Cause: LIBUV_ERROR_MAP in src/sys/libuv_error_map.rs is filled with "unknown error" and then written one row per errno; it has no row for ENOEXEC. uv.h's UV_ERRNO_MAP (src/jsc/bindings/libuv/uv.h:161) has one, and the other two in-tree copies of that table (ProcessBindingUV.cpp, util.ts, both updated in node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660) have it too. The row is missing on every target; ENOEXEC is a SystemErrno variant on all of them.
  • Same file, FreeBSD only: the ENOTSUP row is #[cfg(not(target_os = "freebsd"))]'d out on the grounds that FreeBSD has no ENOTSUP variant. It has the associated const SystemErrno::ENOTSUP = EOPNOTSUPP (src/errno/freebsd_errno.rs:117), which indexes the table fine, so the guard only leaves that errno reading "unknown error" on FreeBSD.
  • Nothing checked the copy against uv.h: a missing row compiles, and the fill value hides it.

Fix

  • src/sys/libuv_error_map.rs: add ENOEXEC -> "exec format error" (at the end, where uv.h has it; the file follows uv.h's order), and write the ENOTSUP row on FreeBSD as well.
  • The texts are uv.h's verbatim, so the messages now match what node prints for the same errno. On FreeBSD, UV_ENOTSUP is -45, FreeBSD's EOPNOTSUPP, because its <sys/errno.h> defines ENOTSUP as EOPNOTSUPP; labelling slot 45 with uv.h's ENOTSUP text is therefore what libuv does there too.
  • test/internal/source-lints/libuv-error-map.test.ts (new): parses uv.h's UV_ERRNO_MAP, the table's rows together with their #[cfg] predicates, and the SystemErrno enum of each target (variants plus alias consts), then checks per target that every uv.h errno the target has is labelled with uv.h's text and written once, that every row is a uv.h errno, and that the only uv.h rows no target can hold are EAI_* and UNKNOWN. On main it fails on all five targets for ENOEXEC and additionally on FreeBSD for ENOTSUP (output below); it passes with this change. Reading the source is the only way to cover the FreeBSD and Windows rows from a Linux runner, and it picks up future uv.h rows as well. It lives in the source-lints directory, whose workflow already triggers on src/**/*.rs and src/jsc/bindings/**.
  • test/js/bun/spawn/spawnSync.test.ts: Bun.spawnSync and Bun.spawn of a no-shebang script throw ENOEXEC with the exec format error message. POSIX only: libuv on Windows reports an unloadable image as EFTYPE or UNKNOWN (checked against its error.c, and node on Windows prints UNKNOWN for the same file), so the Windows row is covered by the lint alone. Fails on bun 1.4.0 with unknown error, passes with this change.
  • Also run: bun test test/internal/source-lints/ (all pass); cargo check -p bun_sys for x86_64-unknown-freebsd, aarch64-apple-darwin, x86_64-pc-windows-msvc, and aarch64-linux-android; the existing spawnSync.test.ts file with the debug build.
  • spawn: retry via /bin/sh when exec returns ENOEXEC #31717 (open) proposes retrying through /bin/sh on ENOEXEC, as libuv does. If it lands, the spawnSync test here stops seeing ENOEXEC and needs to pick a file /bin/sh also cannot run, or go; the table row and the lint are unaffected.

Background

  • bun_sys::Error::to_system_error (src/sys/Error.rs) builds every JS-facing syscall error as "<CODE>: <label>, <syscall> '<path>'", the shape of node's UVException. <label> is looked up in LIBUV_ERROR_MAP, the in-tree copy of libuv's uv_strerror() texts, so a missing row shows up directly in user-visible messages. Shell builtins use a separate strerror()-style table (coreutils_error_map, being fixed for macOS in bun_core: use strerror() texts in the macOS coreutils_error_map #39264); it is not involved here.
  • SystemErrno (src/errno/<os>_errno.rs) is a per-target errno enum; the table is an EnumMap over it, built in a const fn by indexing an array with SystemErrno::X as usize. Rows for errnos some target lacks as a variant (ECHARSET, EOF, ENONET, ...) are wrapped in #[cfg] blocks; an errno present on a target but without an active row keeps the "unknown error" fill.
  • On FreeBSD ENOTSUP and EOPNOTSUPP are one errno (45), so the enum has an EOPNOTSUPP variant and an ENOTSUP associated const aliasing it; casting the alias with as usize yields 45 like a variant would.
Repro on bun 1.4.0 and with this change (Linux)
$ printf 'echo hello\n' > /tmp/no-shebang.sh && chmod +x /tmp/no-shebang.sh
$ bun -e 'try { Bun.spawnSync({ cmd: ["/tmp/no-shebang.sh"] }) } catch (e) { console.log(e.message) }
          try { require("child_process").execFileSync("/tmp/no-shebang.sh") } catch (e) { console.log(e.message) }
          console.log(require("util").getSystemErrorMessage(-8))'
ENOEXEC: unknown error, posix_spawn '/tmp/no-shebang.sh'
ENOEXEC: unknown error, posix_spawn '/tmp/no-shebang.sh'
exec format error

$ bun bd -e '...same...'
ENOEXEC: exec format error, posix_spawn '/tmp/no-shebang.sh'
ENOEXEC: exec format error, posix_spawn '/tmp/no-shebang.sh'
exec format error
Lint output on main
(fail) linux: every uv.h errno the target has is labelled with uv.h's text
  + "ENOEXEC: no row, uv.h says "exec format error""
(fail) android: ...
  + "ENOEXEC: no row, uv.h says "exec format error""
(fail) macos: ...
  + "ENOEXEC: no row, uv.h says "exec format error""
(fail) freebsd: ...
  + "ENOTSUP: no row, uv.h says "operation not supported on socket""
  + "ENOEXEC: no row, uv.h says "exec format error""
(fail) windows: ...
  + "ENOEXEC: no row, uv.h says "exec format error""
(pass) every row is a uv.h errno
(pass) the uv.h rows no target's SystemErrno can hold are exactly EAI_* and UNKNOWN

libuv_error_map.rs had no row for ENOEXEC, so a spawn that failed with
it was reported as "ENOEXEC: unknown error, posix_spawn '...'" instead
of carrying uv.h's "exec format error". The ENOTSUP row was also
compiled out on FreeBSD, where SystemErrno::ENOTSUP is the EOPNOTSUPP
alias and indexes the table fine, leaving that errno unlabelled there.

Add a source lint that replays the table's rows for each target_os and
compares them with UV_ERRNO_MAP in uv.h, and a spawnSync test for the
message.
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

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: 144fa55f-e1db-4727-acf0-eda4ba33f7be

📥 Commits

Reviewing files that changed from the base of the PR and between 23d535a and 9273e5f.

📒 Files selected for processing (3)
  • src/sys/libuv_error_map.rs
  • test/internal/source-lints/libuv-error-map.test.ts
  • test/js/bun/spawn/spawnSync.test.ts

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

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on bun 1.4.0 (Linux): Bun.spawnSync({ cmd: ["./no-shebang.sh"] }) throws ENOEXEC: unknown error, posix_spawn '.../no-shebang.sh'; with this branch it throws ENOEXEC: exec format error, posix_spawn '...'. The new source lint fails on main for ENOEXEC on every target and for ENOTSUP on FreeBSD, and passes here; cargo check -p bun_sys passes for the freebsd, darwin, windows and android targets.

@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 — one missing table row plus a cfg-guard drop, both verified against uv.h and the per-target errno enums.

What was reviewed:

  • Confirmed ENOEXEC exists as a SystemErrno variant on linux/darwin/freebsd/windows and ENOTSUP is an associated const on FreeBSD aliasing EOPNOTSUPP, so the un-gated row indexes a real slot in const fn on every target.
  • Checked the new lint's parsers against windows_errno.rs (which has both an E enum and a SystemErrno enum) — it anchors on pub enum SystemErrno {, so it reads the E-prefixed variants and ignores the E:: alias consts it doesn't need.
  • The spawn test is hermetic (tempDir + using), POSIX-gated with a stated reason, and asserts the exact code and message; Bun.spawn throws synchronously on posix_spawn failure so the non-async thrownBy shape is correct.
Extended reasoning...

Overview

Two-line data fix in src/sys/libuv_error_map.rs: add the ENOEXEC -> "exec format error" row (present in uv.h's UV_ERRNO_MAP at line 161 but missing from this copy on every target) and drop the #[cfg(not(target_os = "freebsd"))] guard on the ENOTSUP row (FreeBSD has SystemErrno::ENOTSUP as an associated const aliasing EOPNOTSUPP, so the guard only left slot 45 reading "unknown error"). A new source-lint test replays the table's rows per target against uv.h so a future missing row fails CI, and a POSIX-only spawn test asserts the user-visible message for a shebang-less script.

Security risks

None. This is a static errno -> const &str lookup table used only to format error messages; the added rows are string literals copied verbatim from vendored uv.h. No parsing of untrusted input, no allocation, no control-flow change.

Level of scrutiny

Low for the Rust change (pure table data; the const fn shape is unchanged; the PR ran cargo check -p bun_sys for freebsd/darwin/windows/android). Medium for the new lint — it's a hand-rolled parser over three source files, but I traced its regexes against the actual file shapes: uv.h's XX(NAME, "text") lines, the arr[SystemErrno::X as usize] = "..." rows with their #[cfg] blocks, and each target's pub enum SystemErrno body. The Windows file has two enums (E with bare names at ~line 100 and SystemErrno with E-prefixed names at line 415); the lint correctly anchors on the latter. The lint has size guards (> 50) so an empty parse fails loudly, and unknown cfg predicates throw rather than silently evaluating.

Other factors

The PR description shows the lint failing on main with exactly ENOEXEC (all five targets) plus ENOTSUP (FreeBSD only) and passing after — the fix and the check are proven paired. The spawn test follows harness conventions (tempDir, using, exact-message assertion, it.if(isPosix) with the Windows exclusion reason stated). The author flagged the interaction with open PR #31717 (sh-retry on ENOEXEC) up front. The source-lints directory already contains ~25 similar file-parsing checks, so the pattern is established.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:04 AM PT - Aug 16th, 2026

@robobun, your commit 9273e5f9506d2831a892c30d4e7fee972be9b792 passed in Build #99223! 🎉


🧪   To try this PR locally:

bunx bun-pr 39318

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

bun-39318 --bun

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