Db/mapped source lever - #90
Conversation
…ource
js2bin's default bootstrap stores the app as base64(brotli(source)) and, at every process start,
base64-decodes it, brotli-decompresses into a large Buffer, .toString()s that into a JS string, and
concatenates THAT into a template literal to prepend the cluster preamble. V8 keeps the result alive
for the whole process lifetime, because it needs the source to lazily compile functions that have not
run yet. For a ~26 MB bundle that is ~26 MB of private heap in every process, plus a boot peak of
roughly three times that while the copies coexist.
Two alternative bootstraps, each selected by an env var at build time so the default path is
untouched:
JS2BIN_UNCOMPRESSED_SOURCE=1 stores the payload as raw source. Node's builtin loader already hands
V8 an external one-byte string backed by the executable's read-only
data, so no C++ is needed -- but the placeholder must hold the source
uncompressed, so the binary grows by the source size.
JS2BIN_MAPPED_SOURCE=1 keeps the payload compressed, so the placeholder and binary do not
grow, but materialises the decompressed source to a cache file once
and maps it read-only via a new internalBinding. Survives overlay,
since the overlay only replaces the payload.
The mapped variant needs two new Node patches: node_mapped_source.cc.patch adds a js2bin binding
exposing mapFileAsExternalString (Windows CreateFileMapping/MapViewOfFile, POSIX mmap) wrapped in a
v8::String::NewExternalOneByte, and node_binding_mapped_source.cc.patch registers it in the
compile-time binding list, which NODE_BINDING_CONTEXT_AWARE_INTERNAL alone does not do on Node 22.
Both are applied after node.cc.patch, whose hunk shifts the line numbers they are offset against. If
the binding is missing or anything else fails, the bootstrap falls back to the historical in-heap
path rather than refusing to boot.
Two invariants in both bootstraps are load-bearing: never slice the source string and never
concatenate it. A sliced or concatenated string over an external parent can be flattened by V8 when
compiled, which silently reinstates the private copy. That is why the cluster preamble runs as real
code instead of being textually prepended, and why the uncompressed placeholder is padded with a
trailing line comment rather than NULs.
NOT READY TO SHIP. The mapped variant does not yet verify the mapped bytes against a digest embedded
in the signed binary, which is what would make the cache file's writability irrelevant; it also lacks
delete-on-close, ACL hardening, and hash-keyed cache invalidation, and it uses per-account
os.tmpdir(). Opening for SME review of the mechanism, not the packaging.
…the worker check The two mapped-source patches are offset against this file's post-application line numbers, so this change is required for the branch to apply -- but it is not part of the lever and a reviewer should decide whether to keep it. It does two things. It re-anchors the hunk (the original @@ -404 no longer matches 22.22.2 cleanly), and it moves the _third_party_main hook to AFTER the env->worker_context() check rather than before it, so a worker thread takes internal/main/worker_thread instead of re-entering the app bootstrap. That reordering is a behaviour change to existing js2bin functionality and is not needed by anything here; if it is wrong, revert it and regenerate the two mapped-source patches against the original anchoring.
…g it Overlay and mapped-source are orthogonal: overlay decides WHICH source runs, mapped-source decides HOW it reaches V8. But only one bootstrap is installed as _third_party_main, so JS2BIN_MAPPED_SOURCE=1 silently displaced the overlay bootstrap and produced a binary that was not overlay-capable at all, even with --enable-overlay passed and the signing and encryption keys embedded. This adds their combination. Two things here are not a copy-paste merge. The cache key had to change. It was the embedded payload's LENGTH, but under overlay the compiled source can be a different payload of the same length -- a collision on which a node maps and executes the wrong source. It is now a SHA-256 of whichever payload is active, still computable without decompressing so an existing cache file short-circuits the decompress. This is not the integrity check; that still has to be a digest embedded in the signed binary. And the two failure modes are kept distinct. A decompression failure of an overlay bundle demotes to the embedded payload, as before. A mapping failure must not, or an unrelated filesystem or binding problem would discard a perfectly good overlay. Verified against a purpose-built binary across seven scenarios: no overlay staged, valid signed overlay, overlay signed with an unrelated key, valid signature over a tampered body, signature truncated to zero bytes, encrypted bundle with the right key, and encrypted bundle with the wrong key. Each asserts on both halves -- which payload got compiled, and that no run fell back to the in-heap path. All seven pass, the two payloads land on distinct cache files, and every overlay rejection still fires. The overlay region is byte-identical to _third_party_main_overlay.js, checked mechanically rather than by eye, so adding mapping changes overlay behaviour not at all. Bootstraps cannot share code -- exactly one is installed and there is no module for them to require -- so that duplication is forced; the header says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Overlay composition resolved — this was the last remaining unknown. The mapped bootstrap used to displace the overlay bootstrap, so Two parts weren't a copy-paste merge, and both are worth a reviewer's eye:
Verified on a purpose-built binary across 7 scenarios — no overlay, valid signed overlay, wrong signing key, valid signature over a tampered body, zero-length signature, encrypted with the right key, encrypted with the wrong key. Two different test apps, so the compiled source is identifiable from stdout rather than inferred, and every scenario also asserts nothing fell back to the in-heap path (a silent fallback would otherwise read as a pass). All 7 pass, the two payloads land on distinct cache files, and every overlay rejection still fires. The overlay region of the new file is byte-identical to And the memory win survives overlay. Real Cribl binary, overlay-capable, 3 settled runs on Windows: 199.9 MB private working set vs 202.1 MB for the non-overlay mapped build — indistinguishable within the spreads, i.e. overlay-capability costs nothing. Still to do — all knowns, no unknowns left: hash verification of the mapped bytes (the blocker; nothing ships before it), delete-on-close so no readable copy of the source is left on disk, a fixed ACL-hardened cache directory instead of Two side findings outside this PR's scope:
|
Stop V8 retaining a private copy of the app source (mapped-source lever)
DRAFT — not ready to merge. More commits are coming; see "What is still missing" below. The most
important of these is hash verification of the mapped bytes, which is a hard prerequisite for shipping
this at all.
The problem
js2bin stores the app as
base64(brotli(source))inside the binary. At every process start the defaultbootstrap base64-decodes it, brotli-decompresses it into a large Buffer,
.toString()s that into a JSstring, and then concatenates that into a template literal in order to prepend the cluster preamble.
The resulting string is what V8 keeps alive for the entire process lifetime, because it needs the source
text to lazily compile functions that have not run yet. For Cribl's ~26 MB bundle that is ~26 MB of
private heap in every process, plus a boot peak of roughly three times that while the copies
coexist.
On a two-process Cribl Edge node that is a little over 20% of the node's total memory footprint, and it
is pure overhead — the same bytes, duplicated per process, never mutated.
The fix
Keep the payload compressed in the binary, but materialise the decompressed source to a file once,
map it read-only, and hand V8 an external one-byte string over the mapping. The bytes become
file-backed and shared between every process instead of private to each, and the binary does not grow.
Two bootstraps are added, both opt-in at
--citime:JS2BIN_MAPPED_SOURCE=1→src/_third_party_main_mapped_source.js. The lever described above.Requires the new native binding. Falls back to the historical in-heap path on any failure rather
than refusing to boot.
JS2BIN_UNCOMPRESSED_SOURCE=1→src/_third_party_main_uncompressed_source.js. Stores thepayload as raw source instead. Node's own builtin loader already hands V8 a file-backed external
one-byte string, so this needs no C++ at all — but the binary grows by the source size, and the win
is lost under overlay because an overlay delivers a fresh bundle that goes back through the in-heap
path.
Plus the native support:
src/patch/22.22.2/node_mapped_source.cc.patch— addsinternalBinding('js2bin') .mapFileAsExternalString(path), returning av8::String::NewExternalOneByteover a read-only filemapping (Windows
CreateFileMapping/MapViewOfFile, POSIXmmap). Public V8 API only, no Nodeinternal helpers, to keep the patch small and portable across Node versions.
src/patch/22.22.2/node_binding_mapped_source.cc.patch— addsV(js2bin)to the compile-timebinding registry. This is required:
NODE_BINDING_CONTEXT_AWARE_INTERNALalone does not registera binding on Node 22, which cost a debugging cycle (
No such binding: js2bin).src/NodeBuilder.js— the env-var flags, bootstrap selection, raw payload return, and thecomment-padding fill for the uncompressed variant.
The native patches are applied unconditionally, so every binary carries the capability even though
only the mapped bootstrap uses it.
Measured result
Windows Server 2019, Cribl Edge, two processes, 1020 s settle, three settled runs per arm, both arms
on the same box built from the same source tag, differing only in
cribl.exe:v4.19.0v4.19.0−53.0 MB PWS (−21.4%), boot −42 ms, no change in binary size.
Reproduced on current
dev(bundle 6.2% larger): stock ~256.8, mapped 202.1, so the win grows to~54.7 MB — it tracks the source size, as the mechanism predicts.
Working Set moves only −10.2 MB while private drops 53.0, and both processes gain ~24.7 MB of shared
memory. That is the signature of pages being re-accounted as file-backed, not freed — which is exactly
what this change is supposed to do.
Heap snapshots confirm the mechanism in isolation: strings drop 35.54 → 9.30 MiB (−26.24) while code,
object shapes, arrays, closures and node count are all identical to within noise. The retained string is
precisely one copy of the source.
Why the win is slightly larger than the retained source
At
v4.19.0the source is 24.77 MiB, so two processes should give −49.5 MB against a measured −53.0 —the mechanism explains ~93%, leaving ~3.5 MB.
The likely remainder is resident heap slack. The old path transiently materialises ~50 MB of strings
per process (decompressed Buffer →
.toString()→ template concatenation); V8 grows its heap to holdthem, and those pages stay resident and private after GC, because Private Working Set charges committed
resident pages rather than live set. The mapped path never builds that peak, so it never grows the heap.
This is inferred, not measured. A heap-total-vs-PWS comparison on the two arms would settle it.
Two invariants that must not be "tidied up"
SlicedStringover an external parent can be flattened by V8 atcompile time, silently reinstating the private copy this exists to remove.
of being textually prepended, and why the uncompressed placeholder is padded with a trailing line
comment rather than NULs.
Both are easy to undo by accident during a cleanup pass, and both fail silently — the binary still works,
it just quietly costs the memory again.
About the second commit
82262e4is titled "NEEDS REVIEW". That title is now stale — it has been reviewed and it should bekept. It re-anchors
node.cc.patch's_third_party_mainhook and moves it after theenv->worker_context()check. The original message worried that the reordering was an unjustifiedbehaviour change to existing js2bin functionality. It is not; it is a bug fix:
_third_party_mainhook at all — verified against pristinenode.ccfrom the 22.22.2 tarball, where the only match inStartExecutionis theworker_context()check. js2bin injects the whole block, so where it goes is js2bin's choice, not a modification of
Node's own control flow.
StartExecutiontoo — that is precisely why upstream'sworker_context()check lives there. Anything injected above it therefore also fires for workerthreads, and
Exists("_third_party_main")is true in every Environment because the builtin iscompiled into the binary.
new Worker()inside a js2bin binary re-enters the appbootstrap instead of
internal/main/worker_thread: the worker never loads its designated script andinstead boots a second copy of the whole app in-thread.
LOCALCOMPUTE_ENTRY_ADAPTER_SOURCE, worker glue that theapp-platform LocalCompute engine runs via
new Worker(bundleFilePath, { workerData: {...} }).Reviewers may reasonably ask for it as a separate PR, since it fixes a pre-existing bug and is not part
of the lever. Happy to split it.
Cross-platform status
The implementation is cross-platform by construction, but has only ever been built and measured on
Windows:
open/fstat/mmap(PROT_READ, MAP_PRIVATE)/close) behind#if defined(_WIN32), with correctly gated headers;--cibuild already attempts to compile it;path.join,os.tmpdir(),fs);v8::String::NewExternalOneByteis public and identical across platforms.The POSIX path has never been compiled. Two things to know before anyone relies on it:
maps them, so two processes sharing one 26 MB mapping still report 26 MB each. It shows only in
USS/PSS — the analogue of Windows Private Working Set. Measuring a sharing win with RSS will
report zero.
(
js2bin-src-<app>-<payloadLength>.js) andos.tmpdir()is world-writable/tmp, so anunprivileged local user could pre-create that file and have their source compiled and executed by a
root-run process. Windows' per-account temp directories blunt this; on Linux it is a straightforward
local privilege escalation.
What is still missing — further commits to come
This branch is a measured spike, not a shippable feature. Still to come:
and verify the mapped bytes before compiling. That, and not filesystem permissions, is what makes
the cache file's writability irrelevant: tampering yields a mismatch and a refusal to run. ~26 MB of
SHA-256 is tens of milliseconds. Nothing here should ship before this lands, and it is doubly
required for any POSIX build.
FILE_FLAG_DELETE_ON_CLOSEon Windows, unlink-after-mmapon POSIX — so no readable copy of the application source is left on disk.
os.tmpdir(), which is per-account and thereforeproduces one copy per account (an interactive run and the service account currently write two), plus
ACL hardening on that directory.
uncompressedSource ? … : mappedSource ? … : enableOverlay ? … : default, soJS2BIN_MAPPED_SOURCE=1replaces the overlay bootstrap rather than composing with it — the binary is not overlay-capable at
all. The concept survives overlay (the payload stays compressed, and an overlay simply replaces that
payload), but shipping it overlay-capable means merging the mapping logic into
_third_party_main_overlay.js. Unbuilt and unmeasured.Testing done
rm -rf build/node-v22.22.2) applies every patch cleanly —node.ccpatched twice,node_binding.ccpatched, no failed hunks, no reversed-patch detection — and the resulting binaryboots: 2 processes,
{"status":"healthy"}, zeroMODULE_NOT_FOUND/Cannot find module/unhandledRejection.js2bin: falling back to in-heap sourceon the debug channel, and the mapped cache file is presentwith the expected size.
cribl.exe(verified by hash).