Skip to content

fix: generate SEA code cache with the final signed binary (#28)#29

Merged
BYK merged 5 commits into
mainfrom
fix/sea-code-cache-rejected
Jun 10, 2026
Merged

fix: generate SEA code cache with the final signed binary (#28)#29
BYK merged 5 commits into
mainfrom
fix/sea-code-cache-rejected

Conversation

@BYK

@BYK BYK commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Fixes #28.

Problem

Single-platform SEA binaries built with useCodeCache emit Warning: Code cache data rejected. at startup on macOS (Apple Silicon), losing the ~15% startup win the cache is supposed to provide.

Root cause (proven)

V8 only accepts a code cache when the consuming process has the same FlagList::Hash() as the process that generated it. Verified directly with vm.compileFunction({ produceCachedData }) → consuming under a single differing V8 flag flips cachedDataRejected to true (the exact path behind embedding.js's warning). The compile APIs/ScriptOrigin already match (host-defined options are not part of the cache key — per Node's own source comment).

The real gap: fossilize generated the cache with the raw signed official Node download (targetNodeBinary), but consumed it with the unsigned copy (getNodeBinaryFromCache calls unsign() on darwin/win) that is re-signed after injection. On Apple Silicon the hardened-runtime/JIT entitlements (allow-jit, disable-executable-page-protection) change V8's code-memory strategy → different flag-hash → rejection. Linux never reproduces (plain copyFile, identical flags).

Fix

  • Base SEA blob is now always useCodeCache: false.
  • The host platform's code-cache blob is generated in place: the prepared (stripped) host binary is signed exactly like the final executable (signBinary() helper, shared with the final signing), runs --experimental-sea-config, then is unsigned again (unsignBinaryInPlace()) so postject injects into a cleanly unsigned binary; the final signature is applied after inject + hole-punch. Generator and consumer therefore share an identical flag-hash. No throwaway binary copy.
  • The two signing operations are fundamental and can't be deduplicated (the cache must be produced in the signed state before inject; the final signature must cover the post-inject blob).
  • New --no-code-cache escape hatch; cache generation is best-effort (falls back to no-cache, non-fatal).

Real test on macOS CI

The smoke test now runs on the whole matrix — ubuntu-latest, windows-latest, and macos-latest (arm64), the platform where #28 reproduced. It asserts both that the cache is embedded (with code cache in the build log) and accepted at runtime (no Code cache data rejected in stderr), with correct output. All green.

Notes

  • Works for real-key signing too: signBinary() applies the identical signature to the cache-gen pass and the final binary, so their flag-hashes match regardless of ad-hoc vs full identity. (Not CI-tested — public CI has no Apple certs.)
  • Two gotchas surfaced & documented in .lore.md: postject strips an existing Mach-O signature during inject (doesn't require pre-unsigned); and getNodeBinary writes darwin/win binaries at 0o644 (no execute bit), so running one for cache-gen needs an explicit chmod.

BYK added 5 commits June 10, 2026 21:56
The V8 code cache embedded in single-platform SEA binaries was rejected
at runtime on macOS ("Code cache data rejected"). V8 only accepts a code
cache when the consuming process has the same FlagList::Hash() as the
process that generated it (verified: any differing V8 flag flips
cachedDataRejected to true via vm.compileFunction).

fossilize generated the cache with the raw, *signed* official Node
download (targetNodeBinary) but consumed it with the *unsigned* + later
re-signed copy. On Apple Silicon the hardened-runtime/JIT entitlements
(allow-jit, disable-executable-page-protection) change V8's code-memory
strategy, so the flag-hashes differ and the cache is rejected. Linux
never reproduced because the consumer is a plain copy with identical
flags.

Generate the host platform's code-cache blob from a throwaway copy of
the prepared (unsigned, stripped) host binary that is signed exactly
like the final executable, so generator and consumer share a flag-hash.
The base blob is now always cache-free; the host blob is produced lazily
in createBinaryForPlatform. Signing is extracted into a reusable
signBinary() helper used for both the cache seed and the final binary.

Also adds a --no-code-cache escape hatch and a cross-OS CI assertion
(including the arm64 macos-latest runner) that the cache is both
embedded and accepted at runtime.
postject strips an existing Mach-O code signature during injection
(verified: signed 108MB binary -> inject succeeds -> output is unsigned
and ~1MB smaller), so the cache-generation binary does not need to be a
separate unsigned copy.

Sign the prepared host binary in place to get the final flag-state,
generate the code cache with it, then inject (postject removes that
signature) and apply the final signature after inject + hole-punch.
This drops the ~100MB throwaway "cacheseed" copy and its bookkeeping.

The two sign operations remain fundamental: the cache must be produced
in the signed state before injection, and the final signature must
cover the post-inject blob.
The previous commit ran the host binary directly to generate the cache,
but on darwin/win getNodeBinary writes the (unsigned) binary via
fs.writeFile at 0o644 — no execute bit — so the run failed with EACCES
and the cache silently fell back to disabled.

chmod the binary to 0o755 before running it, and instead of relying on
postject to strip the temporary signature, explicitly unsign it in place
(new node-util.unsignBinaryInPlace) so injection still targets a cleanly
unsigned binary (Node's documented SEA flow). Still no throwaway copy.
Address issues from self-review:

- Add missing `await` on `fs.chmod` after postject injection (pre-existing
  race with holePunch/sign that followed immediately).
- Preserve file permissions in `unsignBinaryInPlace` — `fs.writeFile`
  defaults to 0o644, losing the execute bit set earlier.
- Validate APPLE_TEAM_ID/APPLE_CERT_PATH/APPLE_CERT_PASSWORD before the
  platform loop when `sign=true` targets darwin, so the user gets a clear
  error instead of a misleading "could not generate code cache" warning
  before the real crash.
- Update stale "throwaway copy" comment in `signBinary` docblock to
  reflect the in-place approach.
@BYK BYK merged commit f7e68b3 into main Jun 10, 2026
5 checks passed
@BYK BYK deleted the fix/sea-code-cache-rejected branch June 10, 2026 22:55
@craft-deployer craft-deployer Bot mentioned this pull request Jun 10, 2026
2 tasks
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.

SEA code cache rejected at runtime despite same Node binary generating and consuming it

1 participant