Skip to content

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

Open
GrapeBaBa wants to merge 1 commit into
ChainSafe:mainfrom
GrapeBaBa:gr/serialize-module-lifecycle
Open

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

Conversation

@GrapeBaBa
Copy link
Copy Markdown
Contributor

@GrapeBaBa GrapeBaBa commented May 13, 2026

Motivation

moduleInit atomically increments State.env_refcount to decide which caller is the "first" env, but the user's options.init and the subsequent registerDecls / options.register are not serialized between concurrent environments. When two N-API envs attach near-simultaneously (e.g. a Node.js main thread plus a Worker thread require()-ing the same addon), the "second" env's fetchAdd returns a non-zero prev_refcount, skips the user's init, and proceeds to expose exports to its JS isolate while the first env is still inside the user's init hook. JS code on the second env can then observe partially-initialized shared state. The same race applies between cleanupHook and a fresh moduleInit.

Description

Guard the lifecycle path in moduleInit and the body of cleanupHook with a per-module spinlock, reusing the same primitive introduced for class_runtime.zig in #20 (std.atomic.Value(bool) + cmpxchgWeak + spinLoopHint). No new std.Thread/std.Io dependency. One CAS on attach, one atomic store on detach; uncontended in the single-env case. addEnvCleanupHook is called while the lock is held, but N-API never invokes the hook synchronously from registration, so no self-deadlock. zig build test passes.

`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.
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