Skip to content

Commit 1ef6a13

Browse files
zkochankairosci
authored andcommitted
fix(exe): restore @pnpm/exe startup on Node.js v25.7+ (pnpm#11330)
## Summary `@pnpm/[email protected]` aborts on every invocation with: ``` node::sea::(anonymous namespace)::SeaDeserializer::Read() at ../src/node_sea.cc:174 Assertion failed: (format_value) <= (static_cast<uint8_t>(ModuleFormat::kModule)) ``` Two independent Node.js v25.7+ SEA regressions are responsible, both surfaced by the rc.4 bump of the embedded runtime from 25.6.1 to 25.9.0. This PR fixes both and adds a prepublish smoke test so a broken binary can't reach npm again. ## Root cause **1. SEA blob format changed in Node.js v25.7.0** ([nodejs/node#61813](nodejs/node#61813) added ESM-entry-point support and inserted a new `ModuleFormat` byte into the blob header). SEA blobs carry no version marker, so a blob written by one Node.js version can only be deserialized by a matching one. In rc.4, the CI host Node.js (25.6.1, pre-change) wrote the blob and it was embedded in a 25.9.0 runtime (post-change) — the deserializer reads a misaligned byte as `format_value`, exceeds `kModule`, `CHECK_LE` fires, `SIGABRT`. `resolveBuilderBinary()` was preferring `process.execPath` whenever the running Node supported `--build-sea`, never checking that its version matched the embedded runtime. **2. Node.js v25.7+ replaces the ambient `require` and `import()` inside a CJS SEA entry with embedder hooks** that only resolve built-in module names. The `pnpm.cjs` shim loaded `dist/pnpm.mjs` via `await import(pathToFileURL(...).href)`, which after the fix to (1) reached the CJS entry and then blew up with: ``` ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: file:///.../dist/pnpm.mjs at loadBuiltinModuleForEmbedder at importModuleDynamicallyForEmbedder ``` ## Changes - **`releasing/commands/src/pack-app/packApp.ts`** — `resolveBuilderBinary` now takes the resolved target runtime version and only reuses `process.execPath` when `process.version` exactly matches; otherwise it downloads a host-arch Node of the target version via the existing `ensureNodeRuntime` path. Added `PACK_APP_RUNTIME_TOO_OLD` for runtimes older than v25.5 (no `--build-sea`). Removed the now-unused `DEFAULT_BUILDER_SPEC` and the stale `fetch`/`nodeDownloadMirrors` args on the builder resolver. Help text / examples refreshed to drop `node@22` / `node@lts` references that would now be rejected. - **`pnpm/pnpm.cjs`** — loads `dist/pnpm.mjs` through `Module.createRequire(process.execPath)` instead of `await import(fileURL)`. `createRequire` returns a regular CJS loader that bypasses the SEA embedder hooks, and the pnpm bundle has no top-level await so synchronous `require` of ESM (Node 22+) loads it cleanly. No build-time paths are baked in — `process.execPath` is evaluated at runtime, verified by relocation-testing the darwin-arm64 SEA under `/tmp/`. - **`pnpm/artifacts/verify-binary.mjs`** (new) + `prepublishOnly` on every platform artifact — replaces the existence-only `test -f pnpm` gate with: 1. A **relocation-sensitivity check**: run the binary without `dist/` staged and confirm the failure mentions a path derived from `process.execPath`, not a build-time constant. Catches any future regression of (2). 2. A **smoke test**: stage a `dist → ../exe/dist` symlink (using `symlink-dir` so Windows junctions are handled transparently), exec `./pnpm -v`, assert the output is a SemVer 2 string. - Cross-platform targets (darwin/win32 artifacts on a Linux CI, or a libc mismatch) skip the exec with a log line and fall back to existence-only, so a musl artifact published from a glibc host still goes through. - Real `dist/` dirs (developer layout) are preserved; stale symlinks from aborted runs are replaced; created symlinks are cleaned up on exit. - **`pnpm/artifacts/exe/test/setup.test.ts`** — new `pnpm -v` execution test gated on both the platform binary and the staged bundle being present, so ordinary `pn compile` test runs skip cleanly instead of failing on a missing `dist/`.
1 parent 4427491 commit 1ef6a13

14 files changed

Lines changed: 243 additions & 34 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@pnpm/releasing.commands": patch
3+
"pnpm": patch
4+
---
5+
6+
Fix the `@pnpm/exe` SEA executable crashing at startup on Node.js v25.7+. Two separate regressions in `@pnpm/[email protected]` are addressed:
7+
8+
1. `pnpm pack-app` now pins the Node.js used to write the SEA blob to the exact embedded runtime version. The SEA blob format changed in Node.js v25.7 (ESM entry-point support added a `ModuleFormat` header byte), so a blob produced by a pre-25.7 builder cannot be deserialized by a 25.7+ runtime and vice versa. In rc.4 the CI host Node.js (v25.6.1) built blobs embedded in a v25.9.0 runtime, tripping `SeaDeserializer::Read() ... format_value <= kModule` on every invocation. `pack-app` now downloads a host-arch builder Node.js of the target version when the running Node.js doesn't already match.
9+
10+
2. The pnpm CJS SEA entry shim now loads `dist/pnpm.mjs` through `Module.createRequire(process.execPath)` instead of `await import(pathToFileURL(...).href)`. In Node.js v25.7+, the ambient `require` and `import()` inside a CJS SEA entry are replaced with embedder hooks that only resolve built-in module names, causing external `file://` loads to fail with `ERR_UNKNOWN_BUILTIN_MODULE`. An explicit `createRequire()` bypasses those hooks.

cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"eisdir",
7575
"elifecycle",
7676
"elit",
77+
"embedder",
7778
"emfile",
7879
"enametoolong",
7980
"endregion",

pnpm/artifacts/darwin-arm64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"pnpm"
1818
],
1919
"scripts": {
20-
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
20+
"prepublishOnly": "node ../verify-binary.mjs darwin arm64"
2121
},
2222
"devDependencies": {
2323
"@pnpm/macos-arm64": "workspace:*"

pnpm/artifacts/darwin-x64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"pnpm"
1818
],
1919
"scripts": {
20-
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
20+
"prepublishOnly": "node ../verify-binary.mjs darwin x64"
2121
},
2222
"devDependencies": {
2323
"@pnpm/macos-x64": "workspace:*"

pnpm/artifacts/exe/test/setup.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ const platformBin = path.join(
1717
isWindows ? 'pnpm.exe' : 'pnpm'
1818
)
1919
const hasPlatformBinary = fs.existsSync(platformBin)
20+
// dist/ is staged by the build-artifacts flow (not by `pn compile`), so
21+
// ordinary test runs don't have it. The hardlink test is fine without it
22+
// (existence + inode only), but the -v test actually executes the SEA, which
23+
// loads dist/pnpm.mjs from next to the binary and would fail here.
24+
const hasStagedBundle = fs.existsSync(path.join(exeDir, 'dist', 'pnpm.mjs'))
2025

2126
describe('exePlatformPkgName', () => {
2227
test('uses linuxstatic- prefix for linux + musl libc family', () => {
@@ -68,4 +73,18 @@ test('prepare writes correct content for all bin files', () => {
6873

6974
const pnpmBin = path.join(exeDir, isWindows ? 'pnpm.exe' : 'pnpm')
7075
expect(fs.statSync(pnpmBin).ino).toBe(fs.statSync(platformBin).ino)
76+
});
77+
78+
// Actually execute the hardlinked pnpm binary. Existence and inode-match are
79+
// not enough — a SEA blob built by a Node.js version that differs from the
80+
// embedded runtime deserializes on startup with a native assertion and an
81+
// abort signal, not a clean error exit (see rc.4 regression). Running `-v`
82+
// verifies the SEA payload is actually readable by the embedded Node.
83+
(hasPlatformBinary && hasStagedBundle ? test : test.skip)('pnpm -v runs and prints a semver', () => {
84+
execFileSync(process.execPath, [path.join(exeDir, 'prepare.js')], { cwd: exeDir })
85+
execFileSync(process.execPath, [path.join(exeDir, 'setup.js')], { cwd: exeDir })
86+
87+
const pnpmBin = path.join(exeDir, isWindows ? 'pnpm.exe' : 'pnpm')
88+
const stdout = execFileSync(pnpmBin, ['-v'], { encoding: 'utf8', timeout: 30_000 }).trim()
89+
expect(stdout).toMatch(/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/)
7190
})

pnpm/artifacts/linux-arm64-musl/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"pnpm"
1818
],
1919
"scripts": {
20-
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
20+
"prepublishOnly": "node ../verify-binary.mjs linux arm64 musl"
2121
},
2222
"devDependencies": {
2323
"@pnpm/linuxstatic-arm64": "workspace:*"

pnpm/artifacts/linux-arm64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"pnpm"
1818
],
1919
"scripts": {
20-
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
20+
"prepublishOnly": "node ../verify-binary.mjs linux arm64 glibc"
2121
},
2222
"devDependencies": {
2323
"@pnpm/linux-arm64": "workspace:*"

pnpm/artifacts/linux-x64-musl/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"pnpm"
1818
],
1919
"scripts": {
20-
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
20+
"prepublishOnly": "node ../verify-binary.mjs linux x64 musl"
2121
},
2222
"devDependencies": {
2323
"@pnpm/linuxstatic-x64": "workspace:*"

pnpm/artifacts/linux-x64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"pnpm"
1818
],
1919
"scripts": {
20-
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
20+
"prepublishOnly": "node ../verify-binary.mjs linux x64 glibc"
2121
},
2222
"devDependencies": {
2323
"@pnpm/linux-x64": "workspace:*"

pnpm/artifacts/verify-binary.mjs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env node
2+
// Prepublish gate for the @pnpm/<platform> artifact packages. Runs from the
3+
// package directory (cwd contains the built pnpm binary). Verifies:
4+
// 1. The binary exists with the expected filename for the target.
5+
// 2. If the host can execute the target, `pnpm -v` returns a semver.
6+
//
7+
// Existence alone is not sufficient — @pnpm/[email protected] shipped a binary
8+
// that was present but crashed with a native SEA deserialization assertion on
9+
// any invocation. Executing -v would have caught it on the Linux CI host.
10+
//
11+
// Each platform package ships only the SEA binary (no dist/ or node_modules),
12+
// but the SEA's CJS entry (pnpm.cjs) loads dist/pnpm.mjs from
13+
// dirname(process.execPath). To run the binary in place we symlink
14+
// ./dist -> ../exe/dist (the sibling @pnpm/exe package's staged bundle) for
15+
// the duration of the test, then remove the symlink on exit. The platform
16+
// package's "files" whitelist is "pnpm" only, so a stale symlink would never
17+
// reach the published tarball, but we clean up anyway to leave the tree
18+
// untouched for subsequent tools.
19+
import { execFileSync } from 'node:child_process'
20+
import fs from 'node:fs'
21+
import path from 'node:path'
22+
import process from 'node:process'
23+
24+
// Resolves via the pnpm CLI's own node_modules (which always contains
25+
// symlink-dir — the CLI depends on it directly). symlink-dir handles Windows
26+
// junctions internally, so the verifier doesn't need its own elevation /
27+
// link-type branching.
28+
import { symlinkDirSync } from 'symlink-dir'
29+
30+
const [targetOs, targetArch, targetLibc] = process.argv.slice(2)
31+
if (!targetOs || !targetArch) {
32+
console.error('Usage: verify-binary.mjs <os> <arch> [libc]')
33+
process.exit(2)
34+
}
35+
36+
const binName = targetOs === 'win32' ? 'pnpm.exe' : 'pnpm'
37+
if (!fs.existsSync(binName)) {
38+
console.error(`Error: ${binName} is missing in ${process.cwd()}`)
39+
process.exit(1)
40+
}
41+
42+
// Node populates header.glibcVersionRuntime only on glibc hosts, so its
43+
// presence is a reliable glibc/musl discriminator without shelling out.
44+
function detectHostLibc () {
45+
if (process.platform !== 'linux') return null
46+
const header = process.report.getReport().header
47+
return header.glibcVersionRuntime ? 'glibc' : 'musl'
48+
}
49+
const hostLibc = detectHostLibc()
50+
51+
// Cross-platform or cross-libc targets can't be executed from the publish
52+
// host. Existence is the best we can verify — skip the -v check instead of
53+
// failing, so a musl artifact published from a glibc CI still goes through.
54+
const osMatches = process.platform === targetOs
55+
const archMatches = process.arch === targetArch
56+
const libcMatches = targetOs !== 'linux' || !targetLibc || targetLibc === hostLibc
57+
58+
if (!osMatches || !archMatches || !libcMatches) {
59+
const targetLabel = [targetOs, targetArch, targetLibc].filter(Boolean).join('/')
60+
const hostLabel = [process.platform, process.arch, hostLibc].filter(Boolean).join('/')
61+
console.log(`Skipping ${binName} -v: host ${hostLabel} cannot execute target ${targetLabel}`)
62+
process.exit(0)
63+
}
64+
65+
const distLinkPath = path.resolve('dist')
66+
const distLinkTarget = path.join('..', 'exe', 'dist')
67+
let distLinkCreated = false
68+
// Remove a prior symlink from an aborted run so cleanup ownership is always
69+
// well-defined. A real dist/ directory (unlikely in a platform package, but
70+
// possible during development) is preserved — we treat it as external and
71+
// skip cleanup.
72+
try {
73+
if (fs.lstatSync(distLinkPath).isSymbolicLink()) fs.unlinkSync(distLinkPath)
74+
} catch (err) {
75+
if (err.code !== 'ENOENT') throw err
76+
}
77+
const distPreexists = fs.existsSync(distLinkPath)
78+
79+
process.on('exit', () => {
80+
if (!distLinkCreated) return
81+
try { fs.unlinkSync(distLinkPath) } catch { /* nothing to clean up */ }
82+
})
83+
84+
// Relocation check: before staging dist/, confirm the binary reads its bundle
85+
// path from process.execPath at runtime and not from a build-time constant.
86+
// A pnpm.cjs shim that accidentally captured __filename or a cwd-relative
87+
// path during packaging would keep working on the build machine but break on
88+
// every end-user machine. Asserting the error references the *runtime* cwd
89+
// catches that regression here instead of after publish.
90+
//
91+
// Skipped when a real dist/ is already present (developer layout); in that
92+
// case we can't distinguish a correctly-resolved dist from a hardcoded one.
93+
if (!distPreexists) {
94+
const expectedRuntimeDist = path.join(fs.realpathSync(process.cwd()), 'dist', 'pnpm.mjs')
95+
let sansDistStdout
96+
try {
97+
sansDistStdout = execFileSync(`./${binName}`, ['-v'], {
98+
encoding: 'utf8',
99+
timeout: 30_000,
100+
stdio: ['ignore', 'pipe', 'pipe'],
101+
})
102+
} catch (err) {
103+
const stderr = String(err?.stderr ?? '')
104+
// Expected: binary tried to require the runtime dist path and failed
105+
// because it isn't there. Anything else (a spawn error, crash signal,
106+
// timeout) is NOT evidence of a non-relocatable pnpm.cjs — it's an
107+
// unrelated failure that would hide the regression we actually care
108+
// about. Surface it with the raw diagnostic so the operator can tell
109+
// which one they're looking at.
110+
if (!stderr.includes(expectedRuntimeDist)) {
111+
const status = err?.status ?? 'none'
112+
const signal = err?.signal ?? 'none'
113+
const code = err?.code ?? 'none'
114+
console.error(`Error: ${binName} -v without dist/ did not fail with a missing-runtime-dist error. Either pnpm.cjs regressed to a non-relocatable form, or the binary failed for an unrelated reason. status=${status} signal=${signal} code=${code}\nstderr:\n${stderr}`)
115+
process.exit(1)
116+
}
117+
}
118+
if (sansDistStdout !== undefined) {
119+
console.error(`Error: ${binName} -v unexpectedly succeeded without dist/ alongside the binary. Output: ${JSON.stringify(sansDistStdout.trim())}. pnpm.cjs is loading a bundle from somewhere other than dirname(process.execPath); the published binary would ignore the dist shipped in @pnpm/exe.`)
120+
process.exit(1)
121+
}
122+
}
123+
124+
// Only stage the symlink when nothing's there already — symlink-dir will
125+
// atomically rename away any existing dir/file, which would silently drop a
126+
// developer's staged dist/ directory.
127+
if (!distPreexists) {
128+
try {
129+
symlinkDirSync(distLinkTarget, distLinkPath)
130+
distLinkCreated = true
131+
} catch (err) {
132+
console.error(`Error: could not stage dist/ symlink: ${String(err)}`)
133+
process.exit(1)
134+
}
135+
}
136+
137+
let stdout
138+
try {
139+
stdout = execFileSync(`./${binName}`, ['-v'], { encoding: 'utf8', timeout: 30_000 }).trim()
140+
} catch (err) {
141+
console.error(`Error: ${binName} -v failed: ${String(err)}`)
142+
process.exit(1)
143+
}
144+
145+
// Accept SemVer 2 with optional prerelease and build-metadata suffixes so a
146+
// future `11.0.0-rc.4+sha.<hash>` release doesn't fail this gate spuriously.
147+
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(stdout)) {
148+
console.error(`Error: ${binName} -v produced unexpected output: ${JSON.stringify(stdout)}`)
149+
process.exit(1)
150+
}
151+
152+
console.log(`${binName} -v OK (${stdout})`)

0 commit comments

Comments
 (0)