Visitar URL original
fix(npm): select the Linux platform package by libc, not manifest order by kjanat · Pull Request #108 · kjanat/runner · GitHub
Skip to content

fix(npm): select the Linux platform package by libc, not manifest order - #108

Merged
kjanat merged 15 commits into
masterfrom
106-libc-linux-defensive-cause-bun-and-deno-aint
Aug 5, 2026
Merged

kjanat merged 15 commits into
masterfrom
106-libc-linux-defensive-cause-bun-and-deno-aint

Conversation

@kjanat

@kjanat kjanat commented Aug 4, 2026 •

Copy link
Copy Markdown
Owner

Closes #106. Parent: #100.

Problem

npm/facade/lib/resolve.cjs walked Object.keys(optionalDependencies) and returned the first sub-package whose bin/ entry existed. That treats presence and manifest order as proof of runtime compatibility. The generated order puts linux-*-gnu ahead of linux-*-musl, so any tree carrying both variants deterministically selected GNU.

npm, pnpm and Yarn honour the libc field and never produce that tree. Bun and Deno do, and so does a node_modules copied off another machine. On a musl-only host the shim then handed back a glibc binary whose ELF interpreter is absent, and spawnSync could only report ENOENT.

Approach

The package names already encode the selection key, so the resolver uses that contract instead of ordering:

  1. Detect the host libc with layered signals, most authoritative first — RUNNER_LIBC, then process.report's glibcVersionRuntime, then musl markers (/etc/alpine-release, then the arch's ld-musl-*.so.1, plus a /lib + /usr/lib scan for arches with no known loader token), then the arch's glibc loader and libc.so.6. Each layer is a positive proof, never an inference from another layer's absence, so a missing or incomplete process.report — Bun, Deno, musl builds of Node — degrades to the filesystem rather than to a guess. musl is checked before glibc because a musl host with a glibc shim can run both.
  2. Derive the expected package name from the scope, platform, arch and libc convention: on musl x64 that is @runner-run/linux-x64-musl, on glibc arm64 @runner-run/linux-arm64-gnu.
  3. Resolve that name directly and drop the incompatible sibling from the search entirely, so it is not reachable through ordering, a partial install, or a missing bin.
  4. Fail in the facade when the expected package is absent but the sibling is installed, with a diagnostic naming both packages and the deciding signal — instead of deferring to an opaque spawnSync … ENOENT.

Guardrails:

  • The pair is engaged only when both names are declared for the running arch. linux-armv7-gnueabihf, Android, macOS, Windows and the BSDs keep their existing mapping, and no package name is ever synthesized for a target without a libc sibling.
  • An undecided libc keeps the declared order and says so on stderr, rather than passing a coin flip off as a decision.
  • RUNNER_LIBC=glibc|musl overrides detection, for hosts that genuinely run both (Alpine + gcompat, a glibc runtime on a musl userspace). It only picks between installed packages; it cannot make an incompatible binary runnable.

resolveBinary(name) keeps its signature. The new optional second argument is host injection (platform, arch, declared packages, detector, resolver, existsSync), defaulting to the real process — that is what makes the layers testable without a container per case.

Scope is unchanged from the issue: nothing here affects which packages an installer downloads, the .bin links installers create from platform sub-packages, or those sub-packages' standalone bin declarations.

Tests

npm/facade/test/resolve.test.cjs — 19 node:test cases, no dependencies, wired into the verify job (runs on PRs and master) and available as just test-facade / npm run test:facade.

The fixture manifest deliberately declares GNU first, and one case reverses the whole list, so nothing can pass on ordering alone. Coverage:

  • both x64 variants installed on a musl host, run and runner both resolve linux-x64-musl
  • both arm64 variants installed on a glibc host, both resolve linux-arm64-gnu
  • wrong-libc sibling only, in both directions: throws with a compatibility diagnostic, never returns the incompatible path
  • expected package present but its bin missing: still refuses the sibling
  • neither installed: keeps the existing optionalDependencies diagnostic
  • undecided libc: declared order plus a warning; RUNNER_LIBC override
  • linux-armv7-gnueabihf resolves with an empty pair and an untouched candidate order
  • darwin/freebsd unchanged; win32 still appends .exe
  • each detection layer in isolation, including process.report missing or incomplete falling through to the filesystem, and the lib-directory scan

The script is deliberately named test:facade, not test. The Rust integration fixtures live inside the repo, so detection ascends into the workspace package.json; a bare test script there outranks the test recipe in tests/fixtures/chain-sequential/justfile and namespaces it to just:test, which broke three chain_integration tests on the first push.

tests/docker/musl-libc-select.Dockerfile — pure Alpine, no libc6-compat to mask a wrong choice. Installs the published package, overlays the working-tree resolver, fetches and unpacks the GNU sibling next to the musl one (npm will not install it), then asserts: detectLibc() says musl against a real root filesystem, both binaries resolve to musl with both variants present, they execute, and with the musl package removed the facade fails with the libc diagnostic instead of handing back the GNU path.

tests/docker/musl-resolve.Dockerfile keeps testing the published tarball as-is; its header and the "resolve order now decides correctness" warning were stale and now point at the new file.

Verification

cargo test 1019 + all integration suites green, node --test 19/19, tsc --noEmit clean, dprint check clean. The un-stubbed path was also exercised against a real require.resolve tree, and the new Dockerfile's shell and JS stages were run against that fixture.

Not run: the Docker build itself — no daemon available in the environment this was written in. Its registry-dependent steps (npm view + wget of the GNU tarball, the global-install bin layout) are syntax-checked and argued-correct, not executed. Worth one local docker build before trusting it:

docker build -f tests/docker/musl-libc-select.Dockerfile \
  --build-arg VER=0.24.1 --progress=plain --no-cache -t runner-musl-libc-test .

TODO:

  • Verify docker test works

@kjanat kjanat linked an issue Aug 4, 2026 that may be closed by this pull request
@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Make the npm facade resolve the correct Linux libc package

  • Detect GNU or musl libc using runtime, environment, and filesystem signals.
  • Resolve the matching platform package directly.
  • Report actionable diagnostics for incompatible or incomplete installations.
  • Preserve existing behaviour for non-paired targets and linux-armv7-gnueabihf.
  • Add injectable resolver dependencies and 19 tests.
  • Add verification integration and a pure-musl Docker fixture.
  • Document libc detection and the RUNNER_LIBC override.
  • Rename the facade test script to test:facade.

Docker verification was not run because no Docker daemon was available.

Walkthrough

This pull request adds libc-aware Linux package selection to the npm facade. detectLibc uses overrides, runtime data, and filesystem markers. planCandidates selects the matching GNU or musl package and rejects incompatible siblings. The change adds diagnostics, unit tests, Docker coverage, documentation, changelog entries, and facade test commands in CI and local tooling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Facade as resolveBinary
  participant Detector as detectLibc
  participant Planner as planCandidates
  participant Filesystem

  Facade->>Detector: detect libc
  Detector->>Filesystem: read runtime and filesystem signals
  Filesystem-->>Detector: libc evidence
  Detector-->>Facade: gnu, musl, or undecided
  Facade->>Planner: plan platform candidates
  Planner-->>Facade: matching candidate order
  Facade->>Filesystem: verify executable
  Filesystem-->>Facade: executable status
  Facade-->>Facade: return binary or diagnostic
Loading

Possibly related issues

Suggested labels: documentation

Poem

The resolver checks libc before it sails,
GNU and musl now follow matching trails.
Wrong siblings get a clear report,
Tests guard every supported port.
A happy fix aboard the ship! 🏴‍☠️

🚥 Pre-merge checks | ✅ 6 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The pull request also includes unrelated maintenance changes, including nested-test isolation, catalogue updates, exported build types, and release documentation. Move unrelated maintenance changes into separate pull requests, or provide linked issue scope that explicitly covers them.
Semver Version Bump Validation ⚠️ Warning Source changes include resolve.cjs and exported APIs, but Cargo.toml remains 0.24.1 from base and no package.json version changed; the required SemVer bump is missing. Update the applicable version field before merging. Use a MINOR increment for the added backward-compatible resolver functionality and exported APIs.
✅ Passed checks (6 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #106, including layered libc detection, exact package selection, diagnostics, preserved mappings, tests, and musl coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 30.00%.
Changelog Update ✅ Passed Source changes are present and CHANGELOG.md is updated under ## [Unreleased] with a Fixed entry describing libc-aware facade selection; no version bump is detected.
Agents.Md Documentation Updated ✅ Passed No AGENTS.md file exists in the repository, so the conditional documentation-update requirement is not applicable.
Title check ✅ Passed The title clearly describes the libc-based Linux package selection fix and uses the required lowercase fix prefix.
Description check ✅ Passed The description directly explains the resolver fix, tests, scope, and verification results.

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

@kjanat
kjanat marked this pull request as draft August 4, 2026 09:31
@kjanat kjanat self-assigned this Aug 4, 2026
@kjanat
kjanat force-pushed the 106-libc-linux-defensive-cause-bun-and-deno-aint branch from ad84120 to 226a436 Compare August 4, 2026 10:36
@kjanat
kjanat marked this pull request as ready for review August 4, 2026 10:36
@kjanat kjanat added the cr:review Allow CodeRabbit review label Aug 4, 2026
coderabbitai[bot]

This comment was marked as resolved.

This comment was marked as resolved.

@coderabbitai coderabbitai Bot added the documentation Improvements or additions to documentation label Aug 4, 2026
coderabbitai[bot]

This comment was marked as resolved.

kjanat

This comment was marked as resolved.

kjanat

This comment was marked as resolved.

@kjanat kjanat left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know how #100 documents this:

Installer/environment Installed Linux variants Launcher behavior
Deno 2.9.4, official Alpine image GNU and musl Deno links Runner facade; bundled glibc masks its GNU-first selection

Why could we not do the fast path, and if linux, and first exec produces linker error, fall back to the other, like instead of stat'ing each file before reading you just handle the error? Or just order musl first? Why can't we be more like deno, denoland/deno:alpine that is. Tell me... I feel like it's less elegant, but simpler?

Feel free to tell me why I'm wrong... just post reply to this PR comment.

Comment thread .github/workflows/release.yml Resolved
Comment thread npm/facade/test/resolve.test.mts Resolved
Comment thread npm/facade/test/resolve.test.mts Resolved
Comment thread npm/facade/test/resolve.test.mts Resolved

kjanat commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

You're not wrong, and one of your two options is better than what I built. Splitting them, because they fail differently.

Ordering musl first: this works, and I under-weighted why

The reason it works is not ordering, it's that our musl targets are statically linked. This repo already leans on that — README.md: "two musl-static binaries, so one tag serves Alpine and Debian stages alike", and musl-resolve.Dockerfile accepts file reporting static as a pass.

A static binary has no PT_INTERP. There is no interpreter to be missing. @runner-run/linux-x64-musl runs on Alpine, Debian, the Deno hybrid image, and your laptop. So "always prefer musl on Linux" is correct on every host we ship for, in one line, with no detection:

  • Conforming installer, glibc host → only the GNU package is installed, musl isn't there, we fall through to GNU. Nothing changes.
  • Conforming installer, musl host → only musl. Nothing changes.
  • Bun/Deno, both installed → musl wins, and musl runs everywhere. Bug fixed.

That is strictly simpler than four detection layers, and the layers buy one thing over it: on a glibc host with both installed, you get the dynamically-linked GNU build the package manager would have picked. That is a preference, not a correctness property.

Exec-and-fallback: the mechanism is sound, the diagnostic is what breaks

I expected to argue side effects here and I can't. When execve fails there is no child: spawnSync returns result.error with no status, nothing was spawned, nothing read stdin, no task ran. Gating a retry on result.error?.code === "ENOENT" && result.status == null is safe. EAFP is fine.

What it can't do is tell you why. ENOENT from spawnSync is the same errno for:

  • the binary is missing,
  • the binary exists but its ELF interpreter doesn't (our bug),
  • the interpreter exists but a DT_NEEDED library doesn't.

So after the fallback also fails you know "both candidates failed to start", not "you have a glibc build on a musl host, install @runner-run/linux-x64-musl". You can still print a decent message by inspecting what's installed at that point — which is the same name-based reasoning, just moved after the failure instead of before it. That's a fair trade, not an obvious loss.

The narrower cost: resolveBinary() is a resolver with its own callers — musl-resolve.Dockerfile calls it directly to assert what the shim would pick. Fold the decision into the spawn and the only way to ask "which binary is right here" is to run something.

The "be more like Deno" version, taken seriously

denoland/deno:alpine doesn't detect anything. It ships a runtime that works on that image by carrying what it needs. Our equivalent is not smarter selection — it's not shipping the choice at all: drop linux-x64-gnu and linux-arm64-gnu, publish only the static musl builds for Linux, delete the libc field, and the entire failure class disappears. No detection, no fallback, no Bun/Deno double-download, half the Linux packages to build and publish.

The bill for that:

  • glibc users get musl's allocator. For a spawn-detect-exec CLI I'd expect it in the noise, but I have not measured it and I'm not going to assert it without numbers.
  • Anything relying on glibc-side behaviour — NSS, dlopen, locale — is gone. I don't think runner touches any of it, worth confirming.
  • It's a published-package contract change, not a resolver change. Existing lockfiles pin @runner-run/linux-x64-gnu.

What I'd actually do

Not this PR — it implements the acceptance criteria in #106 as written, it's green, and swapping the strategy underneath it makes the diff unreviewable against its own issue.

As a follow-up, in order of how much they delete:

  1. Ship only static musl for Linux. Deletes the problem instead of solving it. Needs a benchmark and a deprecation for the GNU package names.
  2. Prefer musl, keep the diagnostic. Drop detectLibc to a tiebreak, order musl first because static-runs-anywhere, and keep the "only the wrong sibling is installed" error since that case has no working binary in either strategy. Roughly a net deletion.
  3. Leave it as merged.

Say which and I'll open it. My preference is 1 if the benchmark supports it, 2 if it doesn't — and I'd rather find out with a measurement than by me asserting musl's malloc is fine.


Generated by Claude Code

@kjanat

This comment was marked as off-topic.

kjanat

This comment was marked as resolved.

@kjanat

This comment was marked as resolved.

@socket-security

socket-security Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​runner-run@​0.12.2 ⏵ 0.24.170 +11009996 +3100

View full report

kjanat commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Both fixed in defd065. Also: your Docker run went green, so the one part of this PR I couldn't execute is now verified on real hardware.

runner-run@0.12.2

The catalog asks for npm:runner-run@^0. 0.12.2 satisfies that, so once the lockfile recorded it, nothing ever moved it — ^0 spans the entire 0.x series, and bun has no reason to re-resolve a range that is still satisfied. Twelve releases went by underneath it.

It was never visible before because nothing in CI ran bun install. My chain added the first one, which is why a two-month-old resolution surfaced now rather than at some point last spring.

Nothing imports the package, but it is not dead weight either — site's scripts call it:

"prestart": "run build",
"deploy":   "run -s build wrangler:deploy",
"start":    "run wrangler:preview",

So it exists to put run on PATH, and every install was seeding node_modules/.bin/run with 0.12.2. Now npm:runner-run@^0.24, resolving to 0.24.1. It can still float within the minor, but it can no longer sit out a whole series.

One trap worth recording: bun update runner-run is not the fix. The workspace contains a package by that name (npm/facade), so the CLI matches the workspace member and rewrites the dependency to workspace:* — which points run at the facade template, whose optionalDependencies are empty until a release populates them. It "succeeds" and leaves you with a run that cannot resolve a binary. Editing the catalog range is the way.

cargo:test

Not the chain, and not env inheritance — I checked RUNNER_PM, RUNNER_DIR, RUNNER_GROUP_ACTIVE, GITHUB_ACTIONS, RUNNER_WARNED_ROOT, RUNNER_RUNNER, RUNNER_FALLBACK. A pre-existing race in nested_warnings.rs.

Both tests call nesting_project(), which builds the same path — runner-nested-<pid>, and the pid is shared because they are two tests in one binary. Each deletes that directory when it finishes, and cargo runs them on parallel threads. a_nested_runner_over_a_different_root_still_warns is the fast one: it spawns a single runner list, then deletes the shared directory. The other test is spawning runner run outer → just → a nested runner, so it is still mid-dispatch when the justfile disappears underneath it.

The resulting state reproduces the CI output exactly:

$ rm -f justfile && runner run outer      # what the surviving process sees
warn: runner.toml: unknown key `bogus` ignored: …
→ exec outer
Error: No such file or directory (os error 2)

No task named outer any more, so it falls through to exec, and there is no such command. Each test now gets its own tagged directory.

For honesty: I could not make it fail locally — 45 runs, 25 of them under saturated CPU. The window is small and your runner lost the coin flip. The shared path is plain in the source, though, and the reconstruction above matches the failure byte for byte.

Verified before pushing: full cargo test green, and the exact CI chain run end to end locally (fmt:check, cargo:lint, cargo:test, typecheck, test:facade — all ✓; just:lint-sh only omitted because shellcheck isn't in this container).


Generated by Claude Code

@kjanat

kjanat commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

@claude, can you check on your end if "runner-run": "npm:runner-run@latest", would work instead of "runner-run": "npm:runner-run@^0.24",?

kjanat commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Tested it. latest works, and it fixes the staleness better than ^0.24 — but it breaks CI on every release of our own package. Recommend keeping ^0.24.

It resolves, and it self-heals. From a deleted lockfile it lands on 0.24.1. More interestingly, pointed at the stale lockfile from f6f6e89, a plain bun install drags it forward, which ^0 never did:

# stale lock holds runner-run@0.12.2, then bun install
  npm:runner-run@latest  ->  "runner-run@0.24.1"   ← self-healed
  npm:runner-run@^0      ->  "runner-run@0.12.2"   ← stayed put; the original bug

So as a staleness fix it is strictly better than what I pushed. ^0.24 only worked because 0.12.2 falls outside the range — it forces one upgrade and then goes quiet again inside 0.24.x.

The problem is --frozen-lockfile. A dist-tag is re-resolved on every install, so the moment published latest differs from the locked version, the frozen install refuses:

$ bun install --frozen-lockfile      # catalog: latest, lockfile behind
error: lockfile had changes, but lockfile is frozen
note: try re-running without --frozen-lockfile and commit the updated lockfile
$ echo $?
1

We publish this package. So the sequence is: release 0.25.0 → next CI run on every branch and PR fails at the install step of the verify chain → someone has to run bun install and commit the lockfile to unblock. A version range does not do that: with ^0.24 locked at 0.24.1, publishing 0.25.0 leaves the frozen install perfectly happy, it simply does not upgrade until the range is bumped.

The trade is: ^0.24 costs one deliberate edit per minor. latest costs a forced lockfile commit per release, and reds CI in the window between.

Unrelated finding while testing. Both the committed lockfile and a freshly regenerated one resolve the root's runner-run to runner-run@workspace:npm/facade, not the registry — the workspace member wins on name. node_modules/.bin/run still ends up 0.24.1, hoisted from site's copy, so nothing is broken today. But the root devDependency is not doing what it looks like it is doing, and if site ever dropped its copy, run at the root would become the facade template with empty optionalDependencies. Worth a separate look; I have not touched it.

Say the word if you want latest anyway and I will switch it — it is a one-line change, and dropping --frozen or adding a lockfile-refresh bot would cover the CI failure mode.


Generated by Claude Code

@kjanat

kjanat commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner Author

@claude RE:

The trade is: ^0.24 costs one deliberate edit per minor. latest costs a forced lockfile commit per release, and reds CI in the window between.

Mind updating the release checklist in the CHANGELOG.md to mention this?


Bit too verbose, this 0c793d6 update... Mind looking at the surrounding bulletlist, and following that kinda length?

@kjanat
kjanat force-pushed the 106-libc-linux-defensive-cause-bun-and-deno-aint branch from 0c793d6 to f827483 Compare August 5, 2026 00:21
kjanat added 6 commits August 5, 2026 02:22
`lib/resolve.cjs` walked `optionalDependencies` and returned the first
sub-package whose bin existed. The generated order puts `linux-*-gnu`
ahead of `linux-*-musl`, so any tree carrying both variants selected GNU.
Package managers that honour the `libc` field never produce that tree,
but Bun and Deno do, and so does a `node_modules` copied off another
machine. On a musl-only host the shim then handed back a glibc binary
whose ELF interpreter is absent, and `spawnSync` could only report
`ENOENT`.

The resolver now detects the host libc, derives the expected
`@scope/linux-<arch>-<gnu|musl>` name from the packaging convention, and
resolves that name directly, dropping the sibling from the search. When
only the incompatible sibling is installed, resolution fails in the
facade with a diagnostic naming both packages instead of deferring to an
opaque spawn failure.

Detection is layered, because no single signal exists everywhere:
`RUNNER_LIBC`, then `process.report`'s `glibcVersionRuntime`, then musl
markers (`/etc/alpine-release`, the musl loader), then the glibc loader
and `libc.so.6`. Each layer is a positive proof, never an inference from
another layer's absence; an undecided libc keeps the declared order and
says so on stderr rather than passing a coin flip off as a decision.

Only Linux targets that declare both variants for the running arch are
reordered, so `linux-armv7-gnueabihf` never gets a musl sibling
synthesized and non-Linux targets resolve exactly as before.

Tests: `npm/facade/test/resolve.test.cjs` (node:test, no dependencies)
covers selection, both mismatch diagnostics, and every detection layer
against stubbed hosts, with the fixture manifest deliberately declaring
GNU first so nothing passes on ordering alone. Wired into the verify job
and `just test-facade`. `tests/docker/musl-libc-select.Dockerfile` runs
the working-tree resolver on pure Alpine with both Linux variants
planted, which is the only place the detection layers meet a real musl
root filesystem.

Closes #106
A bare `test` script in the workspace root shadowed the `test` recipe in
`tests/fixtures/chain-sequential/justfile`: the fixtures live inside the
repo, so detection ascends into the root `package.json` and a script
there outranks the fixture's just recipe. `runner run -s build test lint`
then ran the facade suite instead of echoing `test-ran`, and the just
recipe was namespaced to `just:test`, breaking three
`chain_integration` tests.

`test:facade` collides with nothing the fixtures declare. CI and
`just test-facade` already invoke `node --test` directly.
Review follow-ups on the libc-aware resolver.

`failLibcMismatch` claimed "Expected package: … — not installed" on every
path, including the one where the package resolves fine and only its
`bin/` entry is missing. The loop had already recorded that reason and
then dropped it, so a half-unpacked install told the user to `npm
install` a package they already had. The recorded failures are now
forwarded and printed, and the lead workaround switches to "reinstall so
the platform packages are unpacked in full" when the package is present
but broken.

`glibcVersionFromReport` now sets `process.report.excludeNetwork` around
`getReport()`. The report otherwise enumerates network interfaces and can
issue reverse DNS lookups for them — unbounded latency on the launch
path, for a field the resolver never reads. The flag is saved and
restored only when it was a boolean to begin with: its setter rejects
`undefined`, so an unconditional restore would throw on runtimes where
the property does not exist. The whole probe is behind a cast because
`excludeNetwork` predates its @types/node declaration.

The header now names the incompatible sibling by libc rather than "That
package", whose antecedent got murky once the failure list sits above it.

Docs: the CHANGELOG points at `npm/facade/lib/resolve.cjs` rather than a
bare `lib/`, and the README troubleshooting entry stops implying the shim
itself emits `ENOENT` — that is the historical symptom the check now
replaces — and no longer hard-codes an x64 package name in an example
that also applies to arm64.
The troubleshooting entry described only the missing-package case, so its
advice was to install a package that, on the other path, is already
installed and merely unpacked without its bin. Both cases are now spelled
out with the fix that matches each, and the historical `ENOENT` is kept
as history rather than as current behaviour.
Review feedback.

The test file hand-listed the platform packages and then spent 19 cases
restating the same selection rule per platform. It now iterates
`npm/targets.json` directly: each target is installed alongside its libc
sibling where the matrix ships one, and must resolve to its own package.
That covers all 14 targets, adds new ones for free, and keeps four cases
for what the loop cannot express — both mismatch diagnostics, the
undecided fallback, and a table of the detection layers. It imports
through `#resolve` rather than a relative path, since the facade already
declares the mapping.

CI runs the suite through runner itself (`run test:facade`) instead of
invoking node directly, and the justfile calls the package.json script
rather than duplicating the command.

Dropped the comment banners and box-drawing rules from the Dockerfile,
the step comment in the workflow, the packaging internals from the
facade README, and two thirds of the CHANGELOG entry.
`node:test` has no `.each`, so the per-target cases were a hand-rolled
`for` loop over `npm/targets.json`. Bun is already this workspace's
package manager and `bun:test` has `test.each`, so the suite moves there
and the matrix drives it directly: one `test.each` over `targets`, one
over the libc detection layers, four cases for what a table cannot
express.

`test:facade` is now `bun test npm/facade/test/`, which the justfile and
CI already invoke. Adds `@types/bun` from the catalog, plus the explicit
`/// <reference types="bun" />` the test file needs — tsconfig pins
`typeRoots`, so the reference `@types/bun` makes to `bun-types` is not
resolved on its own.
kjanat added 7 commits August 5, 2026 02:22
Bun runs TypeScript directly, so the suite is an `.mts` file with real
type annotations instead of a `.cjs` file wearing JSDoc casts. The
`Target` shape, the `LibcSignals`/`Libc` tables and the fixture helpers
are typed declaratively, and `#resolve`'s types come in through
`import type` rather than inline `import("#resolve").X` casts.

No change to what is asserted; both `test.each` tables and the four
hand-written cases are the same 26 tests.
The verify job installs the CLI with this repo's own action and drives
the JavaScript side through it, the way the README tells everyone else
to: one `runner install --frozen typecheck test:facade` in place of the
raw `cargo run --bin run --` call. `--pm bun` picks the JS toolchain over
Cargo, so `install` actually fetches the node dependencies the two tasks
need. That also puts `typecheck` in CI for the first time, which the new
TypeScript test needs.

The test file redeclared the matrix shape that `targets.schema.json`
describes and `build-packages.ts` already models. Those types are now
exported and imported, so `os`/`cpu`/`libc` carry
`NodeJS.Platform`/`NodeJS.Architecture`/`Libc` rather than bare `string`,
and the JSON import states its `with { type: "json" }` attribute.

Dockerfile pins the digest against `alpine:latest` rather than a `3.22`
tag it does not need.
`verify` now installs the CLI with this repo's action and drives every
check through a single `runner install` chain instead of five separate
`run:` steps. Cross-source qualifiers do the work: `just:lint-sh`,
`cargo:lint`, `cargo:test` and the bare package.json scripts all resolve
in one invocation, so the workflow shows one roll-up summary with a line
per check. `-k` keeps going, so a run reports every failure rather than
only the first, and `--pm bun` makes `install` fetch the node
dependencies the JS tasks need.

Adds a `fmt:check` script so dprint is reachable as a task; `just lint-sh`
stays the shellcheck entry point.

The test file used a hand-written regex to strip escape sequences from
captured stderr. ansispeck already exports `strip`, and the facade
already depends on it, so the test uses that.
Replaces the `dprint` entry in the taiki-e tool list. The action installs
the binary and pins `DPRINT_CACHE_DIR`, caching both the binary and the
WASM plugin store, and pre-downloads plugins on a cache miss.

Drops `~/.cache/dprint` from the fmt-tools cache, which now only carries
the nightly toolchain that dprint's rustfmt exec formatter installs
through its `setupCommand`; the plugin store belongs to the action.

`verify` is the only job that can reach dprint — it is the only one that
runs `dprint` directly, and the only one running the `fmt:check` script.
The five release workflows shell out to `.github/scripts/*.sh`, cargo and
docker, none of which invoke dprint.
The digest pinned a `linux/386` manifest rather than the multi-arch
index, so the build resolved to 386 regardless of host, `process.arch`
reported `ia32`, and the fetch asked for `@runner-run/linux-ia32-gnu` —
a package that does not exist, because no Linux GNU/musl pair is
published for that arch. `sha256:28bd5fe8…` is the index digest and
carries amd64, arm64, arm v6/v7, 386, ppc64le, riscv64 and s390x, so the
build resolves to the host.

`npm view` failing left the URL empty, which `set -e` does not catch in a
command substitution, so the run continued to `wget: bad address ''`
instead of reporting the missing package. The tarball URL is now checked,
and the arch is rejected up front unless it is one of the two that ship
both libc variants.
Both tests in `nested_warnings.rs` built the same project path —
`runner-nested-<pid>` — and each deleted it on the way out, while cargo
runs them on parallel threads. When the cleanup landed mid-dispatch the
justfile vanished under the runner being tested, leaving no task named
`outer`, so it fell through to exec and reported `No such file or
directory`. Exactly the CI failure. Each test now gets its own tagged
directory. The window is narrow — 45 local runs never hit it, under load
or otherwise — but deleting a directory another thread is running in is
not a race worth keeping.

The catalog asked for `npm:runner-run@^0`, which `0.12.2` satisfies, so
the lockfile sat on a build from twelve releases ago and every install
put its `run`/`runner` in `node_modules/.bin`. Nothing imported it, but
`site`'s scripts (`prestart`, `deploy`, `start`) call `run`, so it is on
PATH for those. Now `^0.24`, which resolves to 0.24.1 and cannot silently
rot a whole minor series again.

Note `bun update runner-run` is not the fix here: the workspace contains
a package of that name, so it rewrites the dependency to `workspace:*`
and points `run` at the facade template, whose optionalDependencies are
empty until a release builds them.
…klist

The range names a published version, so it can only move after the tag.
Skipping it leaves every install on the previous minor, which is how the
lockfile sat on 0.12.2.
@kjanat
kjanat force-pushed the 106-libc-linux-defensive-cause-bun-and-deno-aint branch from f827483 to 74f3135 Compare August 5, 2026 00:23
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot added bug Something isn't working and removed documentation Improvements or additions to documentation labels Aug 5, 2026
coderabbitai[bot]

This comment was marked as low quality.

kjanat added 2 commits August 5, 2026 02:31
GNU remains preferred when detected, but the musl artifacts are static
and run on glibc hosts. Keep them as a fallback instead of rejecting a
usable package with an inaccurate ELF-interpreter diagnostic.

GNU remains excluded on musl, where it would still fail with `ENOENT`.
Cover both directions and document the asymmetric compatibility.
Register every temporary package tree and remove it after the Bun suite
so local and CI runs do not leak directories under the system temp path.

Keep the facade README focused on commands and supported platforms, link
the full installation choices instead of explaining package internals,
and use Docker bridge networking by default in the musl test example.
@kjanat
kjanat merged commit 72015e2 into master Aug 5, 2026
19 checks passed
@kjanat
kjanat deleted the 106-libc-linux-defensive-cause-bun-and-deno-aint branch August 5, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working cr:review Allow CodeRabbit review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make resolve.cjs select the libc-specific Linux package

1 participant