Skip to content

GuestHeap: host-side allocator over guest memory; remove the copy layer (ECO-416) - #43

Merged
Arshia001 merged 5 commits into
napi-v8-imports-bridge-fixesfrom
napi-value-scope-rework
Aug 7, 2026
Merged

GuestHeap: host-side allocator over guest memory; remove the copy layer (ECO-416)#43
Arshia001 merged 5 commits into
napi-v8-imports-bridge-fixesfrom
napi-value-scope-rework

Conversation

@Arshia001

Copy link
Copy Markdown
Member

Stack 4/4 (napi) — final; matches local state. Base: napi-v8-imports-bridge-fixes.

Replaces the snapshot-copy/write-back coherence layer (root cause of the string_decoder/zlib data-integrity cluster) with a host-side allocator over guest linear memory:

  • GuestHeap: claims pages via host-issued memory.grow, manages them with offset-allocator (all metadata host-side)
  • routes both bridge allocations and V8's ArrayBuffer allocator into guest memory (zero-copy sharing); finalizer-driven frees for bridge buffers
  • removes the copy/coherence layer entirely
  • fixes a pre-existing >2 GiB external-pointer host crash (i32 sign-truncation)
  • one shared GuestHeap per linear memory across envs — fixes host-heap corruption under WASIX worker threads

Results: wasix node suite 1625/0 (skip list 106→59), js-native-api conformance 34/34, native baseline green, ECO-414 loop 0/40.

⚠️ Final PR of the stack; not yet mergeable (ECO-415 guest-finalizer dispatch + remaining ECO-416 clusters still open). Relates to ECO-416.

Arshia001 and others added 5 commits July 28, 2026 14:51
The bridge allocated guest memory by calling the guest's exported malloc
(reentrant into the wasm guest, and with no free path — every allocation
leaked). GuestHeap replaces that: it claims pages of the guest's linear
memory by issuing memory.grow FROM THE HOST and manages allocations
within the claimed ranges itself. Grows are serialized per memory and
return the previous size, so host and guest (sbrk) claims interleave
without overlap — the same protocol the WASIX dynamic linker relies on.

Design points:
- All allocator metadata is host-side (offset-allocator + a live-alloc
  side table). Guest memory holds payload bytes only, so a buggy or
  hostile guest can corrupt its own data but never allocator state; frees
  are validated against the side table (double-frees, fabricated and
  wrong-length pointers are rejected).
- Shared memories grow through a cloned VMSharedMemory handle: no store
  needed, callable from any thread (required by the upcoming V8
  array-buffer allocator routing, whose Free runs on GC threads).
  Non-shared memories (napi_wasmer conformance lane) are pre-funded at
  instantiation and refilled from import boundaries.
- The base pointer must never move (external backing stores hold host
  pointers): verified at init via MemoryStyle::Static, re-checked after
  every grow (abort on violation).
- Claimed bytes are charged to the WasmLinear budget pool (host-issued
  grows bypass BudgetedMemory's accounting) and uncharged on drop.

All five malloc_fn call sites now use the heap (arraybuffer/buffer/
buffer_copy creators return napi_generic_failure on exhaustion, matching
Node's OOM behavior), and the guest-malloc registration is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rs via finalizers (ECO-416)

Phase 2 of the guest-heap work put bridge-side allocations in host-claimed
guest memory. This routes the other — much larger — class of buffers there
too: every backing store V8 allocates (Buffer.alloc & co in host JS) now
comes from the GuestHeap, so guest and host share buffer bytes directly
and the snapshot-copy coherence layer loses its last customer.

- TrackingArrayBufferAllocator gains a guest-heap context (installed via
  unofficial_napi_env_create_options before the isolate exists, so even
  bootstrap-time backing stores are guest-backed). Allocate routes to the
  Rust hook (null => V8 RangeError; never falls back to host memory,
  which the guest could not address). Free uses a mixed-provenance
  protocol: the hook claims pointers by integer range (handles pre-ctx
  allocations from the legacy backing allocator). MaxAllocationSize is
  capped for the 32-bit guest space.
- Guest-heap-routed allocations skip the external-memory budget charge:
  the claimed pages are already charged as wasm linear memory, which is
  the single source of truth for those bytes.
- The allocator/finalizer contexts hold Weak<GuestHeap> plus copied
  integers, never an Arc: process-lifetime V8 globals are deliberately
  leaked (exit-race fix), and an Arc reachable from them would pin the
  instance's multi-GiB memory reservation forever.
- Bridge-created buffers/arraybuffers now use finalized external variants
  whose V8 finalizer returns the allocation to the guest heap — fixing
  the historical permanent leak of every napi_create_buffer.
- Weak no-op hook fallbacks keep the native edgejs build (no Rust host,
  no context installed) byte-for-byte on its existing path.

js-native-api conformance: 34/34 with the full pipeline active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With every backing store now living in guest linear memory (bridge
allocations since the GuestHeap commit, V8/JS allocations since the
allocator-routing commit), guest access to buffer bytes is exact
base-relative translation with zero state. The snapshot-copy layer —
per-frame guest copies, write-back flushes at callback/import unwinds,
backing-store-token and handle-id caches — has no remaining customers,
and its heuristics were the root cause of the string_decoder/zlib
data-integrity cluster. Deleted:

- NapiEnv: guest_data_ptrs (+ prune), guest_data_backing_stores,
  host_buffer_copies + frame stacks, and their types;
- callback.rs: all flush machinery (the callback-invocation ctx swap
  stays — the trampoline needs it);
- resolve_current/resolve_or_copy_host_data_to_guest and the
  remember_* helpers;
- the snapshot/overwrite FFI pair and its C++ byte-span helpers.

The info getters now translate exactly, with one narrow fallback: a
"foreign" backing store that did not come from the array-buffer
allocator (e.g. a view over a V8-side WebAssembly.Memory) gets a one-way
READ-ONLY snapshot into a guest-heap allocation whose lifetime is tied
to the JS value via napi_add_finalizer. Mutations through such a pointer
do not propagate (they never reliably did); a rate-limited log makes any
unexpected traffic visible.

Also: unofficial_napi_free_buffer now actually frees (guest-heap
validated), closing the profile/snapshot JSON copy leak.

js-native-api conformance: 34/34.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge (ECO-416)

guest_napi_create_external_arraybuffer (and its buffer twin) computed the
host backing-store address as `host_base + external_data as u64`, where
external_data is the guest data pointer typed i32. wasm32 pointers are
unsigned (0..4GiB); once the guest's own heap grows past 2GiB a pointer
has bit 31 set, so `as u64` sign-extends it and the computed host address
lands ~2GiB BELOW the linear-memory base. V8 then reads/writes that wild
pointer — an out-of-bounds host access (SIGSEGV, and a co-tenancy hazard
on Edge).

Zero-extend instead (`as u32 as u64`). Reproduced with a buffer-churn
that holds >2GiB of Buffer.alloc live (each is a guest-malloc'd external
arraybuffer): host SIGSEGV in V8's TypedArray fill right at the 2GiB
boundary before, clean run to >2.5GiB after.

Pre-existing (the i32 signature predates the guest-heap work); surfaced
now because dropped guest finalizers (ECO-415) let those buffers
accumulate past 2GiB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uption under WASIX threads) (ECO-416)

WASIX worker threads share a single linear memory but each instantiates
its own N-API env, so configure_instance built a SEPARATE GuestHeap per
env over the SAME memory. Confirmed with etherpad: 6 heaps created over
one base (0x..c00000) from 6 threads. Each heap had an identical
ownership range (`owns()` = [base, base+4GiB)) but its own offset
allocator, live table, and mutex — so ownership was ambiguous across
heaps and the per-heap mutexes serialized nothing between them. Under
etherpad's sustained multi-threaded load this corrupted the HOST glibc
heap (`malloc_consolidate(): unaligned fastbin chunk detected`, SIGABRT).
It was a Heisenbug — gdb and any added synchronization shifted the timing
enough to hide it (which is itself how the multi-heap race was confirmed).

Fix: a process-global registry keyed by the memory's base address hands
every env on the same shared memory the same Arc<GuestHeap>. One
allocator, one live table, one mutex now serialize all threads with
unambiguous ownership; Weak refs let the heap drop when its last env
goes. Non-shared memories (the single-threaded conformance lane) still
get a fresh heap each time, avoiding base-key collisions across
short-lived instances.

Verified: etherpad 3x natively (no instrumentation, race live) — zero
host corruption, now reaches only its pre-existing unclean-exit
(unchanged, still skipped). js-native-api conformance 34/34;
string-decoder / zlib / http2-respond-file / buffer still pass. The
grow-from-host architecture is sound (dlmalloc copes with foreign
memory.grow via add_segment); this was purely a host-side multi-heap
concurrency bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Arshia001
Arshia001 force-pushed the napi-value-scope-rework branch from 4fff8bd to 4db81be Compare July 28, 2026 14:52
@Arshia001
Arshia001 requested a review from syrusakbary July 31, 2026 06:06
Comment thread v8/src/unofficial_napi.cc
Comment on lines +504 to +513
extern "C" __attribute__((weak)) void* napi_host_guest_heap_alloc(
const void* /*ctx*/, size_t /*length*/, int /*zero*/) {
return nullptr;
}
extern "C" __attribute__((weak)) int napi_host_guest_heap_free(
const void* /*ctx*/, void* /*data*/, size_t /*length*/) {
return 0;
}
extern "C" __attribute__((weak)) void napi_host_guest_heap_release(
void* /*ctx*/) {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we can use the allocator exposed in the libc of wasm directly, that way we don't need 3 new extra functions (and we move that logic into the wasm program itself)

@Arshia001
Arshia001 merged commit bce0147 into main Aug 7, 2026
7 of 8 checks passed
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.

2 participants