Skip to content

feat(dist): ship one binary per platform package, carry the verb in __NUB_ARGV0 - #665

Merged
colinhacks merged 7 commits into
mainfrom
dedup-platform-binary
Aug 3, 2026
Merged

feat(dist): ship one binary per platform package, carry the verb in __NUB_ARGV0#665
colinhacks merged 7 commits into
mainfrom
dedup-platform-binary

Conversation

@colinhacks

Copy link
Copy Markdown
Contributor

Platform packages shipped bin/nub and bin/nubx byte-identical at 45 MB so the CLI could read its verb off argv[0]. Six of eight exceeded cnpm/npmmirror's 80 MiB cap, so that mirror is pinned at 0.0.31 and installs behind it fail. Now 90.3 → ~45 MiB; release archive 49.0 → ~24.5 MB.

The second name cannot ship: npm pack drops symlinks, hardlinks are mangled on install, exec -a is a bashism dash rejects.

Deleting the duplicate alone fails silently — call 2 runs nub, exit 0. So the trampoline and the Node spawn both set the var; the latter is what makes Windows work. argv[0] stays the fallback; the var is erased at startup.

Verified on ubuntu + windows.

…_NUB_ARGV0

Every platform package shipped the SAME binary twice — bin/nub and bin/nubx,
byte-identical, 45 MB each — solely so the Rust CLI could read its verb off
argv[0]'s basename. That doubled the packages to 77-99 MiB and put six of eight
over cnpm/npmmirror's 80 MiB unpacked sync cap, so the mirror has been stuck at
0.0.31 since 2026-06-10 and installs behind it fail with "no version of
@nubjs/nub-darwin-x64 matches range 0.6.0".

The verb now travels in `__NUB_ARGV0`. argv[0] stays the fallback and still
carries every direct invocation: the installer's ~/.nub/bin/nubx symlink,
`nub pm shim` hardlinks, nubx-dev.

Why an env var rather than a link. Shipping the second name is not available:
npm pack silently DROPS symlinks, and hardlinks survive packing but are mangled
on install (npm and bun drop the file, pnpm writes a 0-byte one). Materializing
it at runtime would work on POSIX but trades a static guarantee for a filesystem
write that can fail. And sh cannot fake argv[0] portably — `exec -a` is a
bash/zsh-ism that dash, i.e. /bin/sh on Debian and Ubuntu, rejects.

Deleting the duplicate alone is NOT sufficient, and fails silently. Measured on
a real install with bin/nubx removed and nothing else changed: call 1 is correct
(the Node path falls back to bin/nub and sets argv0) and call 2 runs `nub` —
exit 0, no error, wrong command. Both dispatch paths therefore set the variable:
the healed sh trampoline as a prefix assignment, and the Node spawn via opts.env.

The Node-path env var is also what makes Windows work. Node documents argv0 as
affecting only the process TITLE on Windows, and Windows takes that path on every
call because the heal is POSIX-only.

capture_argv0_override runs unconditionally as the first statement of
normalize_invocation_environment, before its early returns, and ERASES the
variable. Erasing is load-bearing: nub spawns Node, a script under it may
re-enter nub, and an inherited __NUB_ARGV0=nubx would silently put that
grandchild in exec mode. The value is cached in a OnceLock because
Argv0::detect runs more than once per process and the variable is gone by then.

Because release.yml tars the GitHub archive from the platform package directory,
the curl/Homebrew download halves too (49.0 MB -> ~24.5 MB), and install.sh's
existing claim that "the release archive ships only bin/nub" becomes true.

Verification: clippy --all-targets --all-features clean, fmt clean, argv0 unit
tests pass, launcher matrix 25 ok/0 failed with a one-binary host package, and a
new tests/verb-dispatch probe against the REAL binary is 11 ok/0 failed covering
both PM shim shapes, both verbs, first AND second call, and env hygiene.
A branch-scoped workflow runs that probe on real ubuntu and windows runners.
Copilot AI review requested due to automatic review settings August 2, 2026 18:55
@vercel

vercel Bot commented Aug 2, 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 3, 2026 12:58am

Request Review

…file; align verb-dispatch action pins

The Docker non-owner leg chmod'd the platform package's nubx, which no longer
exists — the image build failed before the probe ran. The launcher package keeps
both stubs (they are bin-field entries npm chmods on install); only the platform
package lost its second file.

verb-dispatch.yml used action pins whose version comments did not match, and
omitted persist-credentials: false — both zizmor errors. Now copied verbatim from
launcher.yml / ci.yml.

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.

Pull request overview

This PR updates Nub’s launcher + CLI verb-dispatch mechanism so each platform package ships only one native binary (bin/nub), avoiding duplicate nub/nubx copies that pushed several platform packages over cnpm/npmmirror’s unpacked size cap.

Changes:

  • Switch verb selection for the one-binary platform package to use __NUB_ARGV0 (set by the launcher and erased at Nub startup), with argv[0] remaining as a fallback for direct invocations.
  • Update launcher/fixture tests to validate verb dispatch through both the Node-launcher path and the healed POSIX sh-trampoline path.
  • Update release packaging scripts/workflows to ship/carry only bin/nub (and nub.exe on Windows) in platform packages.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/verb-dispatch/probe.mjs Adds a real-binary probe validating nub/nubx dispatch with a one-binary platform package on Linux + Windows.
tests/launcher/run-launcher-matrix.sh Extends the launcher matrix assertions to validate the healed trampoline shape and post-heal dispatch for nubx.
tests/launcher/make-fixture.sh Updates the fake native fixture to prioritize __NUB_ARGV0 over argv[0] for mode selection.
npm/nub/postinstall.js Adjusts installer chmod commentary/logic for the one-binary platform package layout.
npm/nub/bin/launch.js Implements the one-binary verb transport (__NUB_ARGV0) across both healed and Node-spawn paths.
npm/build-local.sh Updates local platform package build script to ship only bin/nub.
crates/nub-cli/src/cli.rs Captures + erases __NUB_ARGV0 at startup and makes Argv0::detect() consult the override first.
.github/workflows/verb-dispatch.yml Adds a branch-scoped CI probe job for Linux + Windows verb dispatch with a real build.
.github/workflows/release.yml Updates release assembly to package only bin/nub (and nub.exe) per platform package and archive.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/verb-dispatch/probe.mjs Outdated
Comment on lines +149 to +151
const child = execFileSync(path.join(hostPkg, "bin", `nub${exe}`), [script], {
encoding: "utf8", env: { ...process.env, __NUB_ARGV0: "nubx" },
}).trim();
Comment on lines +6 to +8
// package past cnpm/npmmirror's 80 MiB sync cap. The verb now travels in `__NUB_ARGV0`
// (npm/nub/bin/launch.js sets it on both dispatch paths; Argv0::capture_argv0_override
// reads it and erases it).
Comment on lines +712 to +715
# not two: the launcher passes the verb in `__NUB_ARGV0` rather than relying
# on a separately-named `nubx` file (npm/nub/bin/launch.js,
# Argv0::capture_argv0_override). This is what keeps every platform package
# under cnpm/npmmirror's 80 MiB sync cap, and it halves the release archive
Comment thread crates/nub-cli/src/cli.rs
Comment on lines +599 to +608
pub unsafe fn capture_argv0_override() {
ARGV0_OVERRIDE.get_or_init(|| {
let verb = env::var("__NUB_ARGV0").ok().filter(|v| !v.is_empty());
if verb.is_some() {
// SAFETY: upheld by this function's caller — no other thread exists yet.
unsafe { env::remove_var("__NUB_ARGV0") };
}
verb
});
}

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

Caution

Two artifact consumers still expect bin/nubx inside the produced archive and were not updated: the Homebrew formula generator (brew install nub will fail on the next release, with no CI gate to catch it) and the winget portable manifest (the nubx command alias disappears from the Windows zip). A third, tests/launcher/Dockerfile.non-owner, should already be failing on this PR.

Reviewed changes — the full 9-file diff at 88ae5f5, plus the packaging consumers of the platform package and release archive, the __NUB_ARGV0 producer/consumer paths, and the dlx consent gate the new probe reaches.

  • One binary per platform packagerelease.yml and npm/build-local.sh stop emitting the byte-identical second copy, halving each package under cnpm/npmmirror's 80 MiB cap.
  • Verb moves into __NUB_ARGV0 — set by the healed sh trampoline, the polyglot JS fallback, and the Node direct-spawn; read once into a OnceLock and erased by Argv0::capture_argv0_override, called first in normalize_invocation_environment.
  • argv[0] stays the fallback — still carries the installer's ~/.nub/bin/nubx symlink, nub pm shim hardlinks, and nubx-dev.
  • Backward-compatible resolutionresolveBinary and postinstall.js keep probing bin/<verb> so a newer launcher still works against an older platform package.
  • Test coveragetests/launcher/ now models a one-binary host package and asserts the post-heal second call; new tests/verb-dispatch/probe.mjs plus a branch-scoped Windows/Linux workflow.

🚨 The Homebrew formula still installs bin/nubx from an archive that no longer has it

.github/scripts/gen-homebrew-formula.sh:90 is bin.install "bin/nub", "bin/nubx". The tarball is now packed from a bin/ holding only nub, so Homebrew raises Errno::ENOENT and brew install nub / brew upgrade nub fails outright. The formula is regenerated and pushed to nubjs/homebrew-tap by the bump-homebrew-tap job on every real tag push, and no workflow in this repo runs brew install against it — the break first surfaces at end-user install time.

Technical details
# Homebrew formula installs a file the archive no longer contains

## Affected sites
- `.github/scripts/gen-homebrew-formula.sh:90``bin.install "bin/nub", "bin/nubx"`; `bin/nubx` is absent from `nub-{darwin,linux}-*.tar.gz` after this PR.
- `.github/scripts/gen-homebrew-formula.sh:84` — the surrounding comment still describes "bin/ (nub + nubx, both real copies)".
- `.github/workflows/release.yml:1550-1618``bump-homebrew-tap` regenerates and pushes the formula on every tag-push release with no install verification.

## Required outcome
- A Homebrew install of the next release puts both `nub` and `nubx` on PATH, from an archive that ships only `bin/nub`.
- The generator's comment matches what the archive actually contains.

## Suggested approach
- `bin.install "bin/nub"` followed by `bin.install_symlink bin/"nub" => "nubx"`, mirroring what `install.sh` and `perform_selfowned_upgrade` already do.

## Open questions for the human
- Worth adding a `brew install --formula` smoke to `bump-homebrew-tap`, or at least a grep-level check that every path the formula installs exists in the packed archive? This class of break is invisible to CI today.

⚠️ The winget portable install loses the nubx alias

winget/manifests/n/Nubjs/Nub/0.2.5/Nubjs.Nub.installer.yaml:12 declares bin\nubx.exe as a NestedInstallerFiles entry with PortableCommandAlias: nubx. The win32 zip is packed from the same $PKG/bin the assemble step no longer copies nubx.exe into, so from the next release that file does not exist. Windows users installing via winget get nub but no nubx, and if winget-releaser/komac carries the previous manifest's file list forward rather than re-deriving it, winget install Nubjs.Nub fails validation instead of merely losing the alias. winget-validate.yml will not catch either case — the committed manifest pins the already-published v0.2.5 zip, which does still contain both binaries.

Technical details
# winget zip no longer contains the `nubx.exe` it declares

## Affected sites
- `winget/manifests/n/Nubjs/Nub/0.2.5/Nubjs.Nub.installer.yaml:12-13``RelativeFilePath: bin\nubx.exe` / `PortableCommandAlias: nubx`.
- `.github/workflows/release.yml` (assemble step, ~line 715-733) — the win32 branch no longer copies `nub.exe` to `nubx.exe`.
- `.github/workflows/release.yml:1619-1670``submit-winget` auto-generates the next manifest via `vedantmgoyal9/winget-releaser`.
- `.github/workflows/winget-validate.yml` — validates the committed v0.2.5 manifest against the v0.2.5 release, so it is blind to this.

## Required outcome
- Either the win32 zip keeps a `bin\nubx.exe` (a copy — the cnpm cap this PR targets applies to npm packages, not the GitHub release zip), or the `nubx` portable alias is intentionally dropped and the manifest reflects that.
- Whichever way it goes, confirm what `winget-releaser`/komac does with `NestedInstallerFiles` on an update: re-derive from the new zip, or carry the old list forward.

## Open questions for the human
- Is losing `nubx` for winget users acceptable? `install.ps1` sidesteps this by copying `nub.exe``nubx.exe` at install time, but a winget portable install has no such hook.

ℹ️ Nitpicks

  • tests/launcher/README.md:34 still documents the old assertion ("asserts the healed nubx names bin/nubx"), which the diff inverted.
  • tests/launcher/run-launcher-matrix.sh:12 — the header's scenario table still reads nubx-verb nubx keeps its verb through the heal (bin/nubx, "nubx-mode").
  • npm/nub/bin/launch.js:108-112 — the staged-copy rationale claims "the healed sh trampoline execs this path with no argv0 override", which this PR makes false. The naming constraint it justifies is now belt-and-suspenders rather than load-bearing.
  • .github/workflows/verb-dispatch.yml:71-84 — the step comment says it will "assert the packaged bin/ actually halved and would clear cnpm/npmmirror's 83,886,080-byte unpacked cap", but the step only echos the three numbers. Either compare against the cap and exit non-zero, or reword the comment to say it reports.

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

Comment thread npm/nub/bin/launch.js
Comment on lines +240 to +243
if (argv0Name) {
opts.argv0 = argv0Name;
opts.env = Object.assign({}, process.env, { __NUB_ARGV0: argv0Name });
}

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.

bin/nub calls launch() with no argument, so argv0Name is undefined and this block never fires for the nub verb — spawn then inherits process.env verbatim, letting a pre-existing __NUB_ARGV0=nubx silently win. The other two producers (the sh trampoline at :181 and the polyglot fallback at :185) assert the verb unconditionally for every verb; only this path doesn't, and it's the one Windows takes on every call.

Technical details
# The `nub` verb is the only dispatch path that doesn't pin `__NUB_ARGV0`

## Affected sites
- `npm/nub/bin/launch.js:240-243``opts.env` set only when `argv0Name` is truthy.
- `npm/nub/bin/nub:8``require("./launch.js")()`, no argument, so `argv0Name` is `undefined`.
- `crates/nub-cli/src/cli.rs:616-618` — the override wins over argv[0] unconditionally, so an inherited value is decisive.

## Required outcome
- Every dispatch path asserts the verb it means, so the invariant the header comment states ("the variable survives exactly one process") actually holds on all three producers.

## Suggested approach
- Set `opts.env` from `verb` (which is already `argv0Name || "nub"`) unconditionally; keep `opts.argv0` gated so the `nub` path's argv[0] is unchanged.
- Optional complement on the Rust side: `Argv0::classify` currently accepts `node` and every PM shim name from the override, though the launcher only ever emits `nub`/`nubx`. Narrowing the override to those two values would keep the internal channel as small as its producer.
Suggested change
if (argv0Name) {
opts.argv0 = argv0Name;
opts.env = Object.assign({}, process.env, { __NUB_ARGV0: argv0Name });
}
opts.env = Object.assign({}, process.env, { __NUB_ARGV0: verb });
if (argv0Name) opts.argv0 = argv0Name;

Comment thread tests/verb-dispatch/probe.mjs Outdated
Comment thread tests/launcher/make-fixture.sh
Comment on lines +23 to +24
push:
branches: [dedup-platform-binary]

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.

Scoped to dedup-platform-binary, this workflow stops firing the moment the branch merges. It is the only automated Windows coverage of the verb plumbing — launcher.yml excludes Windows for the reason this PR's own header calls "now stale" — so on merge that coverage drops to zero on the platform where every call takes the Node launcher path. Worth deciding whether this graduates to a path-filtered pull_request/push: main trigger or gets deleted as a landed ad-hoc probe.

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

ℹ️ Nothing new in the follow-up commit; the items from the previous review are still open.

Reviewed changes — only e1db603, the delta since the previous pullfrog review at 88ae5f5.

  • Dockerfile.non-owner chmod fixed — line 21 now modes only nub-host/bin/nub. Lines 19-20 correctly keep @nubjs/nub/bin/nubx (the launcher package's committed stub, still shipped) and the 2>/dev/null || true-guarded /opt/nub/bin/nubx. This clears the launcher.yml non-owner job break flagged last round.
  • verb-dispatch.yml action pins alignedcheckout@93cb6ef (v5.0.1) with persist-credentials: false, setup-node@a0853c2 (v5), rust-cache@e18b497. All three match the SHAs every other workflow in .github/workflows/ uses.

Still open from the previous review, unchanged by this commit: the Homebrew formula's bin.install "bin/nub", "bin/nubx", the winget manifest's bin\nubx.exe, the nub-verb gap in launch.js's direct-spawn opts, the env-hygiene check in probe.mjs, and the branch-scoped trigger on verb-dispatch.yml.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

…tatus

execFileSync throws on a non-zero exit, and `nub <script>` in a bare temp dir with
no dependencies warns and exits non-zero on Windows (macOS only warns). That failed
the Windows leg even though both dispatch assertions on it passed:
  ok: node bin/nub  -> nub mode
  ok: node bin/nubx -> nubx mode

The assertion is what the CHILD SAW in its environment, not nub's exit status, so
use spawnSync and read stdout+stderr either way. Also distinguishes the three
outcomes — erased, leaked, and never-reported — so a future failure says which.

@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 env-hygiene check now reports instead of throwing, but it still cannot observe the child on Windows — the nubx subject never resolves there, so the call lands in the CI dlx refusal and the check reports env-hygiene child never reported. The Windows leg of verb-dispatch.yml goes red every run, and the erasure invariant stays untested on the one platform where every call carries __NUB_ARGV0.

Reviewed changes — only 2a865ff, the delta since the prior pullfrog review at e1db603.

  • Env-hygiene check no longer aborts the probeexecFileSyncspawnSync with stdout and stderr merged, so a non-zero exit from nub no longer throws past RESULT: and the fs.rmSync cleanup. A miss is now a counted FAIL carrying the exit status, and a genuine leak is distinguished from a child that never reported.

One correction to the previous review, since it informed this commit: on Linux the check does reach its assertion. find_bin's unix candidate list is [""], and Path::join lets an absolute component replace the base, so the absolute script path resolves to itself and runs — verified against the published 0.6.0 binary in CI (CHILD:undefined, exit 0). Windows is where it breaks, for a different reason.

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

Comment thread tests/verb-dispatch/probe.mjs
…c mode

Setting __NUB_ARGV0=nubx on the parent made it dispatch as nubx, which treats the
script path as a tool to fetch and refuses under CI:

  nubx: refusing to download ...\probe-env.js in CI

Nothing to do with env hygiene. The property under test is that the variable is
REMOVED at startup, which capture_argv0_override does for any non-empty value, so
the value is irrelevant — use 'nub'.

Passed on macOS only because CI was unset there. Documented CI=true as the local
reproduction so the next probe change is checked against the runner's conditions
rather than a bare shell.

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

ℹ️ The Windows-leg breakage is genuinely fixed; one branch was left behind by the value swap.

Reviewed changes — only acef1cb, the delta since the prior pullfrog review at 2a865ff. It touches tests/verb-dispatch/probe.mjs and nothing else.

  • Env-hygiene check no longer dispatches in exec mode__NUB_ARGV0 for that spawn is now "nub" rather than "nubx", so the parent stays in file-run mode. I traced the chain to confirm the check can now reach its assertion on both legs: classify("nub")Argv0::Nubrun_nub() → the flag scan's final run_file_with_compat arm (cli.rs:2038) → run_file_in_dir, with nubx_consent's CI refusal out of the picture entirely. Nothing else aborts first — verify_deps::resolve_policy lands on the hardcoded Policy::Warn default (project_config.rs:213), and discover_node's PATH fast path satisfies the root engines.node >=22.15.0 from setup-node's Node 22, so no network access is involved.
  • Rationale recorded at the call site and in the header — the block now explains why the value is irrelevant to the assertion, and the header documents CI=true as the local reproduction of the runner's conditions.

ℹ️ Nitpicks

  • tests/verb-dispatch/probe.mjs:153-156 — this paragraph now contradicts the one directly below it. It attributes the execFileSync Windows failure to a dependency warning; lines 160-163 correctly identify the dlx refusal as the cause. Its "a bare temp dir has no dependencies" premise is also wrong: the spawn passes no cwd, so the child inherits the probe's cwd — the checkout root under verb-dispatch.yml, not the fixture's temp dir.

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

});
const seen = `${res.stdout ?? ""}${res.stderr ?? ""}`;
if (seen.includes("CHILD:undefined")) ok("__NUB_ARGV0 erased before any child sees it");
else if (seen.includes("CHILD:nubx")) no("__NUB_ARGV0 LEAKED to child — erasure is not working");

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.

The override value is now nub, so a genuine leak prints CHILD:nub and this branch can never match. The leak instead falls through to branch 3 and reports env-hygiene child never reported (exit=0): CHILD:nub — still a counted FAIL, so no false pass, but the message contradicts the output it prints alongside it.

Suggested change
else if (seen.includes("CHILD:nubx")) no("__NUB_ARGV0 LEAKED to child — erasure is not working");
else if (seen.includes("CHILD:nub")) no("__NUB_ARGV0 LEAKED to child — erasure is not working");

…VM timing run

Provisioning the VS C++ workload, cmake and an -msvc rustup host on a VM costs far
more than one CI dispatch, and a windows-gnu cross-compile is not the same artifact.
Release rather than fast: the timing question is about the shipped binary, and fast
carries debuginfo at roughly 3x the size, which moves image-load time.

@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 in the new commit — one substantive note on the artifact job, inline.

Reviewed changes — only fb69d56, the delta since the prior pullfrog review at acef1cb. It touches .github/workflows/verb-dispatch.yml and nothing else.

  • New release-artifact job — a windows-latest job that runs cargo build -p nub-cli --release, stages out/platform/bin/nub.exe plus the launcher package copied from npm/nub/., and uploads both as win-release-verbdispatch with 5-day retention, to feed a manual Windows-VM timing run.
  • Cross-reference from the size step — the Packaged-size comment now points at that job as the source of the binary the VM run uses.
  • Action pinscheckout@93cb6ef, rust-cache@e18b497, and upload-artifact@ea165f8d match the SHAs the rest of .github/workflows/ uses; the upload pin is identical across all seven other call sites in the repo.

ℹ️ Nitpicks

  • .github/workflows/verb-dispatch.yml:109-111out/platform carries no package.json, so the staged launcher in out/launcher cannot require.resolve("@nubjs/nub-win32-x64/bin/nub.exe") against it; using the artifact means hand-assembling a node_modules tree on the VM. Writing a two-field package.json in the staging step would make the artifact self-contained.
  • .github/workflows/verb-dispatch.yml:94-105 — the job fires on every branch push matching the paths filter, and [profile.release] is lto = "thin" with codegen-units = 1, so this is now the workflow's most expensive job while asserting nothing. An if: github.event_name == 'workflow_dispatch' gate would keep a one-off artifact on demand, and folds it into the same delete-or-graduate decision as the branch-scoped trigger.

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

with:
key: verb-dispatch-release-windows
- name: Build release
run: cargo build -p nub-cli --release

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.

This is not the shipped binary: release.yml:664-666 builds every platform binary with --features embed-runtime, so the published nub.exe carries the zstd runtime blob (and a populated primer) that a plain --release build omits — it is materially smaller, which is exactly the quantity the job says it exists to time. On the VM it also resolves no runtime at all: the feature-off find_preload arm walks up from the exe for a runtime/ sidecar (not staged here) and then falls back to the compile-time CARGO_MANIFEST_DIR path, i.e. this runner's checkout, so nub runs un-augmented and never pays the first-run extract-and-verify the shipped binary pays.

Technical details
# The staged artifact omits `embed-runtime`, so it is not the binary being timed

## Affected sites
- `.github/workflows/verb-dispatch.yml:105``cargo build -p nub-cli --release`, no `--features embed-runtime`.
- `.github/workflows/release.yml:664-666` — every shipped platform binary is `cargo build --release -p nub-cli --features embed-runtime` (`cross build` for the cross targets), after staging the addon and the vendored `runtime/node_modules`.
- `crates/nub-core/src/node/spawn.rs` `find_preload` — the `#[cfg(not(feature = "embed-runtime"))]` arm walks up from the binary's dir for `runtime/preload.mjs`, then falls back to `env!("CARGO_MANIFEST_DIR")/../../runtime/preload.mjs`. Neither path exists on the VM, so augmentation is silently off.
- `crates/nub-core/src/node/runtime_cache.rs` — the first-run zstd inflate + BLAKE3 verify that the shipped binary performs is compiled only under the feature, so it is absent from this artifact.
- No committed `crates/aube-resolver/data/primer-*.rkyv.*` in the tree, so this build also embeds an empty primer where the release build embeds the downloaded blob.

## Required outcome
- The uploaded `nub.exe` matches the shipped artifact closely enough for a load-time measurement to mean something: embedded runtime blob present, and the runtime resolvable on a machine that is not the build runner.

## Suggested approach
- Mirror `ci.yml`'s `windows-embed-roundtrip` job, which already does this on `windows-latest`: build and stage the real addon (`cd crates/nub-native && cargo build --release`, then copy `target/release/nub_native.dll` to `runtime/addons/nub-native.node`), then `cargo build -p nub-cli --release --features embed-runtime`.
- If a fully faithful artifact is not worth the extra steps, say so in the job comment instead — the current comment argues the opposite, that the profile was chosen because the question is about the shipped binary.

…ilds

Closes the two gaps the dispatch probe left on Windows.

The probe hand-assembles a node_modules tree and calls `node bin/nubx` directly, so
npm's own .cmd/.ps1 shim generation and PATH resolution were never exercised —
production Windows is cmd.exe -> nub.cmd -> node bin/nub -> spawn nub.exe and only
the last two hops were covered. And nothing measured speed.

win-e2e.mjs does a real `npm install -g`, dispatches both verbs through npm's own
generated shims on PATH, then A/Bs the launcher by holding the BINARY FIXED and
swapping only launch.js — two builds would confound the delta with codegen noise.

Two harness faults found and fixed while bringing it up, both mine:
- `npm install -g <dir>` SYMLINKS the directory, so the launcher's realpath became the
  source tree and resolution never saw the platform package. Pack to a tarball first,
  which is what a registry install does and also honours `files`.
- a declared `file:` optionalDependency is not installed into a global prefix and its
  failed attempt shadows a manual placement.

Local (macOS, release-equivalent): 5 ok / 0 failed, delta -2.3 ms.
Runs on windows-latest and ubuntu-latest so the two are comparable.

@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 in the new commit — but the new A/B harness measures less than it claims. Details inline.

Reviewed changes — only b2df038, the delta since the prior pullfrog review at fb69d56. It touches .github/workflows/verb-dispatch.yml and adds tests/verb-dispatch/win-e2e.mjs.

  • release-artifact became release-e2e, matrixed over two OSeswindows-latest + ubuntu-latest, with setup-node@22, fetch-depth: 0, and a step that reads the pre-change launch.js out of origin/main into /tmp/launch-old.js as the A/B baseline. Artifact renamed release-verbdispatch-${{ matrix.os }}; the staging step branches on OS for the binary name.
  • New win-e2e.mjs harnessnpm pack the launcher, npm install -g the tarball into a temp prefix, hand-place a one-binary platform package as a sibling in the global node_modules, then dispatch both verbs through npm's own generated shims with shell: true. This is the production cmd.exe → nub.cmd → node bin/nub → nub.exe chain that probe.mjs could not reach, and the comments explaining why a packed tarball is used instead of npm install -g <dir> (which symlinks, breaking module resolution) and why optionalDependencies must be deleted are both load-bearing and worth having recorded.
  • A/B timing on top of that install — 40 samples plus 5 warmups per arm per verb, median + IQR, with a max(5 ms, 10%) budget on the nub delta.

The --features embed-runtime note on the build step is unchanged by this commit and its thread is still open; the timing numbers now depend on it, since they are taken off that binary.

ℹ️ Nitpicks

  • tests/verb-dispatch/win-e2e.mjs:145-148launchJsAlt is a dead intermediate: installedLaunch is always equal to it, so three names collapse to one fs.existsSync check.
  • tests/verb-dispatch/win-e2e.mjs:84-96 — a failed npm pack records a FAIL and falls through, so the next step runs npm install -g <root> (tgz ?? "") and reports a second, unrelated failure on top. An early exit after the pack failure would keep the output readable.
  • .github/workflows/verb-dispatch.yml:121-126 — the baseline is origin/main:npm/nub/bin/launch.js, a moving ref. If this workflow is ever graduated past the merge, the A/B silently becomes new-vs-new and reports a 0.0 ms delta forever. Pinning to the merge-base commit keeps it honest.
  • The win- prefix no longer describes win-e2e.mjs now that it runs on ubuntu too.

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

const samples = [];
for (let i = 0; i < N + WARMUP; i++) {
const t = process.hrtime.bigint();
spawnSync(`${verb} --version`, { shell: true, encoding: "utf8", env, cwd: root });

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.

On the ubuntu leg this times the healed sh trampoline, not launch.js, so both arms are the identical path and the delta assertion below cannot fail. The dispatch section above already made the first call for each verb, which is what heals prefix/bin/nub and prefix/bin/nubx; from then on POSIX never reads launch.js again. timeIt also ignores spawnSync's status, so an arm that fails instantly times fast and still passes.

Technical details
# The ubuntu A/B arm swaps a file that is no longer in the code path

## Affected sites
- `tests/verb-dispatch/win-e2e.mjs:125-141` — the dispatch loop calls `nub` and `nubx` once each through `prefix/bin`, which triggers `healPathEntry`.
- `npm/nub/bin/launch.js:187-204` — the heal scans `PATH` for an entry named `<verb>`; `leadsToUs` matches because `npm install -g` makes `<prefix>/bin/<verb>` a symlink whose realpath is the installed launcher. The entry is replaced with an sh trampoline that `exec`s the native directly.
- `tests/verb-dispatch/win-e2e.mjs:167-173``installedLaunch` is rewritten per arm, but nothing reads it after the heal.
- `tests/verb-dispatch/win-e2e.mjs:152-161``timeIt` discards the `spawnSync` result, so exit status is never checked.
- `tests/verb-dispatch/win-e2e.mjs:14-15` — the header states "the only variable is the launcher change", which holds on Windows (the heal is a win32 no-op) but not on ubuntu.

## Measured
On this runner, with a fake native standing in for `nub.exe`: after `npm pack` + `npm install -g --prefix`, `prefix/bin/nub -> ../lib/node_modules/@nubjs/nub/bin/nub`. One call per verb turns both entries into `#!/bin/sh` trampolines carrying `__NUB_ARGV0='<verb>'`. Overwriting the installed `launch.js` with `throw new Error(...)` afterwards still dispatches `nub` and `nubx` correctly — proof the file is out of the path.

## Required outcome
- Either the ubuntu leg's A/B compares arms that actually differ, or it does not report `ok: launcher change costs nothing measurable` on a comparison it cannot fail.
- A timing arm that failed to run is not reported as a passing measurement.

## Suggested approach
- Re-heal per arm: after writing the arm's `launch.js`, restore `prefix/bin/<verb>` to the npm symlink (or point `PATH` at a fresh per-arm bin dir) so the timed call goes through Node again.
- Or scope the timing to `isWin` and have the ubuntu leg run only the install + dispatch assertions, which are the parts that carry information there.
- Assert `status === 0` inside `timeIt` and fail the arm otherwise.

Comment on lines +177 to +184
// The old launcher cannot dispatch nubx off a one-binary package, so old/nubx is a
// WRONG-ANSWER timing, not a comparable arm — nub is the honest comparison.
const dNub = results["new/nub"].med - results["old/nub"].med;
console.log(` delta (nub, new - old): ${dNub >= 0 ? "+" : ""}${dNub.toFixed(1)} ms`);
const budget = Math.max(5, results["old/nub"].med * 0.10);
Math.abs(dNub) <= budget
? ok(`launcher change costs nothing measurable on nub (|${dNub.toFixed(1)}| <= ${budget.toFixed(1)} ms)`)
: no(`launcher change moved nub by ${dNub.toFixed(1)} ms (budget ${budget.toFixed(1)})`);

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.

The header names the cost to measure as nubx spawning bin/nub.exe instead of bin/nubx.exe, but the only compared arm is nub — the verb whose path barely changed. Excluding old/nubx is right as written, yet a valid baseline is available: give the old arm a two-binary platform package (nub.exe copied to nubx.exe), which is exactly the pre-change shipping layout, and the comparison covers the verb the change actually affects.

Technical details
# The A/B compares the one verb the change does not affect

## Affected sites
- `tests/verb-dispatch/win-e2e.mjs:11-12` — states the quantity under test: `nubx` now spawns `bin/nub.exe` rather than `bin/nubx.exe`, plus an env-var assignment.
- `tests/verb-dispatch/win-e2e.mjs:46-48` — the platform package is staged with one binary for BOTH arms, which is what makes `old/nubx` a wrong-answer arm.
- `tests/verb-dispatch/win-e2e.mjs:177-184` — only `new/nub` vs `old/nub` feeds the budget assertion; `old/nubx` is timed (45 spawns) and discarded.

## Required outcome
- The reported delta covers `nubx`, or the harness states plainly that `nubx` is unmeasured rather than timing an arm it then throws away.

## Suggested approach
- Stage the old arm against a two-binary platform package: copy `nub.exe` to `nubx.exe` in the placed package before the `old` arm and remove it afterwards. That is the pre-change layout, so `old/nubx` becomes a correct-answer baseline and `new/nubx - old/nubx` is the number the header promises.
- If that is not worth the extra staging, drop the `old/nubx` timing loop and say `nubx` is covered for correctness only.

Windows pays ~94 ms per call, of which ~58 ms is a Node boot. Measured baselines
(run 30680335776): cmd /c rem 10.22, native no-op exe 6.11, nub.exe --version by
absolute path 19.19, node -e "" 57.98.

That Node hop is not inherent to Windows — it is a consequence of our bin target
starting with '#!/usr/bin/env node'. npm's cmd-shim branches on exactly that
(cmd-shim/lib/index.js:42) and for a SHEBANG-LESS target emits a direct invocation
in ALL THREE shims (.cmd, .ps1, extensionless sh) with no node and nothing to delete.
It is also why pnpm ships 'This file intentionally left blank' and bun names its bins
bin/bun.exe on every platform.

This probe measures the ceiling of each mechanism before any is built:
  A  today's shape (node-shebang bin)
  B  shebang-less bin target
  C  B plus a real nub.exe in the bin dir (PATHEXT)
  D  the binary by absolute path — the floor

B deliberately uses the real binary as the bin target. That is NOT shippable (a
cross-platform package cannot carry a 45 MB Windows exe); it measures whether the
mechanism is worth building a tiny native launcher for.

Local (macOS): A 77.6, B 13.9, C 12.6, D 8.5 ms — B is 5.6x. POSIX already gets this
via the heal; Windows, where the heal is a no-op, is the real question.

@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 in the new commit — but the new harness prints a speedup for a variant that never ran. Details inline.

Reviewed changes — only 0d4119f, the delta since the prior pullfrog review at b2df038. Pure additions: one workflow step and one new file; no existing line was modified.

  • New Launcher latency variants step in release-e2e — runs the new harness against target/release/nub{,.exe} on both matrix legs, after the win-e2e step and before staging the artifact.
  • New tests/verb-dispatch/win-latency-variants.mjs — four variants (A today's node-shebang bin target, B a shebang-less bin target, C = B plus a real nub.exe in the bin dir so PATHEXT resolves .exe ahead of .cmd, D the binary by absolute path as the floor), each npm packed and npm install -g'd into its own prefix, then 40 timed nub --version calls through whatever npm generated, resolved off PATH. Reports median + IQR plus a speedup-vs-A summary.
  • Rationale recorded in the header — the measured ~94 ms Windows breakdown, the cmd-shim/lib/index.js:42 shebang branch the hypothesis rests on, and an explicit note that variant B's use of the real 45 MB binary as a bin target is a ceiling measurement rather than a shippable shape. All three are worth having in the file.

This harness asserts nothing and always exits 0, which is defensible for a measurement probe — the caveat is below. It also inherits the still-open --features embed-runtime thread on the build step, since these numbers come off that same binary.

ℹ️ Nothing runs this harness once the branch merges

win-latency-variants.mjs measures a hypothesis for a future change — a shebang-less bin target fronted by a tiny native launcher — and verifies nothing about the one-binary change this PR makes. Its only caller is verb-dispatch.yml, which is scoped to push: branches: [dedup-platform-binary], so on merge the repo gains a third committed harness under tests/verb-dispatch/ that nothing invokes. Whether that is deliberate staging for the follow-up or should move out of this PR is a call only you can make.

Technical details
# Exploratory harness lands in a PR whose workflow dies on merge

## Affected sites
- `tests/verb-dispatch/win-latency-variants.mjs` — committed, 145 lines, no caller outside `verb-dispatch.yml`.
- `.github/workflows/verb-dispatch.yml:23-24``push: branches: [dedup-platform-binary]`, so the workflow stops firing after the merge.
- `.github/workflows/verb-dispatch.yml:139-144` — the only invocation.

## Required outcome
- Each of the three harnesses under `tests/verb-dispatch/` has a stated fate: graduated onto a trigger that survives the merge, or deleted with the branch, or explicitly kept as an on-demand `workflow_dispatch` probe.

## Open questions for the human
- Is the latency investigation a follow-up PR? If so, is there a reason to land its harness here rather than there — and would that follow-up want the harness driven from a `workflow_dispatch`-gated job rather than a branch push?
- `probe.mjs` and `win-e2e.mjs` assert real invariants about this change and would keep paying rent on a surviving trigger; `win-latency-variants.mjs` would not. Worth splitting their fates.

ℹ️ Nitpicks

  • tests/verb-dispatch/win-latency-variants.mjs:111-119 — off Windows, extraBinDirFile returns immediately, so C-exe-on-PATH is a byte-identical rerun of B. Measured on this runner: B 2.0 ms / C 2.0 ms, and the summary still prints both as separate mechanisms with separate speedups. Skipping the row when !isWin (or labelling it = B) keeps the ubuntu output honest.
  • tests/verb-dispatch/win-latency-variants.mjs:37-46median, iqr, and the two-candidate npm-cli.js probe are copy-pasted from win-e2e.mjs:69-72,150-151. Two throwaway harnesses in one directory is where a tiny shared _util.mjs starts paying off.
  • The win- prefix names a harness the workflow runs on ubuntu-latest too — same observation as win-e2e.mjs last round, now in a second filename.

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

Comment on lines +141 to +144
const a = rows.find((r) => r.label.startsWith("A"))?.med;
for (const r of rows.slice(1)) {
if (r.med && a) console.log(` ${r.label} vs today: ${(a / r.med).toFixed(1)}x faster (-${(a - r.med).toFixed(1)} ms)`);
}

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.

Measured on this runner, not inferred: pointed at a binary that exits 3 on every call, the harness prints works=false on A/B/C and still emits B-shebangless-bin vs today: 14.6x faster (-26.5 ms), exit 0. works is computed and printed but never consulted here, and D-direct-exec (line 133) hardcodes works: true while discarding its own spawnSync result — it reported works=true against that same exit-3 binary. Since the header makes these numbers a go/no-go threshold for building a native launcher, a variant that fails fast is the failure mode that most closely resembles a win.

Technical details
# The speedup summary does not consult whether the variant ran

## Affected sites
- `tests/verb-dispatch/win-latency-variants.mjs:141-144` — the `vs today` loop gates only on `r.med && a`, never on `r.works`.
- `tests/verb-dispatch/win-latency-variants.mjs:133` — the `D-direct-exec` row sets `works: true` unconditionally; line 129's `spawnSync(direct, ["--version"])` return value is discarded, so nothing checks that the floor measurement executed anything.
- `tests/verb-dispatch/win-latency-variants.mjs:79-84``works` is derived from iteration 0 only (a warmup), which is adequate, but it is the sole signal and it is advisory.
- `tests/verb-dispatch/win-latency-variants.mjs:19-20` — the header states the decision these numbers drive: "If B lands near 29 ms the approach is worth building a tiny native launcher for; if it lands near 90 ms the idea is dead and we stop."

## Measured
`VERB_TIMING_N=3 node tests/verb-dispatch/win-latency-variants.mjs <sh script whose only statement is `exit 3`>` on ubuntu:

A-node-shebang 28.4 ms works=false
B-shebangless-bin 1.9 ms works=false
C-exe-on-PATH 2.0 ms works=false
D-direct-exec 1.5 ms works=true
B-shebangless-bin vs today: 14.6x faster (-26.5 ms)


Exit status 0.

## Required outcome
- A variant whose invocation did not succeed does not contribute a `vs today` line, or contributes one that is unmistakably marked as invalid.
- `D-direct-exec` establishes `works` from what it actually observed rather than asserting it.

## Suggested approach
- Capture `r.status` / `r.stdout` in D exactly as `variant()` does, and set `works` from the same semver test.
- Skip (or prefix with a loud marker) any row in the summary loop where `works !== true`, and consider `process.exit(1)` when the A row itself did not run, since every comparison is relative to it.

@colinhacks
colinhacks merged commit f3e02e6 into main Aug 3, 2026
69 checks passed
@colinhacks

Copy link
Copy Markdown
Contributor Author

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

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