feat: charge V8 against an embedder budget and let the host stop it - #60
Open
Arshia001 wants to merge 2 commits into
Open
feat: charge V8 against an embedder budget and let the host stop it#60Arshia001 wants to merge 2 commits into
Arshia001 wants to merge 2 commits into
Conversation
Arshia001
force-pushed
the
feat/embedder-memory-budget-and-host-control
branch
from
August 14, 2026 13:32
75c4f7d to
097303c
Compare
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
force-pushed
the
feat/embedder-memory-budget-and-host-control
branch
from
August 14, 2026 15:04
097303c to
596dc58
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.
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.
Memory
NapiMemoryAccountantlets the embedder supply the accounting, so V8 draws onthe same total as everything else it charges rather than a budget of its own.
ResourceBudgetkeeps per-pool numbers for diagnostics either way, andstandalone 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_allstops every live isolate. Embedders need itfor 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 isterminated 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_threadssizes V8's background pool. V8 sizes it from the host'sprocessor 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:
while (true)loop, and the guest's attempt to cancel itand resume is refused;
Getting there needed the native harness repaired first — it had drifted from
build.rson 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.rscompiles so the two cannot driftapart again.
The handle-scope lifetime case is now marked
wasix_only. Its first checkasserts that a value from a closed scope is rejected, which holds because the
guest boundary makes a
napi_valuea generation-tagged id; the native buildhands out raw
v8::Locals, where nothing remains to validate against once thescope closes, and that check has never passed there. The WASIX half still runs.