Skip to content

fix(sea): match blob generator to target Node version#247

Merged
robertsLando merged 15 commits intomainfrom
fix/sea-macos-signing
Apr 22, 2026
Merged

fix(sea): match blob generator to target Node version#247
robertsLando merged 15 commits intomainfrom
fix/sea-macos-signing

Conversation

@robertsLando
Copy link
Copy Markdown
Member

@robertsLando robertsLando commented Apr 17, 2026

Summary

SEA binaries built via pkg --sea . crashed at startup on a range of host/target Node.js patch-release combinations, reported on macOS arm64 + Linux arm64 + Windows x64 in discussion #236 (minimal NestJS repro: julianpoemp/yao-pkg-nestjs-sea-example). Symptoms:

EXC_BAD_ACCESS inside node::BlobDeserializer<SeaDeserializer>::ReadArithmetic<unsigned long>
  → node::sea::FindSingleExecutableResource()
  → node::sea::FixupArgsForSEA(int, char**)
  → node::Start(int, char**)

Root cause

Node.js changes the SEA prep-blob header layout between patch releases within the same major line — not just between majors (22.19/22.20 added fields, 25.8 added exec_argv_extension, etc.). pkg's old pickBlobGeneratorBinary matched host-to-target only at the major level, so any Node 22.x on the host would be used verbatim to generate a blob that the downloaded Node 22.22.2 target binary (the one pkg injects into) couldn't deserialize — segfault at FindSingleExecutableResource.

Reproduced the exact same crash on Linux by iterating host Node via nvm:

host Node target = node22-linux-x64
v22.12.0 – v22.18.0 exit 139 (SIGSEGV, matching frame-for-frame)
v22.20.0+ works

Fix

Rewrote pickBlobGeneratorBinary in lib/sea.ts to:

  1. Prefer a downloaded target binary whose platform+arch matches the host. It's the exact same version pkg will inject into and it's runnable locally — so generator and reader are byte-for-byte version-matched by construction.
  2. Fall back to process.execPath only for true cross-platform builds (e.g. Linux host → Windows target) where no downloaded target is executable on the host. Same-major skew is still theoretically possible there but there's no alternative — pkg can't fabricate a host-runnable binary of the exact target version for a foreign platform.

Secondary cleanups in the same PR

  • Skip patchMachOExecutable for SEA binaries — for SEA, __LINKEDIT already sits after NODE_SEA at the file tail, so the patch is a no-op. Per the Node.js SEA docs, SEA binaries on macOS just need codesign --sign -.
  • Drop the redundant codesign --remove-signature pre-strip in bake(). signMacOSIfNeededcodesign -f --sign - already force-replaces the existing signature after postject injection.
  • Collapse signMacOS{,Sea}IfNeeded behind an isSea flag.

Test plan

  • Reproduced the crash on Linux x64 with host Node 22.12.0/22.15.0/22.17.1/22.18.0 — same gdb stack trace as the reporter's lldb trace on macOS arm64.
  • After the fix, every one of those host versions produces a working binary that serves GET /api{"message":"Hello API"}.
  • Cross-platform fallback verified (Linux x64 host → macos-arm64 target via ldid).
  • Multi-target build verified (-t node22-linux-x64,node22-macos-arm64,node22-win-x64 from a Linux x64 host — all three artifacts produced, linux-x64 runs cleanly).
  • All SEA tests pass on Linux x64.
  • yarn lint clean.
  • macOS arm64 host verification — @julianpoemp to confirm against the repro.

Refs: discussion #236

robertsLando and others added 4 commits April 17, 2026 10:48
The classic pkg flow appends the VFS payload to the end of the binary
and uses patchMachOExecutable to extend __LINKEDIT so codesign hashes
cover the payload. For SEA, postject creates a dedicated NODE_SEA
segment — patching __LINKEDIT on top of that corrupts the SEA blob on
macOS arm64 once the payload is non-trivial (reported for NestJS apps
in discussion #236, 9 MB enhanced-SEA blob → segfault at
LoadSingleExecutableApplication).

Split the ad-hoc-sign path used by SEA into signMacOSSeaIfNeeded,
which matches the Node.js SEA docs: just codesign, no LINKEDIT patch.
signMacOSIfNeeded still patches for the non-SEA producer path.

Refs: #236
Per review on #247 — a dedicated second function was more code than the
branch deserved. Replace it with a single isSea parameter that skips the
__LINKEDIT patch when true. Call sites read clearer too.
codesign -f --sign - in signMacOSIfNeeded already force-replaces the
existing signature after postject injection, so stripping it first is
redundant. On macOS Tahoe 26.x with non-trivial SEA payloads (NestJS,
~9 MB blob) the pre-strip leaves the Mach-O in a state that crashes
Node at load time with "v8::ToLocalChecked Empty MaybeLocal" inside
node::sea::LoadSingleExecutableApplication.

Cross-host Linux-to-macOS builds never pre-stripped and have been
confirmed working on the same payload by the reporter, which pins the
regression to this step.

Refs: discussion #236

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@robertsLando
Copy link
Copy Markdown
Member Author

Follow-up: root cause identified, additional fix pushed (a24854e)

After the first two commits didn't fix the macOS segfault for @julianpoemp, I re-ran the investigation end-to-end on a clean Linux x64 host using the NestJS repro:

Binaries I cross-compiled from Linux (PR branch at HEAD before this commit) — all confirmed working by the reporter:

Target Verified by
linux-x64 me (native) + reporter
linux-arm64 me (Docker+QEMU) + reporter (Ubuntu ARM VM)
macos-arm64 reporter (macOS Tahoe 26.4.1)
win-x64 reporter (Windows 11 x64)

When the reporter built the same targets on their macOS arm64 host with this PR applied, all four targets (incl. Linux/Windows cross-builds) segfaulted at node::sea::LoadSingleExecutableApplication with v8::ToLocalChecked Empty MaybeLocal. Since the blob generator, postject invocation, and sign step all run on the build host, this pinned the regression to the one pipeline step that differs between hosts.

The culprit

bake() in lib/sea.ts called codesign --remove-signature on the downloaded Node binary before postject injection, but only when process.platform === 'darwin'. On Linux/Windows hosts we skipped that call entirely — and those builds work. On macOS Tahoe (26.x) the pre-strip leaves the Mach-O in a state that survives postject but produces a SEA blob that node::sea::LoadSingleExecutableApplication can't deserialize.

The fix

a24854e drops the pre-strip. signMacOSIfNeededcodesign -f --sign - already force-replaces any existing signature after postject injection, so the pre-strip was redundant to begin with — the Node.js SEA docs recommend it, but with postject's current Mach-O injection path on recent macOS it's actively harmful for non-trivial payloads.

Net PR state

  1. (existing) 3d932d0 — skip patchMachOExecutable __LINKEDIT extension for SEA. Still needed: postject creates a proper NODE_SEA LC_SEGMENT_64, patching __LINKEDIT on top corrupts the layout.
  2. (existing) 2952a91 — collapse signMacOS{,Sea}IfNeeded behind isSea flag.
  3. (new) a24854e — drop redundant codesign --remove-signature pre-strip. The actual fix for the macOS-host regression.

Waiting for @julianpoemp to re-test a macOS-host build of the NestJS repro against the updated branch to fully close the loop.

The SEA prep-blob layout changes between Node patch releases within the
same major line (e.g. 22.19/22.20 added fields that break the 22.22
reader), so generating the blob with a host Node whose patch release
differs from the downloaded target binary crashes
node::sea::FindSingleExecutableResource at startup with EXC_BAD_ACCESS
inside BlobDeserializer::ReadArithmetic<unsigned long>.

Fix: pick a downloaded target binary whose platform+arch matches the
host. That binary is the exact version we inject the blob into, so
generator and reader are byte-for-byte version-matched. Fall back to
process.execPath only when no target is runnable on the host (true
cross-platform builds, e.g. Linux → Windows).

Reproduced locally: host Node 22.12.0/22.15.0/22.17.1/22.18.0 →
target node22-linux-x64 (downloaded 22.22.2) crashed with exit 139
and a stack trace matching the one reported by @julianpoemp on
macOS arm64 in discussion #236. After this fix, every one of those
host versions produces a working binary.

Refs: discussion #236

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@robertsLando robertsLando changed the title fix(sea): skip mach-O __LINKEDIT patch for SEA binaries fix(sea): match blob generator to target Node version Apr 21, 2026
robertsLando and others added 4 commits April 21, 2026 15:07
pickBlobGeneratorBinary had an inline `process.platform === 'darwin' ?
'macos' : ...` branch and read `process.arch` directly. pkg-fetch's
`system` module already exports `hostPlatform` / `hostArch` as the
"fancy" values (macos/win/linux) that target descriptors use, and the
rest of lib/ consumes them — same translation, one source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier commits in this PR (3d932d0 skip __LINKEDIT patch, a24854e drop
pre-strip) landed while we believed the macOS arm64 crash in discussion
#236 was caused by those steps corrupting the SEA blob. The actual
cause turned out to be host/target Node patch-version skew in the blob
generator (fixed in 6f456e2), not either of these.

The code changes are still valid cleanups — patchMachOExecutable is a
no-op on SEA binaries because __LINKEDIT already sits at the file tail,
and the codesign pre-strip is redundant because codesign -f --sign -
replaces any existing signature — but the inline comments claimed they
were fixing corruption bugs that don't exist. Replaced those comments
with accurate no-op / redundancy reasoning so future maintainers
chasing mach-O or codesign bugs aren't led astray.

Code behaviour is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When no target matches the host platform/arch (e.g. Linux host building
only a macos-arm64 binary), the previous fallback to process.execPath
re-opened the same SEA blob version-skew footgun that 6f456e2 closed
for the host-matching case: if the user's local node is on a different
patch release than the downloaded target binary, the generated blob
crashes node::sea::FindSingleExecutableResource at startup with
EXC_BAD_ACCESS (discussion #236).

Now we download a host-platform/arch node binary at the same node range
as the targets and use it purely as the blob generator — byte-for-byte
version-matched with the reader baked into every target. process.execPath
is only used as a last-resort fallback if the download itself fails
(unsupported host platform such as alpine/musl, no network, etc.), and
that path now emits a warning pointing at the exact symptom.

Verified:
- Linux host 22.12.0 → macos-arm64-only target: previously would silently
  bake a skewed blob; now logs "No target matches host" + downloads
  node-v22.22.2-linux-x64 for generation. NODE_SEA filesize matches the
  known-good build byte-for-byte.
- Host-matching and mixed-target flows still take the fast path with no
  extra download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the silent process.execPath fallback with wasReported when
the host node version differs from the resolved target version — the
old fallback silently reintroduced the very EXC_BAD_ACCESS crash this
flow is meant to prevent (discussion #236).

Add resolveTargetNodeVersion as the single source of truth for the
resolved target version (reused by getNodejsExecutable): queries the
user-provided binary via --version for opts.nodePath, uses
process.version for opts.useLocalNode, and falls back to the public
registry lookup otherwise.

Extract pickMatchingHostTargetIndex as a testable helper and cover it
with test-00-sea-picker (exact match, no match, cross-arch, alpine
host, empty targets). Drop the now-unused
removeMachOExecutableSignature from lib/mach-o.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@robertsLando robertsLando requested a review from Copilot April 21, 2026 13:55
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes SEA startup crashes caused by generating SEA prep blobs with a Node binary whose patch-version header layout doesn’t match the injected target Node binary, and streamlines macOS signing behavior for SEA outputs.

Changes:

  • Reworked SEA blob-generator selection to prefer host-runnable, version-matched downloaded target binaries and add safer fallbacks.
  • Adjusted macOS signing flow for SEA builds (skip Mach-O __LINKEDIT patching for SEA; remove redundant signature stripping).
  • Added a focused unit test for the host/target matching helper used by the new picker logic.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
test/test-00-sea-picker/main.js Adds a unit test for host platform/arch target matching used by SEA blob-generator selection.
lib/sea.ts Implements new blob-generator selection strategy; refactors macOS signing to optionally skip Mach-O patching for SEA.
lib/mach-o.ts Removes now-unused Mach-O signature removal helper and narrows exports.

Comment thread lib/sea.ts Outdated
Comment thread test/test-00-sea-picker/main.js
robertsLando and others added 6 commits April 22, 2026 16:47
Address copilot review on #247:

- pickBlobGeneratorBinary: resolve target's concrete patch version up
  front via resolveTargetNodeVersion, pass it as nodeRange for the
  host-platform download so host and target resolve to the same patch
  (unofficial builds / arch-specific availability could otherwise diverge
  and reintroduce the discussion #236 SEA header skew crash).
- Short-circuit to process.execPath when process.version already matches
  the resolved target version (skip the download).
- Drop user-supplied nodePath / useLocalNode when invoking the host
  download so neither can short-circuit the pinned fetch.
- test-00-sea-picker: rewrite the "cross-platform" case comment to
  describe the real invariant (Linux host, no Linux target), since the
  target list contains both macos and win.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getNodeVersion's 3-part branch returned bare `22.22.2` while every other
producer (nodejs.org/dist, process.version, `node --version`) returns
`v22.22.2`. Two concrete consequences:

  1. pickBlobGeneratorBinary's `targetVersion === process.version` check
     would false-negative when the user pins the patch (e.g. node22.22.2
     target), dropping into the download path unnecessarily.
  2. getNodejsExecutable's archive filename was
     `node-22.22.2-linux-x64.tar.gz` instead of `node-v22.22.2-...`, so
     the download would 404 for any patch-pinned range.

Fix: always return v-prefixed; strip the prefix in the one place that
needs a bare semver (the cross-platform `nodeRange` construction, which
is re-parsed by getNodeVersion's regex).

Also wire test-00-sea-picker into the npmTests list for parity with
test-00-sea — the picker test is a pure unit test that runs in every
flavor except only-npm; adding it makes both SEA tests behave the same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add NodeVersion (`v${number}.${number}.${number}`), NodeOs, NodeArch,
  NodeRange (`node${string}`) template-literal/union types encoding the
  invariants the runtime already enforces.
- Route getNodeOs / getNodeArch / getNodeVersion / resolveTargetNodeVersion
  through the narrow types so downstream callers (filename construction,
  `=== process.version` check, cross-platform `nodeRange` build) carry
  compile-time guarantees instead of passing bare strings.
- Simplify getNodeVersion's version search: drop the `.map → tuple →
  .find` acrobatics and inline parameter type annotations in favour of a
  single typed `.find` on the parsed response.
- Add explicit `Promise<void>` / `Promise<string>` / `number` return
  annotations on getNodejsExecutable, bake, signMacOSIfNeeded,
  assertHostSeaNodeVersion, generateSeaBlob, extract.
- Move the 30-line "Strategy" JSDoc back onto pickBlobGeneratorBinary
  (it had drifted above the pickMatchingHostTargetIndex helper after the
  helper extraction).

No behaviour change — types track existing runtime invariants; the only
functional adjustment is swapping `targetVersion.replace(/^v/, '')` for
`targetVersion.slice(1)` in the cross-platform `nodeRange` construction,
safe because NodeVersion guarantees the leading `v`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NodeVersion, NodeRange, NodeOs, NodeArch (+ their backing NODE_OSES /
NODE_ARCHS const tuples) describe invariants that aren't sea-specific —
`lib/index.ts` already builds `node<major>` strings by hand,
`lib/fabricator.ts` logs `${arch}` tuples, and `compress_type.ts`
interpolates `process.version` into messages. Hoisting them next to
`platform` in `lib/types.ts` makes them available for reuse without
widening the NodeTarget signature (out of scope here — keeping
downstream callers untouched).

No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@robertsLando robertsLando merged commit ad2a336 into main Apr 22, 2026
26 checks passed
@robertsLando robertsLando deleted the fix/sea-macos-signing branch April 22, 2026 15:56
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