Skip to content

feat: charge V8 against an embedder budget and let the host stop it - #60

Open
Arshia001 wants to merge 2 commits into
fix/guest-heap-store-lendingfrom
feat/embedder-memory-budget-and-host-control
Open

feat: charge V8 against an embedder budget and let the host stop it#60
Arshia001 wants to merge 2 commits into
fix/guest-heap-store-lendingfrom
feat/embedder-memory-budget-and-host-control

Conversation

@Arshia001

Copy link
Copy Markdown
Member

Lets an embedder that hosts many apps in one process account for the memory a
V8 isolate holds, stop an app's JS, and size V8's background thread pool.

Branched from 52e9da4, the commit the consuming Wasmer checkout pins. main
has moved 32 commits since, including large changes to ctx.rs, env.rs,
guest/napi.rs and a new JS backend, so this will need a rebase before merge.

Memory

NapiMemoryAccountant lets the embedder supply the accounting, so V8 draws on
the same total as everything else it charges rather than a budget of its own.
ResourceBudget keeps per-pool numbers for diagnostics either way, and
standalone use keeps its existing local budgeting.

Charging is by reservation, not live usage — initial heap, per-isolate
overhead, unwind slack, young generation, code range — so the guarantee never
races V8's GC. When the near-heap-limit callback cannot get another grow step
it terminates the isolate and releases pre-reserved unwind slack once, letting
the guest unwind instead of hitting V8's fatal OOM path.

Stopping an app's JS

NapiRuntimeControl::terminate_all stops every live isolate. Embedders need it
for two situations — the app went over its memory budget, and the app was
killed for an unrelated reason — and N-API draws no distinction, because either
way that app's JS must never run again.

The stop is sticky. Guest code cannot undo it with
napi_cancel_terminate_execution, and an isolate created afterwards is
terminated as it registers, under the same lock the sweep holds, so it cannot
slip through by being created after the sweep passed.

Worker pool

set_v8_worker_threads sizes V8's background pool. V8 sizes it from the host's
processor count, which suits a process running one JS app; a host running many
gets a pool whose threads compete with every tenant's foreground JS and whose
work is attributed to none of them. Lowering it strands nothing: V8's parallel
jobs are cooperative and the posting thread joins in, so the work migrates onto
the thread that asked for it. The size is fixed when the platform is built, and
asking again for the size already in place is accepted so several components
can configure the runtime from one setting.

Tests

The new tests drive a real isolate through a WASIX guest rather than exercising
the bookkeeping in isolation:

  • a host stop ends a while (true) loop, and the guest's attempt to cancel it
    and resume is refused;
  • reservations return to zero once the guest exits;
  • an isolate is refused when the budget cannot cover the per-isolate floor;
  • the worker pool has exactly the number of threads it was asked for.

Getting there needed the native harness repaired first — it had drifted from
build.rs on the prebuilt V8 version, an include path, and the list of C++
translation units, so it had not linked in some time. The source list is now
globbed over the same directories build.rs compiles so the two cannot drift
apart again.

The handle-scope lifetime case is now marked wasix_only. Its first check
asserts that a value from a closed scope is rejected, which holds because the
guest boundary makes a napi_value a generation-tagged id; the native build
hands out raw v8::Locals, where nothing remains to validate against once the
scope closes, and that check has never passed there. The WASIX half still runs.

@Arshia001
Arshia001 force-pushed the feat/embedder-memory-budget-and-host-control branch from 75c4f7d to 097303c Compare August 14, 2026 13:32
@Arshia001
Arshia001 changed the base branch from main to fix/guest-heap-store-lending August 14, 2026 13:33
The Makefile still looked for a prebuilt V8 under 11.9.2 while
`build.rs` fetches 11.9.7, so it never found the cached distribution.
The native script was already corrected; this brings the Makefile in
line with it.

Also notes the fix for `wasm-opt` failures caused by an old binaryen,
which is otherwise an obscure "Unknown option" error from a tool the
build calls indirectly.
An embedder hosting many apps in one process needs the memory a V8
isolate holds to count against the same limit as the rest of the app,
and needs to be able to stop an app's JS. Neither was possible before:
the budget only knew about guest wasm linear memory, and nothing outside
the guest could terminate an isolate.

`NapiMemoryAccountant` lets an embedder supply the accounting, so V8
draws on the same total as everything else it charges. `ResourceBudget`
keeps its own per-pool numbers for diagnostics either way. V8 memory is
charged by reservation rather than by live usage -- the initial heap,
per-isolate overhead, unwind slack, young generation and code range --
so the guarantee never races V8's GC. When the near-heap-limit callback
cannot get another grow step, the isolate is terminated and pre-reserved
unwind slack is released once so the guest can unwind rather than fall
into V8's fatal OOM path. Standalone use keeps its existing local
budgeting.

`NapiRuntimeControl::terminate_all` stops every live isolate. Embedders
need this for two cases -- the app exceeded its memory budget, and the
app was killed for an unrelated reason -- and N-API draws no distinction
between them, since either way that app's JS must never run again. The
stop is sticky: guest code cannot undo it with
`napi_cancel_terminate_execution`, and an isolate created afterwards is
terminated as it is registered, under the same lock, so it cannot slip
through by being created after the sweep.

`set_v8_worker_threads` sizes V8's background worker pool. V8 sizes it
from the host's processor count, which suits a process running one JS
app; a host running many gets a pool whose threads compete with every
tenant's foreground JS and whose work is attributed to none of them.
Lowering it strands nothing, because V8's parallel jobs are cooperative
and the posting thread joins in, so the work migrates onto the thread
that asked for it. The size is fixed when the platform is built, and
asking again for the size already in place is accepted so that several
components can configure the runtime from one setting.

The new tests drive a real isolate through a WASIX guest rather than
testing the bookkeeping in isolation: a host stop ends a `while (true)`
loop and the guest cannot cancel it, reservations return to zero when
the guest exits, an isolate is refused below the budget floor, and the
worker pool is the size it was asked to be.
@Arshia001
Arshia001 force-pushed the feat/embedder-memory-budget-and-host-control branch from 097303c to 596dc58 Compare August 14, 2026 15:04
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