Skip to content

node: route Node 23.0-23.4 to the compat tier - #802

Merged
colinhacks merged 5 commits into
mainfrom
tier-23-registerhooks
Aug 28, 2026
Merged

node: route Node 23.0-23.4 to the compat tier#802
colinhacks merged 5 commits into
mainfrom
tier-23-registerhooks

Conversation

@colinhacks

Copy link
Copy Markdown
Contributor

Nub crashes at startup on Node 23.0 through 23.4 with TypeError: module_.registerHooks is not a function.

module.registerHooks is a semver-minor that shipped on the 23.x line at 23.5.0 and on the 22.x LTS line only later, at 22.15.0. The tier gate compared against 22.15.0 alone, so 23.0-23.4 sorted above the fast floor without having the API and was handed --require preload.cjs.

The fix excludes the band in supports_augmentation, the one predicate every downstream gate keys on. Two JS gates carried the same defect and now probe the capability instead of the version.

Verified on real 18.19.0, 22.14.0, 22.15.0, 23.0.0, 23.3.0, 23.4.0, 23.5.0, 23.6.0, 24.0.0, 24.19.0. Both new tests confirmed red without the fix.

`module.registerHooks` is a semver-minor that shipped on the 23.x line at
23.5.0 and on the 22.x LTS line only later, at 22.15.0 (nodejs/node#55698).
The tier gate compared against 22.15.0 alone, so 23.0.0-23.4.x sorted above
the fast floor without having the API. Those releases were injected with
`--require runtime/preload.cjs`, whose unconditional `registerHooks` call
threw `TypeError: module_.registerHooks is not a function` before any user
code ran.

`supports_augmentation` now excludes the band, which is the single point every
downstream gate keys on (preload channel, user-preload routing, chainer
channel). The compat `--import` path already works on 23.x.

Two JS gates carried the same defect and are fixed with capability probes
rather than version arithmetic: `__isFastTier` in preload.mjs, which is the
file the band now lands on, and the fast/async branch in preload.cjs, which
a grandchild `node` can still reach through inherited NODE_OPTIONS.

Verified on real 18.19.0, 22.14.0, 22.15.0, 23.0.0, 23.3.0, 23.4.0, 23.5.0,
23.6.0, 24.0.0 and 24.19.0.
Copilot AI lite review requested due to automatic review settings August 27, 2026 10:05
@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview Aug 27, 2026 11:24am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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

Important

The core fix is right and well evidenced, but the same off-by-a-release-line defect is still live in nub compile's external-shim gate, and the new preload.cjs fallback branch does not deliver everything the compat tier delivers on this band.

Reviewed changes — the full diff at 45f3ed27 across all 8 files, plus the surrounding tier-routing code in crates/nub-core/src/node/, crates/nub-cli/src/, and runtime/.

  • supports_augmentation re-bandedMIN_AUGMENTED_23 = 23.5.0 added, and the predicate branches on major() == 23. I confirmed this against nodejs/node#55698 and the v23.5.0 / v22.15.0 release notes: registerHooks reached 23.x at 23.5.0 and 22.x at 22.15.0 four months later, so 23.0.0–23.4.x is exactly the affected band and no other line is.
  • tier() de-duplicated — the test-only classifier now calls supports_augmentation() / is_supported() instead of restating the comparisons, so the tests can no longer drift from production.
  • Runtime capability probespreload.mjs's __isFastTier swaps its version band for typeof module.registerHooks === "function", and preload.cjs gains a hasRegisterHooks guard for the inherited-NODE_OPTIONS grandchild case.
  • Wiki + doc comments — an "Availability by release line" section in the coverage matrix with a probed typeof table, and the two-tier table in architecture.md.

Things I checked and cleared, so they don't come back in a later pass: module is a real binding in preload.mjs (import module from "node:module", line 43), so the typeof probe is valid there; the band/probe swap agrees with the old band on every Node version except 23.0–23.4; nodeHookComposeBroken()'s 22.15–24.11 band legitimately contains 23.0–23.4 because it guards a different defect (nodejs/node#59666), and its only consumer is inert on this band once hasRegisterHooks is false; and both new test groups genuinely go red without the fix — pre-fix, tier(23.0.0) returns FastPath and preload_injection_for(23.0.0) returns --require.

⚠️ nub compile's external-shim gate has the identical defect and is untouched

The PR body says supports_augmentation is "the one predicate every downstream gate keys on." That holds for the spawn path, but crates/nub-cli/src/compile/external.rs:82 keeps its own hardcoded NodeVersion::new(22, 15, 0), and the artifact it gates emits registerHooks directly. A nub compile --external … targeting 23.0–23.4 passes the check and produces a binary that dies at startup with the same registerHooks is not a function — the exact bug this PR exists to kill, one layer over.

Technical details
# `nub compile` external shim accepts a Node 23.0–23.4 target it cannot run on

## Affected sites
- `crates/nub-cli/src/compile/external.rs:82``fn min_node() -> NodeVersion { NodeVersion::new(22, 15, 0) }`, with a doc comment that repeats the same reasoning error: "`module.registerHooks` … landed in Node 22.15, the same floor nub's own fast tier uses."
- `crates/nub-cli/src/compile/external.rs:93``check_node_support` passes on `*version >= min_node()`, so `23.0.0 >= 22.15.0` is accepted.
- `crates/nub-cli/src/compile/external.rs:323,338` — the generated `__nub_external.mjs` destructures `registerHooks` from `node:module` and calls it unconditionally.
- `crates/nub-cli/src/compile/external.rs:106` — the refusal message says "which older Node does not have", which is the version-ordering fallacy stated out loud to the user.

Reachable only when `plan.needed()` is true, i.e. a `--external` or `--allow-dynamic-import` build. Note that a bare major pin floors at `X.0.0` (per the `check_node_support` doc comment), so plain `--target 23` lands squarely in the broken band.

## Required outcome
- A `--external` / `--allow-dynamic-import` build targeting any Node in 23.0.0–23.4.x is refused at build time with the existing message, rather than producing an artifact that throws at startup.
- The 22.15 floor and the 23.x floor stay expressed in one place, so the next reader cannot reintroduce the skew.

## Suggested approach (optional)
- Reuse the predicate this PR just made authoritative rather than adding a third copy of the band — e.g. gate on `version.supports_augmentation()` and derive the message's floor from the target's own major, so a 23.x target is told `--target 23.5` and a 22.x target is told `--target 22.15`. As written, a 23.4 user would be told to "pass `--target 22.15` (or newer)", which is both a downgrade and still wrong.
- `check_node_support`'s existing unit tests (`external.rs:456-481`) are the natural home for a `23.0.0` / `23.4.99` rejection case and a `23.5.0` acceptance case.

## Open questions for the human
- In scope for this PR, or a follow-up? It is the same defect class and the PR body claims the predicate is now centralized, so shipping without it leaves that claim inaccurate.

ℹ️ No CI leg runs a 23.x Node, so the band this PR fixes has no end-to-end coverage

The bug was an end-to-end startup crash on a real binary, but everything added here is a unit test over NodeVersion and preload_injection_for. Nothing in CI ever executes nub on a 23.0–23.4 Node, so a future regression in the runtime JS — preload.mjs's probe, say — would go unnoticed exactly the way this one did. The repo already has the machinery for this; it just needs one more entry.

Technical details
# Add a 23.0–23.4 leg so the fixed band is actually exercised

## Affected sites
- `.github/workflows/ci.yml:167``test_tiers` is `22.15`, `22.13`, `20.11`, `18.19` (plus `24` via `test_os`). No 23.x anywhere.
- `.github/workflows/ci.yml:906-918` — the `install_node` block side-installs 22.13.0 / 18.18.0 / 20.11.0 / 26.5.0 and exports them as `TEST_NODE_BIN_<MAJ>_<MIN>_<PAT>`.
- `crates/nub-cli/tests/version_tiers.rs` — already consumes those vars via `find_node_bin_dir`, skipping when absent, which is the established route for pinning a Node from a Rust test.

## Required outcome
- A test that runs the real `nub` binary against a real 23.0–23.4 Node and asserts the process starts and lands on the compat tier — i.e. it would have caught the original `TypeError` rather than only the predicate that caused it.

## Suggested approach (optional)
- One line in the `install_node` block (`dir_23_4=$(install_node 23.4.0)``TEST_NODE_BIN_23_4_0`) plus a `version_tiers.rs` case. That reuses the existing skip-when-absent convention, so it costs nothing locally.
- 23.4.0 is the strongest single pick: it is the last release before the API landed, so it pins the exact boundary.

ℹ️ Nitpicks

  • runtime/preload-common.cjs:308 still justifies treating any --import it sees as FOREIGN with "its compat-tier --import preload.mjs lives below 22.15 (outside the broken band this gates on)". This PR makes that clause false — the compat tier now overlaps nodeHookComposeBroken()'s 22.15–24.11 band on 23.0–23.4. I traced it and found no live self-detection bug (both injection sites call split_inherited_preloads first, so the two token flavors never coexist), but it is a load-bearing invariant stated incorrectly in a file this PR already edits.
  • Four more places restate the old boundary in prose and were not swept: runtime/preload.cjs:21 ("The compat tier (< 22.15)"), runtime/pnp-bin-run.cjs:13 ("on the compat tier (Node <22.15"), and crates/nub-core/src/node/spawn.rs:3234 and :5729. All are comments; the code at each is correct because it goes through supports_augmentation().

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread runtime/preload.cjs Outdated
`nub compile`'s external-shim gate kept its own `22.15.0` floor, so a
`--external` / `--allow-dynamic-import` build targeting Node 23.0-23.4 was
accepted and the generated `__nub_external.mjs` called `registerHooks`
unconditionally — the artifact died at startup with the same TypeError the
tier fix addresses. A bare `--target 23` floors at 23.0.0, landing in the band.

The gate now calls `supports_augmentation`, so the band lives in one place.
`fast_tier_floor_for_line` is new on NodeVersion and derives the floor for a
version's own release line; the refusal uses it to suggest 23.5 to a 23.x
target instead of 22.15, and drops the "which older Node does not have"
ordering claim. A 22.15 suggestion now carves the band back out, since a bare
"or newer" from there would re-admit it.

The preload.cjs async fallback added for that band registered the loader
worker but never installed the main-thread CJS require hooks, so a 23.0-23.4
grandchild reached through inherited NODE_OPTIONS had no transpile path at all
for `require('./x.ts')`: no registerHooks until 23.5, no native type stripping
until 23.6, no classic shim. It now installs them exactly as preload.mjs does
on the same band, gated on the transform core being loaded — the
require(esm)-disabled entry has none. The branch comment described only that
entry's rationale and now covers all three.

Also stop the native-deps harness from discarding verify-load.cjs output: a
native addon that aborts rather than throws took the script down under `set -e`
through a failed command substitution, leaving a bare "exit code 134" and no
diagnostics.

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

ℹ️ No critical issues — minor suggestions inline.

Both open findings from the last pass are closed by e9dbd339, and the compile-gate fix is the right shape: it deletes the duplicate floor rather than re-stating the band. What is left is two comments that describe their own code slightly more broadly than the code delivers.

Reviewed changes — commit e9dbd339 on top of the previously-reviewed 45f3ed27, plus the surrounding gate-input code in crates/nub-cli/src/compile/mod.rs and the acceptance predicate in crates/nub-launcher/src/main.rs.

  • Rewired check_node_support off its private 22.15.0 floor — the gate is now !plan.needed() || version.supports_augmentation(), so the compile path and the spawn path key on one predicate. This was the IMPORTANT finding from the last review.
  • Added NodeVersion::fast_tier_floor_for_line() so the refusal names a floor on the release line the user already targets, and supports_augmentation() now delegates to it instead of carrying its own branch.
  • Rewrote the refusal message to lead with module.registerHooks, state both line floors, and carve the hole out (other than 23.0 through 23.4) whenever it suggests the 22.x floor. I checked every version that can reach the message: a major-23 version necessarily lies in 23.0–23.4 (23.5+ early-returns), so the (or newer) form is only ever emitted from the 23.5.0 floor. The old wording would have told a 23.4 user to downgrade to 22.15 — that is gone.
  • Added two external.rs unit tests — 23.0.0 / 23.4.0 / 23.4.99 refused with --target 23.5.0 and explicitly not --target 22.15.0; 22.15.0 / 23.5.0 / 23.11.1 / 24.0.0 accepted; and a 20.19.0 case pinning the carve-out clause. Both go red against the pre-fix gate.
  • Installed installCjsRequireHooks(core, !process.features?.typescript) in preload.cjs's async branch and re-documented the branch's three entry conditions. The withClassicTranspile argument is byte-identical to preload.mjs:114's !__hasNativeTs, so it inherits an already-accepted tradeoff rather than introducing a new one. That closes the inline thread from the last pass, which I have replied to and resolved.
  • Hardened tests/native-deps/run.sh to capture verify-load.cjs's exit status separately, so a SIGABRT in a native addon prints diagnostics instead of taking the script down with a bare 134 under set -e. Correct, and unrelated to the tier fix.

One thing worth stating plainly since it looks like a regression and is not: --smol --target 23 now fails the gate. That is a true positive. A bare major pin floors at 23.0.0 and SmolTarget::matches accepts anything >= floor with no upper bound, so such an artifact really can be handed a 23.2 at run time.

ℹ️ Nitpicks

  • runtime/preload.cjs:190 and :205 scope the new shim call to "an inherited-NODE_OPTIONS 23.0–23.4 grandchild" and "Entry 3 only", but the guard !hasRegisterHooks && core also fires on 20.19–20.x and 22.12–22.14 — require(esm) was unflagged at 20.19.0 / 22.12.0, four minors before registerHooks reached 22.15.0, so core loads and the branch is entered on plain compat-tier Nodes too. The behavior there is right (it matches what preload.mjs:114 does on the same versions); the comment just names a narrower band than the code serves.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/nub-cli/src/compile/external.rs Outdated
Comment thread runtime/preload.cjs

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

ℹ️ No critical issues — one minor suggestion inline.

Both open threads from the last pass are closed by 80476e09, and it is a comment-only commit: no behavioral surface at all. I re-derived both claims it now makes against the source rather than taking them on the text, and both hold — one of them just stops one shape short.

Reviewed changes — commit 80476e09 on top of the previously-reviewed e9dbd339, plus the pin-shape plumbing in crates/nub-cli/src/compile/mod.rs, crates/nub-core/src/version_management/mod.rs, and SmolTarget::matches in crates/nub-launcher/src/main.rs.

  • Widened the KNOWN GAP note to cover the MajorMinor pin — it now says a major.minor pin "carries no range into the manifest, so SmolTarget::matches falls back to candidate >= floor". Verified: smol_version_range carries a range only under VersionPin::Range(_) if range_minimum_is(pin, gate), and range_minimum_is is itself matches!(pin, VersionPin::Range(..)), so a MajorMinor pin reaches the launcher with range: None, exact: false. This was the run-2 inline finding, replied to and resolved.
  • Rewrote the async-branch entry enumeration in preload.cjs from "available on two of them" to "installed on exactly ONE of them", and gave entry 2 (forceAsyncTier) an explicit statement of what it forgoes. Verified the count: entry 1 has core === null, entry 2 sits entirely inside nodeHookComposeBroken()'s 22.15.0..=24.11.0 band so hasRegisterHooks is always true there, and entry 3 is the only one where !hasRegisterHooks && core holds. The named forfeit — loader-worker import coverage, no main-thread _resolveFilename patch — matches installCjsRequireHooks and is genuinely unchanged from before this PR. This closes the other run-2 thread by documenting rather than widening, which was one of the two options offered.
  • Softened the ShimPlan::flag doc comment off its "both share the 22.15 floor" phrasing, now that there is no single floor to share.

One thing I checked and cleared so it does not come back: the "caught" half of the same sentence lists an exact three-part target, which does not floor inside the band when the target is a 22.x one. That is still correct, because smol_requires_exact_target is matches!(pin, VersionPin::Exact(_)) and SmolTarget::matches then compares candidate == floor — an exact pin cannot drift upward into the hole at all.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/nub-cli/src/compile/external.rs Outdated

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

ℹ️ No critical issues — one minor suggestion inline.

aeb4a76f is comment-only and applies the last pass's suggestion verbatim, so the open thread is closed and resolved. I re-derived the Alias claim in source rather than taking it on the text and it holds: resolve_pin_floor_in_index routes Alias to the newest-matching arm (version_management/mod.rs:878), smol_requires_exact_target and smol_version_range both decline it, so it reaches SmolTarget::matches with range: None, exact: false.

Reviewed changes — commit aeb4a76f on top of the previously-reviewed 80476e09, one hunk of three lines, plus an exhaustive re-trace of every VersionPin variant through the gate, the manifest writers, and the launcher's acceptance predicate.

  • Added the lts/<codename> alias to the KNOWN GAP enumeration — the note now names three shapes and groups the alias with the major.minor pin as the two that carry no range into the manifest. Accurate as written.

The one thing I would change is structural rather than factual. This is the third consecutive pass in which the closed count came up one short, and it is short again — an upper-only range (--target "<23") takes the same newest-matching floor path the alias does. Rather than a fourth item, the note is better off stating the invariant, which is what the inline comment proposes.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/nub-cli/src/compile/external.rs Outdated
@colinhacks
colinhacks merged commit 3532501 into main Aug 28, 2026
74 checks passed
@colinhacks
colinhacks deleted the tier-23-registerhooks branch August 28, 2026 21:37
@colinhacks

Copy link
Copy Markdown
Contributor Author

Shipped in v0.8.1: https://github.com/nubjs/nub/releases/tag/v0.8.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants