Skip to content

feat: --ignore-node-options to keep the embedded V8 code cache valid#31

Merged
BYK merged 1 commit into
mainfrom
feat/ignore-node-options
Jun 11, 2026
Merged

feat: --ignore-node-options to keep the embedded V8 code cache valid#31
BYK merged 1 commit into
mainfrom
feat/ignore-node-options

Conversation

@BYK

@BYK BYK commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Problem

A Node SEA built with an embedded V8 code cache (useCodeCache) emits
Warning: Code cache data rejected at startup — and silently falls back to
recompiling the main script — whenever the user has V8 flags in NODE_OPTIONS
(e.g. NODE_OPTIONS=--max-old-space-size=8192, a very common Claude Code
setup). Those flags change V8's FlagList::Hash() at runtime, so it no longer
matches the build-time default the cache was generated against, and V8 rejects
the cache.

This was diagnosed from a real report: the same binary was accepted on a
machine with no NODE_OPTIONS and rejected on one with V8 flags in
NODE_OPTIONS; env -u NODE_OPTIONS <binary> made the warning disappear.

Fix

New opt-in --ignore-node-options flag. It makes the produced binary ignore
NODE_OPTIONS — the equivalent of building Node with
./configure --without-node-options, achieved via an in-place binary patch
(same model as the existing hole-punch step):

  • Renames the single .rodata NODE_OPTIONS C-string that the C++ bootstrap
    feeds to SafeGetenv() to a same-length inert name (NODE_OPTIQNS), so
    getenv() returns null and no V8 flags are applied from NODE_OPTIONS.
  • The runtime flag-hash then matches the build-time default → the embedded
    code cache is accepted (kept; no --no-code-cache tradeoff).
  • process.env is untouched, so process.env.NODE_OPTIONS is still visible to
    the app and still inherited by any child process it spawns.

Applied after strip, before code-cache generation and signing, so both the
generated cache and the final signature cover the patched bytes.

Robust, format-agnostic target selection

A Node binary holds ~10 copies of the literal NODE_OPTIONS (help/error text,
JS bootstrap, and copies inside the checksummed V8 startup snapshot which
must not be touched). The patch targets the .rodata lookup constant by
selecting the occurrence that is null-bounded (\0NODE_OPTIONS\0) and
surrounded on both sides by C-string bytes (NUL/printable-ASCII). This excludes
the JS/error copies and the snapshot copies (which sit next to non-printable
serialized bytes). It throws if no candidate is found so a future Node
layout change fails the build loudly instead of shipping a binary whose cache
would be rejected.

Verified to patch exactly one offset on official node-v22.14.0
darwin-arm64, linux-x64, linux-arm64 and win-x64 builds.

Verification

  • Reproduced Code cache data rejected on a real linux-x64 code-cache SEA with
    NODE_OPTIONS=--stack-trace-limit=99; after the patch the warning is gone and
    the cache is still embedded.
  • New CI step (Test --ignore-node-options keeps the code cache valid under NODE_OPTIONS) builds with the flag and asserts no rejection while running
    under NODE_OPTIONS.
  • process.env.NODE_OPTIONS confirmed still present (children still inherit it).

A Node SEA with an embedded V8 code cache (`useCodeCache`) emits
`Warning: Code cache data rejected` and falls back to recompiling whenever
the user has V8 flags in `NODE_OPTIONS` (e.g. `--max-old-space-size`, common
for Claude Code). Those flags change V8's `FlagList::Hash()` at runtime, so it
no longer matches the build-time default the cache was generated with.

`--ignore-node-options` makes the produced binary ignore `NODE_OPTIONS`, the
equivalent of building Node with `./configure --without-node-options` but via
an in-place binary patch (like the hole-punch step): it renames the single
`.rodata` `NODE_OPTIONS` C-string that the C++ bootstrap feeds to
`SafeGetenv()` to a same-length inert name, so `getenv()` returns null and no
flags are applied. The runtime flag-hash then matches the build-time default
and the embedded code cache is accepted.

`process.env` is untouched, so `process.env.NODE_OPTIONS` is still visible to
the app and still inherited by any child process it spawns.

The target is selected in a format-agnostic way (Mach-O/ELF/PE): the
`NODE_OPTIONS` occurrence that is null-bounded and surrounded by other
C-strings. This excludes the JS/help/error copies and, critically, the copies
inside the checksummed V8 startup snapshot (which must not be modified).
Verified to patch exactly one offset on node-v22.14.0 darwin-arm64, linux-x64,
linux-arm64 and win-x64. Throws if no candidate is found so a future Node
layout change fails the build loudly.
@BYK BYK merged commit 58e2112 into main Jun 11, 2026
5 checks passed
@BYK BYK deleted the feat/ignore-node-options branch June 11, 2026 17:53
@craft-deployer craft-deployer Bot mentioned this pull request Jun 11, 2026
2 tasks
BYK added a commit to BYK/loreai that referenced this pull request Jun 11, 2026
…de cache valid (#710)

## Problem

The standalone `lore` binary emits `Warning: Code cache data rejected`
at
startup — and silently recompiles its main script — for any user who has
V8
flags in `NODE_OPTIONS` (e.g. `NODE_OPTIONS=--max-old-space-size=8192`,
a very
common Claude Code setup).

The binary is a Node SEA with an embedded V8 code cache generated on CI
with
default flags. V8 flags from `NODE_OPTIONS` change `FlagList::Hash()` at
runtime, so the runtime hash no longer matches the build-time default
and V8
rejects the cache.

Diagnosed from a real report (Onur): same binary **accepted** on a
machine with
no `NODE_OPTIONS`, **rejected** on one with `NODE_OPTIONS` V8 flags;
`env -u NODE_OPTIONS lore` made the warning disappear. (It was not the
chip /
macOS version — both were Apple-silicon, same macOS.)

## Fix

- Bump `fossilize` to `^0.10.0`.
- Pass `ignoreNodeOptions: true` to `fossilize()` in `runFossilize()`.

fossilize then patches the binary to ignore `NODE_OPTIONS` (equivalent
to
building Node with `--without-node-options`): it renames the `.rodata`
`NODE_OPTIONS` lookup constant so `getenv()` returns null and no V8
flags are
applied. The runtime flag-hash matches the build-time default → the
embedded
code cache is **accepted** (kept; not disabled).

`process.env` is untouched, so `process.env.NODE_OPTIONS` stays set and
the
agent lore launches (e.g. claude) still inherits the user's flags.

Upstream: BYK/fossilize#31 (released in fossilize 0.10.0).

## Verification

- Reproduced + fixed on a real linux-x64 code-cache SEA; fossilize CI
proves
  acceptance under `NODE_OPTIONS` on macOS/Linux/Windows.
- Full typecheck + Biome clean.
BYK added a commit to getsentry/cli that referenced this pull request Jun 11, 2026
… cache valid (#1092)

## Problem

The standalone `sentry` binary is a Node SEA with an embedded V8 code
cache.
When a user has V8 flags in `NODE_OPTIONS` (e.g.
`NODE_OPTIONS=--max-old-space-size=8192`, common in JS toolchains),
those flags
change V8's `FlagList::Hash()` at runtime, so V8 rejects the build-time
code
cache and prints `Warning: Code cache data rejected` (then recompiles).

## Fix

- Bump `fossilize` `^0.8.1` → `^0.10.1`.
- Pass `--ignore-node-options` to the fossilize invocation in
`script/build.ts`.

fossilize patches the binary to ignore `NODE_OPTIONS` (equivalent to
building
Node with `--without-node-options`): it renames the `.rodata`
`NODE_OPTIONS`
lookup constant so `getenv()` returns null and no V8 flags are applied.
The
runtime flag-hash then matches the build-time default and the embedded
code
cache is accepted (kept; not disabled).

`process.env` is untouched, so `process.env.NODE_OPTIONS` stays set and
any
child process still inherits the user's flags.

fossilize 0.10.x details: BYK/fossilize#31 (the feature) and
BYK/fossilize#33
(NUL-termination selector fix for win-x64 / node 24 — relevant here
since
`NODE_VERSION` is `lts` = 24.x).

## Notes

- Only host-platform (linux-x64, built on ubuntu) currently embeds a
code
  cache; the patch is applied to all targets and is a no-op effect for
cross-compiled ones (just renames the constant), so it's safe
everywhere.
- Typecheck + Biome lint clean; lockfile change is fossilize-only.
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.

1 participant