Repository navigation
Refuse native addons built for the standard V8 ABI on pointer-compression Node runtimes, instead of crashing on first use - #3140
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces a native addon guard mechanism to prevent standard-V8-ABI addons from being loaded on Node.js runtimes with V8 pointer compression enabled, which would otherwise cause a segmentation fault. It implements a custom ELF64 parser in utility/nativeAddonAbi.ts to scan for undefined V8 C++ symbols in .node binaries, wraps process.dlopen via server/threads/nativeAddonGuard.ts in both the main thread and worker threads, and updates createUwsServer to perform early compatibility checks on the uWebSockets.js binary. Comprehensive unit tests and design documentation are also added. There are no review comments, and I have no feedback to provide.
…imes instead of letting them crash the process V8 pointer compression changes V8's C++ ABI without changing NODE_MODULE_VERSION, so an addon that links V8's C++ API for the standard ABI loads and then SIGSEGVs on first use. On a runtime with v8_enable_pointer_compression, process.dlopen is now wrapped first in every thread (bin/harper.ts and index.ts on the main thread, the first --require of every worker) and a .node loads only when its dynamic symbols have no V8 C++ references, its package carries .pointer-compression-build, or node-gyp built it under a pointer-compression Node. Otherwise it is refused with an IncompatibleNativeAddonError (code ERR_DLOPEN_FAILED) naming the package and the fixes. Standard runtimes are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011m4iSgKNWY8toCDWmuoh9a Dispatch-Task: harper-pc-native-addon-guard
Require 24-byte .dynsym entries and a non-empty table, a binary strictly newer than its regular-file config.gypi, and an admission identity taken from the descriptor that was scanned; leave an unreadable file to dlopen. Non-ELF files now admit only after the marker and build rules, so the uWS pre-check (uWS always links V8's C++ API) still demands evidence on non-ELF platforms. Load the guard first in launchServiceScripts/launchHarperDB.js, and cover the execArgv install path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011m4iSgKNWY8toCDWmuoh9a Dispatch-Task: harper-pc-native-addon-guard
…adable config.gypi as no evidence The uWS pre-check now skips a binary that does not exist so uws.js reports the unsupported Node.js version itself; a path that is not a regular file is left to dlopen. Pin fixture mtimes to whole seconds so the equal-mtime and config.gypi-directory tests exercise the rule they name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011m4iSgKNWY8toCDWmuoh9a Dispatch-Task: harper-pc-native-addon-guard
A FIFO named config.gypi would block the synchronous read inside dlopen, and a directory at the uWS binary path is left to uws.js like a missing one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011m4iSgKNWY8toCDWmuoh9a Dispatch-Task: harper-pc-native-addon-guard
kriszyp
force-pushed
the
feat/pointer-compression-addon-guard
branch
from
October 12, 2026 15:18
1e0487f to
4a74245
Compare
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.
⊙ Problem
V8 pointer compression changes V8's C++ ABI (object layouts in the public headers) without changing
NODE_MODULE_VERSION. A pointer-compression (pc) Node 24 still reports ABI 137. So on the harper-pro pc image (harperfast/harper-pro:<ver>-pc), which Fabric is starting to run, an addon that links V8's C++ API (NAN or directv8::) and was built for the standard ABI passes Node's own check and loads. It then SIGSEGVs the whole process, every thread with it, the first time it is used. On a pc Node 24.18 the stockuws_linux_x64_137.nodeloads, creates anApp(), and crashes (exit 139) on its first served request.Application components can bring such prebuilds. Today's guards miss them: one checks uWS only (
server/serverHelpers/uwsServer.ts), and the other is a build-timenmscan of the image's own tree in harper-pro. Node-API addons are ABI-stable and unaffected. Refs #919.💡 Solution
On a runtime with
process.config.variables.v8_enable_pointer_compression === 1, Harper wrapsprocess.dlopenin every thread before any component code runs.process.dlopenis the call behind every.nodeload: CJSrequire,createRequire, and ESM addon import alike. A.nodeloads only when one of these holds:.dynsymhas no undefined V8 C++ symbols (_ZN2v8…/_ZNK2v8…). A pure-JS ELF64 reader does the check, so there is no binutils dependency..pointer-compression-build. The pc image swaps in pc-built uWS and @datadog/pprof binaries and marks them.build/whose node-gypconfig.gypirecords"v8_enable_pointer_compression": 1, and it is strictly newer than thatconfig.gypi. re2 and segfault-handler in the image qualify, as does anything rebuilt withnpm rebuild <pkg> --build-from-source.Anything else is refused before
dlopenruns, and the error says which file and package and how to fix it:The error carries
code: 'ERR_DLOPEN_FAILED'andstatusCode: 500, so code that already falls back to JS when an addon cannot load treats a refusal the same way. On a standard runtime nothing is installed: the cost is one property read when the module loads.Companion docs: HarperFast/documentation#725 adds a "Native Addons on Pointer-Compression Runtimes" section to the Module Loading reference and a 5.4 release-notes entry.
⚖️ Alternatives
Planning review:
Framing-Verdict: better-alternative-exists(codex). I resolved it rather than clearing it. I overruled the proposed alternative, replacing rule 3 with a per-binary "build completed" marker that the producer writes:npm rebuild <pkg> --build-from-source, the fix the error tells users to run, writesconfig.gypiand no marker, so every user source rebuild would stay refused. I adopted its narrowings:config.gypicounts only for a binary inside thatbuild/tree, not a siblingprebuilds/;config.gypi;deploy_component)npm installinside the container, and can arrive by other routes. Only the loader sees every load.NODE_MODULE_VERSIONprocess.version(ABI 137), so re2, segfault-handler and the pc uWS would all be refused. It would also protect only our image, and Node's message would point users at the wrong fix.npm_config_build_from_sourcein the imagenode_modules.nmat load time🔧 Changes
Product and architecture tour
Which native addons may load on a pointer-compression runtime
napibuilds.pointer-compression-buildin the package directorybuild/config.gypirecords pointer compression and is older than the binaryIncompatibleNativeAddonErrorabi137build (picked off-image on Node 24)dlopen)dlopendlopenbuild/, a regularconfig.gypi, strictly older than the binaryWhere the guard installs, and why first
The guard precedes every native load in each thread
The main thread's logger loads segfault-handler at module scope, and worker preloads may bring APM natives, so the guard sits ahead of both.
flowchart TD A[bin/harper.ts or index.ts] --> B[nativeAddonGuard wraps process.dlopen] B --> C[harper_logger loads segfault-handler] B --> D[startWorker] D --> E[worker: --require nativeAddonGuard] E --> F[--require threads.preloadRequire] F --> G[--import threads.preload] G --> H[threadServer and components]Each worker has its own
process, so the main thread's wrapper does not reach it, and argv--requires run in order and before every--import. These were verified on the pc Node 24.18; there athreads.preloadRequireof a stock V8 prebuild was refused in every worker.NODE_OPTIONSpreloads are out of scope: they run in the main thread before Harper's entry module.execArgvWhat a component author sees
IncompatibleNativeAddonErrornaming the file, the package and the fixes; Harper keeps servingpackage.jsonname, the four fixes,ERR_DLOPEN_FAILED,statusCode500utility/nativeAddonAbi.ts(new, Node builtins only, reusable by harper-pro's build-time script):scanUndefinedV8Symbolsreads only the ELF header, the section-header table,.dynsymand its string table. Reads are exact-length and positional, every offset is bounds-checked, each table is capped at 64 MiB, symbol entries must be 24 bytes, and either byte order is handled.checkDescriptorapplies the rules in cost order.isPointerCompressionBuildreadsconfig.gypionly when it is a regular file older than the binary, because reading a FIFO would block insidedlopen.assertNativeAddonLoadableopens the file once and takes the cache identity from that descriptor. It caches admissions only, and leaves a missing, unreadable or non-regular file todlopen.IncompatibleNativeAddonErrorbuilds its message from the nearestpackage.json.isPointerCompressionRuntimeis the single runtime predicate.server/threads/nativeAddonGuard.ts(new):process.dlopenonce per thread, keyed bySymbol.forso a second copy of the module sees the existing wrapper;DLOpenturns an explicitundefinedflags argument into 0, whichdlopen(3)rejects;nativeAddonGuardExecArgv()returns['--require', <this module>]on pc runtimes;bin/harper.ts,index.tsandlaunchServiceScripts/launchHarperDB.js. Inserver/threads/manageThreads.jsit is also first instartWorker'sexecArgv, which covers every worker (HTTP, job, replication pool) and safe mode.server/serverHelpers/uwsServer.ts: the inline marker check becomes the shared rules, plus the existingHARPER_UWS_HTTP/HARPER_UWS_UDShint.uws.jsrethrows any load error as a misleading "supports only Node.js versions" message.uws.js.server/DESIGN.md, indexed fromDESIGN.md: the invariant, the three rules, the producer obligation that comes with the marker, the install points, and the scope boundaries (NODE_OPTIONS, section-header trust).✅ Verification
End to end on a real pointer-compression Node (v24.18.0,
v8_enable_pointer_compression: 1, ABI 137), running this branch'sdist. Docker is not available on this machine, so the harper-pro image itself was not built; the rules were checked against the image per the task.requireandApp()succeed; the first request → SIGSEGV, exit 139harper runwith 2 threads: RESTPUT/GET, operationssearch_by_valueAddonProberesource requires the stock uWS prebuildIncompatibleNativeAddonError,ERR_DLOPEN_FAILEDin both workers; argon2 (Node-API) loads.nodefiles mapped in/proc/<pid>/mapsnapi, msgpackr-extractnapiand the pc-built segfault-handler. cbor-extract'sabi137build was refused (cbor-x decodes through JS)GET /Dog/1→ 200 after the restartthreads.preloadRequiremodule that requires the stock prebuildnew RE2('a(b+)').exec('xabbb')→bbb, andregisterHandlerworksTimeProfilerandheapProfilernm -D --undefined-onlyon all 27 ELF addons innode_modulesUnit tests, all passing on standard Node 26.2 and on the pc Node 24.18:
unitTests/utility/nativeAddonAbi.test.jscovers:unitTests/utility/fixtures/syntheticElf64.js:_ZN2v8/_ZNK2v8counted, defined,napi_*andnode::symbols not counted, big-endian, no section headers, truncated, stride 48 or 2^32, empty table, ELF32, and Mach-O;prebuilds/andconfig.gypi-as-a-directory refusals;unitTests/server/threads/nativeAddonGuard.test.js(withnativeAddonGuard-fixture.cjs) covers:dlopen, exact-argument and receiver forwarding, and idempotence;.nodeloader intercepted in a Worker;execArgvpath, plus a pc-only test that installs fromexecArgvalone (it passes on the pc Node and is skipped on CI).unitTests/server/threads/preloadSafeMode.test.jsnow checks the configured preload paths rather than the bare--requireflag, so it holds on a pc runtime, where the guard is always present.Full gates at
b8505e288, run locally on Node 26.2. The last commit only adds two regular-file checks, after which the guard's own test files and the pc-Node uWS refusal were re-run.test:unit:main: 6875 passing, 208 pending.test:unit:resources: 4132 passing, 54 pending.test:unit:bin: 326 passing (run becausebin/changed).test:integration:all: 2338 pass, 0 fail, 18 skipped, 6 cancelled.CI on
1e0487fe5: 51 checks success, 5 skipped. The full Node 22/24/26 integration matrix, dispatched by hand because a PR push runs Node 24 only, passed 41/41 jobs: run 38072589594.The 6 cancellations are all in
ollama-backend.test.ts: it imports Harper sources as ESM, andutility/globalSchema.tsimports JSON without an import attribute (ERR_IMPORT_ATTRIBUTE_MISSING). That path is untouched by this diff, and the suite cancels the same way on this machine without it.🤖 Generated by Claude Opus 5.5 (Claude Code); posted via @kriszyp.
🤖 Generated with Claude Code
https://claude.ai/code/session_011m4iSgKNWY8toCDWmuoh9a
Release target: v5.4 — new feature (the pointer-compression rollout harper#919/#3072 targets), ships from main only, no backport
Related PRs: none found
Complexity: complicated
Origin — the dispatch brief this PR was written from
On a pointer-compression runtime, refuse native addons built for the standard V8 ABI instead of segfaulting
Dispatch: task
harper-pc-native-addon-guard· queued by unknown · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-muse,gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi; rounds=5; full=1 @ 4a74245
Review-Attention: study ~11m (hot: manageThreads.js, nativeAddonGuard.ts; decisions: global-dlopen-wrapper, refusal-code, library-import-installs-guard, non-elf64-admitted, config-gypi-evidence, do-less-alternative) @ 4a74245