From 680c8ad8b7acd5d81dbabf930f3dae621c3901e5 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Wed, 13 May 2026 21:21:17 +0800 Subject: [PATCH] fix: serialize moduleInit/cleanupHook across N-API envs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 #20). The critical section stays small and uncontended in the common case (single env) — one CAS on attach and one store on detach. --- src/js/export_module.zig | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/js/export_module.zig b/src/js/export_module.zig index aa70ba1..6daceb9 100644 --- a/src/js/export_module.zig +++ b/src/js/export_module.zig @@ -31,8 +31,10 @@ const class_runtime = @import("class_runtime.zig"); /// have been processed and *before* the module's `init` hook (if present). /// `exports` is the JavaScript object that will hold the module's exports. /// -/// The DSL internally manages an atomic refcount for module instances across -/// different N-API environments. +/// The DSL manages an atomic refcount across N-API environments and +/// serializes `init`/`cleanup` so concurrent attaches (e.g. Worker threads +/// loading the same addon) cannot expose exports while init is still +/// running on another thread. /// /// Usage Examples: /// ```zig @@ -64,6 +66,7 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { const State = struct { var env_refcount: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); + var locked: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); // addEnvCleanupHook requires a non-null *Data pointer. const CleanupData = struct { @@ -71,7 +74,20 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { }; var cleanup_data: CleanupData = .{}; + fn lock() void { + while (locked.cmpxchgWeak(false, true, .acquire, .monotonic) != null) { + std.atomic.spinLoopHint(); + } + } + + fn unlock() void { + locked.store(false, .release); + } + fn cleanupHook(_: *CleanupData) void { + lock(); + defer unlock(); + const prev = env_refcount.fetchSub(1, .acq_rel); const new_refcount = prev - 1; if (has_cleanup) { @@ -86,6 +102,9 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { defer context.restoreEnv(prev); if (has_lifecycle) { + State.lock(); + defer State.unlock(); + const prev_refcount = State.env_refcount.fetchAdd(1, .monotonic); var cleanup_hook_registered = false; errdefer if (!cleanup_hook_registered) {