Skip to content

fix: serialize moduleInit/cleanupHook across N-API envs#31

Open
GrapeBaBa wants to merge 4 commits into
ChainSafe:mainfrom
GrapeBaBa:gr/serialize-module-lifecycle
Open

fix: serialize moduleInit/cleanupHook across N-API envs#31
GrapeBaBa wants to merge 4 commits into
ChainSafe:mainfrom
GrapeBaBa:gr/serialize-module-lifecycle

Conversation

@GrapeBaBa

@GrapeBaBa GrapeBaBa commented May 13, 2026

Copy link
Copy Markdown
Contributor

Motivation

moduleInit atomically increments State.env_refcount to decide which caller is the first environment, but options.init and the subsequent registerDecls / options.register calls are not serialized between concurrent environments. When two N-API environments attach near-simultaneously, the second environment can expose exports while the first is still initializing shared state. The same race applies between cleanupHook and a fresh moduleInit.

Description

Guard the lifecycle path in moduleInit and the lifecycle portion of cleanupHook with a per-module std.Io.Mutex. Each attach first retains the shared IO context, then obtains its std.Io interface and calls lockUncancelable; this keeps the IO implementation alive while a contending attach waits. Unlocking uses the same retained std.Io interface, without depending directly on the std.Io.Threaded backend.

The initialization critical section covers the lifecycle refcount update, options.init, automatic and custom export registration, and cleanup-hook registration. Error rollback remains inside the lock. Cleanup keeps js.io() retained while options.cleanup runs, then releases the lifecycle lock before releasing the shared IO context.

`exportModule` atomically incremented `env_refcount`, but the user's
`options.init` and the subsequent `registerDecls` / `options.register`
calls were not serialized across environments. When two N-API
environments (e.g. Node.js Worker threads loading the same addon at
roughly the same time) call into `moduleInit` concurrently, the
"second" env's `fetchAdd` returns a non-zero `prev_refcount` and skips
the user's init, but it then proceeds to `registerDecls` and exposes
the module's exports to JS while the "first" env is still inside the
user's `init` hook. JS code on the second env can observe
partially-initialized shared state.

Guard `moduleInit`'s lifecycle path and `cleanupHook` with a
per-module spinlock (same pattern already used in `class_runtime.zig`,
introduced in ChainSafe#20). The critical section stays small and uncontended
in the common case (single env) — one CAS on attach and one store on
detach.
wemeetagain
wemeetagain previously approved these changes May 13, 2026
spiral-ladder pushed a commit to ChainSafe/lodestar-z that referenced this pull request May 25, 2026
## Motivation

`ThreadPool` worker hot loops use `.acquire` on `err_flag.load()` where
`.monotonic` suffices — the flag is a pure early-exit signal with no
data dependency on the setter's other writes. BLS verification is
CPU-intensive, so relaxing this in the inner loop avoids unnecessary
memory-fence cost. Matches the pattern already used in
`src/state_transition/cache/pubkey_cache.zig`.

The earlier NAPI init-mutex changes from this branch have been dropped
after merging main, because main moved to zapi-managed lifecycle
(`js.exportModule` with `init`/`cleanup` hooks). The concurrent-register
race they were guarding against has been filed against zapi upstream:
ChainSafe/zapi#31.

## Description

`src/bls/ThreadPool.zig`:
- `err_flag.load(.acquire)` → `.monotonic` in `VerifyMultiWorkItem.exec`
and `AggVerifyWorkItem.exec` worker loops.
- Setter side (`err_flag.store(true, .release)` on pairing failure) is
unchanged — release semantics on the producer side carry no obligation
on the consumer to also be `.acquire` when the consumer doesn't depend
on the producer's other writes.
markolazic01 pushed a commit to markolazic01/lodestar-z that referenced this pull request Jun 17, 2026
## Motivation

`ThreadPool` worker hot loops use `.acquire` on `err_flag.load()` where
`.monotonic` suffices — the flag is a pure early-exit signal with no
data dependency on the setter's other writes. BLS verification is
CPU-intensive, so relaxing this in the inner loop avoids unnecessary
memory-fence cost. Matches the pattern already used in
`src/state_transition/cache/pubkey_cache.zig`.

The earlier NAPI init-mutex changes from this branch have been dropped
after merging main, because main moved to zapi-managed lifecycle
(`js.exportModule` with `init`/`cleanup` hooks). The concurrent-register
race they were guarding against has been filed against zapi upstream:
ChainSafe/zapi#31.

## Description

`src/bls/ThreadPool.zig`:
- `err_flag.load(.acquire)` → `.monotonic` in `VerifyMultiWorkItem.exec`
and `AggVerifyWorkItem.exec` worker loops.
- Setter side (`err_flag.store(true, .release)` on pairing failure) is
unchanged — release semantics on the producer side carry no obligation
on the consumer to also be `.acquire` when the consumer doesn't depend
on the producer's other writes.
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