Skip to content

Allocate env ids per application and let stores terminate each other's envs - #108

Merged
syrusakbary merged 3 commits into
mainfrom
feat/concurrent-stores
Oct 28, 2026
Merged

syrusakbary merged 3 commits into
mainfrom
feat/concurrent-stores

Conversation

@syrusakbary

@syrusakbary syrusakbary commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Builds on #107, now in main: stopping an environment from another thread only helps if the running environment does not hold a process-wide lock. This branch is rebased onto main and targets it.

Problem

Native env ids and owner-scope ids were allocated per NapiEnv, that is, per Wasmer store. Every guest import resolved them only through the caller's store. A WASIX application with worker threads has one store per thread, all sharing one linear memory. As a result:

  • Two stores hand out the same ids: each thread's first env is 1. An id stored in shared guest memory then names a different environment depending on which thread reads it.
  • A scope token from another store can alias the caller's own scope, so release_env with another thread's token can release the caller's own environment.
  • A guest cannot stop an environment owned by another store of its own application, for example the main thread stopping a runaway Worker. Only the host could, through NapiRuntimeControl::terminate_all(), which stops everything.

Change

Rust only: src/env.rs, src/ctx.rs, src/guest/napi.rs.

  • Application-wide ids. The application's env registry, shared by all its stores, now allocates native env ids. Ids are unique across those stores and never reused. Each env has one owner scope, so its native scope id equals its env id. The host-JS (wasm32 + js) backend keeps its current allocation.
  • Cross-store terminate. unofficial_napi_terminate_execution resolves its target through that registry (NapiEnv::terminate_app_env), so any store of the application can terminate any of its environments.
    • The registry lock is held only while the request is handed to the bridge.
    • Registered pointers are retained, so the target cannot be released or reused meanwhile.
    • Terminate runs no guest code.
  • Everything else stays store-local. All other imports, including interrupt, cancel and release, still resolve only through the caller's own store; a foreign id gets napi_invalid_arg. An interrupt runs a guest function the caller picks (a table index) in the target's instance. Allowing that across stores is only sound when every store sharing a NapiCtx is a thread of the same module, and nothing guarantees that.
  • Trust model. NapiRuntimeHooks now documents it: one NapiCtx per application, and separate contexts for unrelated untrusted guests. Different NapiCtxs keep separate registries and cannot reach each other's environments.

Tests

tests/concurrent_stores.rs reuses the WAT guest from #107.

  • env_ids_are_unique_per_application. It checks that:

    • two stores get different env ids and scope ids;
    • a scope token from another store cannot release the caller's env;
    • retired ids are not reused;
    • a guest in another application cannot terminate these envs.
  • another_store_can_terminate_but_not_interrupt_or_use_an_env. A worker store on its own thread interrupts its own env (the callback runs in its own store), waits in Atomics.wait, then spins. Meanwhile the main store, on the test thread:

    • gets napi_invalid_arg for napi_get_undefined, interrupt, cancel and release on the worker's env;
    • keeps running scripts and serializing messages while the worker is inside JavaScript;
    • terminates the spinning script.

    Afterwards the retired id is rejected, and the main env was never stopped.

Both tests fail on main without these commits, because the two stores get the same env id 1. The interrupt check also fails on the first commit, which let another store interrupt. This branch adds the suite to the CI job from #107.

After rebasing onto main (with #121, #122 and #123): lib tests 54/54, concurrent_envs 10/10, concurrent_stores 2/2 and guest_callbacks 4/4; clippy shows no warnings.

Before the rebase (macOS arm64, Rust 1.96.1, V8 11.9.9 prebuilt):

  • concurrent_stores: 2/2, plus 20/20 repeated runs of the whole binary.
  • concurrent_envs: 10/10, plus 20/20 repeated runs.
  • v8_host_control: 3/3.
  • Lib tests: 46/46.
  • Manifest programs: all 36, natively and as WASIX.
  • Host-JS node test: 14/14.
  • snapi_js wasm-bindgen tests: 6/6.
  • cargo fmt is clean, and clippy shows no new warnings.

Behavior changes

  • A guest can now terminate any environment of its own application by id, not just those in its own store.
  • Native env ids come from one application-wide counter. A guest that assumed its first env id is 1 will see other values; the API never guaranteed that.
  • Running out of the 2^32 native id space panics instead of handing out duplicate ids. The third commit releases the registry lock before that panic, so it doesn't poison the registry for the application's other stores.

Known follow-up (not changed here)

Message ids are process-wide, sequential and carry no owner check. This is already the case on main; see #107.

Relation to #93

#93 changes neither id allocation nor cross-store control: its terminate and interrupt imports still resolve through the caller's store. This PR does not depend on #93's other fixes (fatal-error exits, callback exit propagation, external-buffer ownership, reentrant release rejection). It needs only one thing from the bridge: accepting a terminate from another thread while the target runs JavaScript. #107 provides that, and #93's terminate path would too.

Size: 5 files, +274/−46 against main:

Area Lines
Rust (env.rs +80/−32, ctx.rs +14/−10, guest/napi.rs +2/−2) +96/−44
test +174
CI +4/−2

🤖 Generated with Claude Code

syrusakbary and others added 3 commits October 27, 2026 20:09
Native env ids and owner-scope ids were allocated per NapiEnv, which
means per Wasmer store, and every guest import resolved them only
through the caller's store. A WASIX application runs one store per
thread over one shared guest memory, so two threads got the same ids
for different environments. A scope token from another thread could
alias, and release, the caller's own environment. And no guest could
terminate or interrupt an environment owned by another thread of its
own application, such as a runaway worker.

The application's env registry, already shared by its stores for
NapiRuntimeControl::terminate_all(), becomes an EnvRegistry: the next
native env id and a map from env id to bridge env. Native ids are
allocated there and never reused. An env has one owner scope, so its
scope id is its env id. The host-JS backend keeps its own allocation.

unofficial_napi_terminate_execution and unofficial_napi_request_interrupt
resolve their target through that registry (NapiEnv::with_control_env),
holding its lock only while the request is handed to the bridge, so the
target cannot be released meanwhile. The bridge accepts both from a
foreign thread. The interrupt callback runs later on the target's owner
thread, in the target store's callback context. Every other import,
including cancel and release, still resolves only through the caller's
own store. Separate applications keep separate registries.

tests/concurrent_stores.rs checks that ids are unique per application
and not reused, that a foreign scope token cannot release the caller's
env, and that a worker store's env running JavaScript on another thread
can be interrupted (in its own store) and terminated from the main store,
but not used, cancelled or released by it. Without this change both
tests fail: the two stores get the same env id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An interrupt runs a guest function chosen by the caller (a table index)
in the target env's instance. Letting any store of the application
interrupt any env was only sound if every store sharing a NapiCtx were
a thread of the same module, sharing its table and memory.
NapiRuntimeHooks makes no such promise, and env ids are sequential. So
unofficial_napi_request_interrupt resolves its target through the
caller's own store again, as on main.

Terminate remains the one cross-store control: it runs no guest code.
NapiEnv::terminate_app_env replaces the general with_control_env
closure. NapiRuntimeHooks documents the trust model: one context per
application, and unrelated untrusted guests get separate contexts.

another_store_can_terminate_but_not_interrupt_or_use_an_env now checks
that another store's interrupt is refused, and that the worker can
still interrupt its own env. It fails on the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The env registry lock is shared by every store of an application.
Panicking while holding it poisoned it, so every later env creation,
release and terminate_all of the application panicked too. Release it
before panicking. Also drop "interrupt" from two comments: since the
previous commit, interrupts stay within the caller's own store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@syrusakbary
syrusakbary force-pushed the feat/concurrent-stores branch from 3ca9482 to 37a4643 Compare October 28, 2026 03:13
@syrusakbary
syrusakbary changed the base branch from perf/concurrent-envs to main October 28, 2026 03:13
@syrusakbary
syrusakbary merged commit f1922b0 into main Oct 28, 2026
11 checks passed
syrusakbary added a commit that referenced this pull request Oct 28, 2026
Brings in #108 (env ids per application; stores can terminate each
other's envs) and #124 (an env can't be released from guest code it
called into).

Releasing an env tears down its agent on this branch, so #124's count of
guest code in progress is per agent: guest code entered through any env
of the agent keeps the isolate's engine frames on the stack.
GuestCallScope counts on the agent, and DisposeBridgeState and
snapi_bridge_unofficial_env_release_refused refuse while it is non-zero
or the agent is closing.

host-JS: #124's thread-wide GUEST_CALL_DEPTH and call_guest come in;
ACTIVE_HOST_JS_ENV, which this branch removed, stays out.

docs/realm-bound-env.md records #108 as landed and #124's count as agent
teardown state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
syrusakbary added a commit that referenced this pull request Oct 28, 2026
Brings in #123, #108 and #124 through Phase 0.

Rust: #108 allocates native env ids per application, so a realm env takes
its id from the application's registry too, and never shares one with
another store's env. It still gets no registry entry. Terminate resolves
through the registry since #108, so terminating one of the caller's realm
envs goes through its principal env, which stops every env of the
isolate as before.

host-JS: releasing an env first asks whether guest code is running
(#124), then releases its realm envs as Phase 1 does through free_env.

The bridge needed no change: #124's count of running guest calls is per
agent, so it covers realm envs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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