Skip to content

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

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

fix(js): 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.
@nazarhussain nazarhussain changed the title fix: serialize moduleInit/cleanupHook across N-API envs fix(js): serialize moduleInit/cleanupHook across N-API envs Jul 17, 2026
@GrapeBaBa

Copy link
Copy Markdown
Contributor Author

Superseded by #53.

@GrapeBaBa GrapeBaBa closed this Jul 18, 2026
nazarhussain added a commit that referenced this pull request Jul 20, 2026
## Motivation

`export_module.zig` (bare atomic) and `js/io.zig` (mutex-guarded
refcount) duplicate the same retain/release lifecycle. That split is the
root cause of both open fixes: the cross-env init race (#31) and the
missed cleanup on registration failure (#52).

## Description

- Add `lifecycle.SharedResource(T, hooks)`: a mutex-guarded refcount
that runs fallible `init` under the lock *before* incrementing, and
`cleanup` after decrementing.
- Port `js/io.zig` and the module lifecycle in `export_module.zig` onto
it. No public API changes.

Both fixes hold by construction:
- Hooks are serialized per module, and the refcount only increments
after `init` succeeds — fixes the #31 race, with registration kept
outside the lock.
- Rollback goes through `release()`, which always runs `cleanup` with
the correct count — fixes #52.

Supersedes #31 and #52.

## Testing

New tests in `lifecycle.zig` cover hook ordering, init-failure rollback,
and instance visibility. `zig build test`: 54/54 pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 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.

2 participants