fix: generate SEA code cache with the final signed binary (#28)#29
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #28.
Problem
Single-platform SEA binaries built with
useCodeCacheemitWarning: 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 withvm.compileFunction({ produceCachedData })→ consuming under a single differing V8 flag flipscachedDataRejectedtotrue(the exact path behind embedding.js's warning). The compile APIs/ScriptOriginalready 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 (getNodeBinaryFromCachecallsunsign()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 (plaincopyFile, identical flags).Fix
useCodeCache: false.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.--no-code-cacheescape 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, andmacos-latest(arm64), the platform where #28 reproduced. It asserts both that the cache is embedded (with code cachein the build log) and accepted at runtime (noCode cache data rejectedin stderr), with correct output. All green.Notes
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.).lore.md: postject strips an existing Mach-O signature during inject (doesn't require pre-unsigned); andgetNodeBinarywrites darwin/win binaries at0o644(no execute bit), so running one for cache-gen needs an explicitchmod.