From 729ed19032a51c4196ce363878dbc7f8527e561a Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 7 Aug 2026 17:43:53 -0300 Subject: [PATCH 01/63] docs: add design for multiple isolated DataWeave engines per process Addresses GUS W-23692110, discovered while implementing Node.js external module support (#154). native-lib's ScriptRuntime is a static singleton with a write-once resolver, so a second DataWeave instance in one Node process silently reuses the first instance's resolver instead of getting its own. Design: turn ScriptRuntime into a handle-addressable registry of per-instance engines (one shared GraalVM isolate, following the pattern native-cli's NativeRuntime already uses), with a per-handle resolver bridge in the Node C addon. Python is out of scope here (tracked as a follow-up) since it already gets isolation via one isolate per instance. Co-Authored-By: Claude Opus 5 --- ...6-08-04-nodejs-external-modules-design.md} | 0 ...26-08-07-native-lib-multi-engine-design.md | 169 ++++++++++++++++++ 2 files changed, 169 insertions(+) rename docs/superpowers/specs/{ 2026-08-04-nodejs-external-modules-design.md => 2026-08-04-nodejs-external-modules-design.md} (100%) create mode 100644 docs/superpowers/specs/2026-08-07-native-lib-multi-engine-design.md diff --git a/docs/superpowers/specs/ 2026-08-04-nodejs-external-modules-design.md b/docs/superpowers/specs/2026-08-04-nodejs-external-modules-design.md similarity index 100% rename from docs/superpowers/specs/ 2026-08-04-nodejs-external-modules-design.md rename to docs/superpowers/specs/2026-08-04-nodejs-external-modules-design.md diff --git a/docs/superpowers/specs/2026-08-07-native-lib-multi-engine-design.md b/docs/superpowers/specs/2026-08-07-native-lib-multi-engine-design.md new file mode 100644 index 0000000..5a491ac --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-native-lib-multi-engine-design.md @@ -0,0 +1,169 @@ +# Design: Multiple Isolated DataWeave Engines per Process (native-lib, Node) + +**Date:** 2026-08-07 +**Status:** Approved for implementation +**Tracks:** GUS [W-23692110](https://gus.my.salesforce.com/lightning/r/ADM_Work__c/a07EE00002gS7SOYA0/view) — "Native-lib: support multiple DataWeave engine instances with independent module resolvers" +**Related:** [docs/superpowers/specs/2026-08-04-nodejs-external-modules-design.md](./2026-08-04-nodejs-external-modules-design.md) (the design during which this limitation was discovered) + +## Goal + +Let multiple `DataWeave` instances coexist in one Node process, each with its own module resolver and script cache, so that different resolvers never collide. Today the second `new DataWeave({ resolveModule })` in a process silently keeps the first instance's resolver. + +## Background + +`native-lib`'s `ScriptRuntime` (`native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java`) is a `static final` singleton (`:33`) holding one `engine` and a **write-once** `static volatile resolver` (`:36`). `setResolver` refuses to run a second time per process (`:58-63`, logs a warning and returns). Every `@CEntryPoint` in `NativeLib.java` routes through `ScriptRuntime.getInstance()`. So two `DataWeave` instances in one process cannot have independent module sets — whichever calls a resolver-backed `run()` first wins. + +**This is not a GraalVM constraint.** `native-cli`'s `NativeRuntime` (`native-cli/src/main/scala/org/mule/weave/dwnative/NativeRuntime.scala:50-60`) already builds one independent `DataWeaveScriptingEngine` + `CompositeWeaveResourceResolver` per instance — there is no shared static state there. GraalVM Java statics are scoped per-isolate, which is also why the Python binding (one GraalVM isolate per `DataWeave()` instance) already gets resolver isolation "for free" today. The limitation is specific to `native-lib`'s deliberate Java static singleton plus the Node C addon's global resolver bridge. + +## Scope + +**In scope:** +- `native-lib` Java layer: turn `ScriptRuntime` from a static singleton into a handle-addressable registry of instances, each with its own engine + resolver. +- Node C addon (`addon.c`): per-handle resolver bridge state instead of one process-global bridge. +- Node TypeScript layer (`ffi.ts`, `dataweave.ts`): each `DataWeave` instance owns an engine handle for its whole lifecycle. + +**Out of scope:** +- Python binding changes. Python already achieves isolation via one isolate per instance; unifying it onto the same handle-based API is a **follow-up task** (see Verification). +- Separate GraalVM isolates per engine — rejected as the isolation mechanism (see Alternatives Considered). +- Solving streaming/transform + **custom-module** resolution across the background-thread boundary. This is an existing, documented hazard (`NativeLib.java:386-390,471-475`) and stays as-is: streaming against a resolver-backed engine still fails closed (returns "not found") for custom modules reached from the background thread; built-in modules continue to resolve normally in all cases. + +## Alternatives Considered + +**Separate GraalVM isolates per engine (rejected).** Each engine gets its own isolate — the most complete form of isolation (own heap, own JIT, own Java statics), and what Python already does per-instance. Rejected for Node because: +- `addon.c` currently assumes exactly one isolate as global state (`g_isolate`, `g_thread`, `g_ref_count`); supporting N isolates means restructuring all of that into per-handle structs. +- Isolate teardown is documented as fragile: `graal_tear_down_isolate` blocks until every attached thread reaches a safepoint (`addon.c:172-178`), and multiple concurrent isolates multiply that fragility. +- It is unnecessarily heavy for the actual need: independent module resolution and script caching, not full JVM-level sandboxing between tenants. + +**Chosen: object-level engines in one shared isolate.** Multiple `DWScriptingEngine` Java objects, each with its own resolver and compiled-script cache, all living in the single existing GraalVM isolate, addressed by an opaque handle. This mirrors what `native-cli` already does and requires no changes to isolate lifecycle management. + +## Architecture + +Three-layer change, following the existing callback/FFI layering. + +### Layer 1 — Java (`native-lib/src/main/java/org/mule/weave/lib/`) + +**`ScriptRuntime.java`** — from static singleton to per-instance + registry: +- Constructor becomes `ScriptRuntime(CallbackWeaveResourceResolver resolver)` (null ⇒ ClassLoader-only resolver, same as today's default). The resolver is now bound once at construction — immutable for the instance's lifetime. **Remove** the `static setResolver` write-once mutation entirely. +- Add a static registry: + ```java + private static final ConcurrentHashMap REGISTRY = new ConcurrentHashMap<>(); + private static final AtomicLong NEXT_HANDLE = new AtomicLong(1); + + static long register(ScriptRuntime rt) { + long handle = NEXT_HANDLE.getAndIncrement(); + REGISTRY.put(handle, rt); + return handle; + } + static ScriptRuntime get(long handle) { return REGISTRY.get(handle); } + static void destroy(long handle) { REGISTRY.remove(handle); } + ``` +- `compositeResolver()` / `createModuleComponentsFactory()` become instance methods operating on the instance's own resolver field instead of a static field. +- **Keep `getInstance()`** returning a lazily-created default (ClassLoader-only, handle-less) instance, so the existing resolver-less `@CEntryPoint`s (`run_script`, `run_script_callback`, `run_script_input_output_callback`) — used by the Python binding — are untouched. + +**`CallbackWeaveResourceResolver.java`** — store a `PointerBase ctx` alongside the callback, forwarded on every `callback.invoke(...)` call (see Layer 2 crux below). Constructor becomes `(ResolveModuleCallback callback, PointerBase ctx)`. + +**`NativeCallbacks.java`** — add a context parameter to the resolver callback, mirroring the existing `WriteCallback`/`ReadCallback` `ctx` idiom (`:31-49`): +```java +public interface ResolveModuleCallback extends CFunctionPointer { + @InvokeCFunctionPointer + CCharPointer invoke(IsolateThread thread, PointerBase ctx, CCharPointer modulePath); +} +``` +This is what lets one shared native callback dispatch to the correct per-handle JS resolver on the C side. + +**`NativeLib.java`** — add lifecycle + handle-based execution entrypoints; keep all existing entrypoints unchanged for Python: +- `create_engine(IsolateThread) -> long` +- `create_engine_with_resolver(IsolateThread, ResolveModuleCallback, PointerBase ctx) -> long` +- `destroy_engine(IsolateThread, long handle)` +- `run_script_engine(IsolateThread, long handle, CCharPointer script, CCharPointer inputs) -> CCharPointer` +- `run_script_callback_engine(...)` / `run_script_input_output_callback_engine(...)` — same bodies as today's streaming methods, but resolving the `ScriptRuntime` via `ScriptRuntime.get(handle)` instead of `getInstance()`. + +The existing `run_script_with_resolver`, `run_script_callback_with_resolver`, and `run_script_input_output_callback_with_resolver` entrypoints (`NativeLib.java:348-566`) are **removed** — they are not called from any stable release path (per their own doc comments) and their functionality is fully subsumed by `create_engine_with_resolver` + the handle-based run methods. + +### Layer 2 — C addon (`native-lib/node/src/addon.c`) + +- Replace the process-global resolver bridge state (`g_resolver_env`, `g_resolver_ref`, `g_resolver_thread`, `:73-86`) with a small per-handle registry: `{ napi_env env; napi_ref resolver_js; uv_thread_t owner; }` keyed by handle (a fixed-size array or linked list is sufficient — engine counts per process are expected to be small). +- **Crux — dispatching to the right resolver.** `ResolveModuleCallback` gains a `ctx` parameter (Layer 1). `createEngineWithResolver` allocates the per-handle bridge struct and passes its address as `ctx` down through `create_engine_with_resolver`. When Java invokes `resolve_module_callback(thread, ctx, path)`, C casts `ctx` back to the bridge struct and calls the JS resolver it holds — synchronously on the JS thread, exactly as today (no `napi_threadsafe_function`; the existing deadlock rationale at `:62-72` still applies, since `createEngineWithResolver`'s native call runs synchronously on the calling JS thread). +- Keep the thread-affinity guard, now scoped per-handle: if `resolve_module_callback` is reached from a thread other than the bridge's recorded `owner` (e.g. from `streaming_thread_fn`/`transform_thread_fn`), fail closed — return "not found" — instead of touching `napi_env` from the wrong thread. This preserves today's safety property, just per-engine instead of process-wide. +- Reuse the existing per-call result-buffer tracking (`resolver_results_track`/`resolver_results_free_all`, `:94-118`) unchanged — it is already scoped to a single native call. +- New N-API methods: `createEngine()`, `createEngineWithResolver(resolverFn)`, `destroyEngine(handle)`, and handle-taking `runScriptEngine`, `runScriptStreamingEngine`, `runScriptTransformEngine` — each attaches/detaches an isolate thread exactly like the current per-call pattern (`fn_attach_thread`/`fn_detach_thread`). + +### Layer 3 — Node TypeScript (`native-lib/node/src/`) + +**`ffi.ts`** — add `createEngine()`, `createEngineWithResolver(resolver)`, `destroyEngine(handle)`, and handle-taking `runScriptEngine`, `runScriptStreamingEngine`, `runScriptTransformEngine`. Remove `runWithResolver`. + +**`dataweave.ts`** — `DataWeave` gains a `private engineHandle?: number`: +- `initialize()`: after `ffi.initialize()`, call `ffi.createEngineWithResolver(this.resolveModule)` if a resolver was supplied at construction, else `ffi.createEngine()`; store the returned handle. +- `run()` / `runStreaming()` / `runTransform()`: always route through the handle-based FFI methods, passing `this.engineHandle`. Drop the `if (this.resolveModule) { ffi.runWithResolver(...) } else { ffi.runScript(...) }` branch (current `dataweave.ts:123-129`) — there is now exactly one code path per method, parameterized by handle. +- `cleanup()`: call `ffi.destroyEngine(this.engineHandle)` before releasing the library reference. +- Update the `resolveModule` docstring (`dataweave.ts:20-48`): remove the "one resolver per process / first instance wins / different-thread" caveats (`:26-42`) — this limitation is what this design fixes. Keep the synchronous-resolver requirement and the security/trust-model note (`:44-46`). + +## Data Flow + +``` +new DataWeave({ resolveModule: A }).initialize() + → ffi.createEngineWithResolver(A) + → addon.c: createEngineWithResolver + allocate bridge_A { env, ref to A, owner=thisThread } + call create_engine_with_resolver(thread, resolve_module_callback, &bridge_A) + → Java: new CallbackWeaveResourceResolver(callback, ctx=&bridge_A) + new ScriptRuntime(resolver) → handle_A = ScriptRuntime.register(rt) + → returns handle_A to JS, stored as this.engineHandle + +dwA.run(script importing "custom/lib.dwl") + → ffi.runScriptEngine(handle_A, script, inputs) + → Java: ScriptRuntime.get(handle_A).run(...) + compositeResolver: ClassLoader (miss) → CallbackWeaveResourceResolver + callback.invoke(thread, ctx=&bridge_A, "custom/lib.dwl") + → C: resolve_module_callback(thread, &bridge_A, path) + cast ctx → bridge_A; thread == bridge_A.owner? yes + call bridge_A.resolver_js(path) synchronously → resolver A's source + → result flows back through Java, script compiles + +// Second, independent instance in the SAME process: +new DataWeave({ resolveModule: B }).initialize() → handle_B, bridge_B (different resolver, different owner-checked bridge) +dwB.run(script importing "custom/lib.dwl") + → resolves via resolver B, NOT resolver A — no cross-talk, and A's cache is untouched +``` + +## Error Handling + +Unchanged from the existing resolver design (`ScriptRuntime` compositeResolver, `CallbackWeaveResourceResolver.resolve`) except scoped per-handle: +- **Module not found:** resolver returns `null` → `Option.empty()` → composite resolver falls through → standard DataWeave "unable to resolve module" error, same as today. +- **Resolver throws / callback fails:** caught in `CallbackWeaveResourceResolver.resolve`'s existing try/catch, logged, treated as not-found — unchanged. +- **Wrong-thread resolver invocation (streaming/transform against a resolver-backed engine):** the per-handle `owner` check in `addon.c` fails closed to "not found" instead of touching `napi_env` cross-thread. This is the same safety property as today's process-wide guard, just correctly scoped to the specific engine instance instead of the whole process. +- **Invalid/unknown handle** (`run_script_engine` called after `destroy_engine`, or with a bogus value): `ScriptRuntime.get(handle)` returns `null`; the `@CEntryPoint` returns a `{"success":false,"error":"Unknown engine handle"}` JSON error rather than throwing an NPE. + +## Backward Compatibility + +- **Python binding:** zero changes. It never called the `*_with_resolver` entrypoints being removed, and continues using `run_script`/`run_script_callback`/`run_script_input_output_callback` against the default `getInstance()` runtime. +- **Node, resolver-less usage:** `new DataWeave()` with no `resolveModule` behaves identically — `initialize()` calls `createEngine()` (no resolver), execution unchanged from the caller's perspective. +- **Node, single-resolver usage:** existing tests that construct exactly one `DataWeave({ resolveModule })` per process continue to pass — the new code path is functionally a superset (it now also supports a second, independent instance). +- **Breaking (internal-only) change:** `ResolveModuleCallback`'s native signature gains a `ctx` parameter. This is an internal FFI contract with no external callers documented outside this repo (the Node addon is the sole consumer), so it is not a public API break. + +## Testing Strategy + +1. **Java unit test** (`native-lib:test`, new test class alongside `ScriptRuntime`): register two `ScriptRuntime` instances with different in-memory `CallbackWeaveResourceResolver`s; assert each instance's `run()` resolves only its own module; assert `destroy()` removes an instance so `get()` returns `null` afterward. +2. **Node integration test** (`native-lib:nodeTest`) — the direct W-23692110 regression: construct two `DataWeave` instances in the same process with different `modulesFromMap` resolvers; assert each `run()` resolves its own import and fails to resolve the other's; assert built-in modules (e.g. `dw::core::Strings`) resolve correctly through both. +3. **Backward-compat regression:** existing resolver-less and single-resolver Node tests continue to pass unchanged. Full Python test suite (`native-lib:pythonTest`) passes unchanged (no Python-facing code touched). +4. **Native image build:** `./gradlew native-lib:nativeCompile` stays green; check build output for any new `--initialize-at-run-time` requirement introduced by the registry (`ConcurrentHashMap`/`AtomicLong` are standard JDK classes already used elsewhere in this codebase, so none expected). + +## Follow-Up Work + +- **Python binding parity:** file a GUS work item (child of W-23692110) to port the handle-based `create_engine`/`run_script_engine` API to the Python binding, so both bindings share one mental model instead of Python's implicit "one isolate per instance" and Node's explicit "one handle per instance." +- **Streaming/transform + custom-module resolution:** the cross-thread hazard preventing custom-module resolution during streaming/transform (documented in `NativeLib.java`) is unrelated to the singleton fix and remains a separate, not-yet-scoped effort. + +## References + +| Item | Location | +|------|----------| +| GUS ticket | W-23692110 | +| Singleton root cause | `native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java:33-45` | +| Write-once resolver guard | `native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java:58-63` | +| CLI's per-instance pattern (proof it's not a GraalVM constraint) | `native-cli/src/main/scala/org/mule/weave/dwnative/NativeRuntime.scala:50-60` | +| Existing resolver-aware entrypoints (to be removed) | `native-lib/src/main/java/org/mule/weave/lib/NativeLib.java:348-566` | +| Existing WriteCallback/ReadCallback ctx idiom | `native-lib/src/main/java/org/mule/weave/lib/NativeCallbacks.java:31-49` | +| C addon process-global resolver bridge (to be made per-handle) | `native-lib/node/src/addon.c:62-118` | +| Documented streaming/transform cross-thread hazard | `native-lib/src/main/java/org/mule/weave/lib/NativeLib.java:386-390,471-475` | +| Node dataweave.ts resolver caveats (to be removed) | `native-lib/node/src/dataweave.ts:26-46` | +| Original external-modules design (where this limitation was discovered) | `docs/superpowers/specs/2026-08-04-nodejs-external-modules-design.md` | From 8636b9e4b9a9a180e4d13663997638f33dd806df Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 10:22:31 -0300 Subject: [PATCH 02/63] W-23692110: handle-keyed ScriptRuntime registry with per-engine resolvers --- .../lib/CallbackWeaveResourceResolver.java | 6 +- .../org/mule/weave/lib/NativeCallbacks.java | 2 +- .../java/org/mule/weave/lib/NativeLib.java | 346 +++++++----------- .../org/mule/weave/lib/ScriptRuntime.java | 113 +++--- .../org/mule/weave/lib/ScriptRuntimeTest.java | 77 ++++ 5 files changed, 278 insertions(+), 266 deletions(-) diff --git a/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java b/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java index d6b8091..9f85a7f 100644 --- a/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java +++ b/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java @@ -3,6 +3,7 @@ import org.graalvm.nativeimage.CurrentIsolate; import org.graalvm.nativeimage.c.type.CCharPointer; import org.graalvm.nativeimage.c.type.CTypeConversion; +import org.graalvm.word.PointerBase; import org.mule.weave.v2.parser.ast.variables.NameIdentifier; import org.mule.weave.v2.sdk.NameIdentifierHelper; import org.mule.weave.v2.sdk.WeaveResource; @@ -20,12 +21,14 @@ */ public class CallbackWeaveResourceResolver implements WeaveResourceResolver { private final NativeCallbacks.ResolveModuleCallback callback; + private final PointerBase ctx; - public CallbackWeaveResourceResolver(NativeCallbacks.ResolveModuleCallback callback) { + public CallbackWeaveResourceResolver(NativeCallbacks.ResolveModuleCallback callback, PointerBase ctx) { if (callback.isNull()) { throw new IllegalArgumentException("Resolver callback cannot be null"); } this.callback = callback; + this.ctx = ctx; } @Override @@ -42,6 +45,7 @@ public Option resolve(NameIdentifier nameIdentifier) { // Invoke callback (blocks if threadsafe function is in use) CCharPointer resultPtr = callback.invoke( CurrentIsolate.getCurrentThread(), + ctx, pathPtr ); diff --git a/native-lib/src/main/java/org/mule/weave/lib/NativeCallbacks.java b/native-lib/src/main/java/org/mule/weave/lib/NativeCallbacks.java index 3e993c7..2deaddd 100644 --- a/native-lib/src/main/java/org/mule/weave/lib/NativeCallbacks.java +++ b/native-lib/src/main/java/org/mule/weave/lib/NativeCallbacks.java @@ -55,6 +55,6 @@ public interface ReadCallback extends CFunctionPointer { */ public interface ResolveModuleCallback extends CFunctionPointer { @InvokeCFunctionPointer - CCharPointer invoke(IsolateThread thread, CCharPointer modulePath); + CCharPointer invoke(IsolateThread thread, PointerBase ctx, CCharPointer modulePath); } } diff --git a/native-lib/src/main/java/org/mule/weave/lib/NativeLib.java b/native-lib/src/main/java/org/mule/weave/lib/NativeLib.java index 549ea3a..30b1ed6 100644 --- a/native-lib/src/main/java/org/mule/weave/lib/NativeLib.java +++ b/native-lib/src/main/java/org/mule/weave/lib/NativeLib.java @@ -89,6 +89,17 @@ public static CCharPointer runScriptCallback( String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); ScriptRuntime runtime = ScriptRuntime.getInstance(); + return streamToWriteCallback(runtime, dwScript, inputs, writeCallback, ctx); + } + + /** + * Runs the streaming write-callback loop shared by the legacy singleton entrypoint + * ({@link #runScriptCallback}) and the per-engine entrypoint + * ({@link #runScriptCallbackEngine}). + */ + private static CCharPointer streamToWriteCallback( + ScriptRuntime runtime, String dwScript, String inputs, + NativeCallbacks.WriteCallback writeCallback, PointerBase ctx) { StreamSession session = runtime.runStreaming(dwScript, inputs); if (session.isError()) { @@ -170,6 +181,22 @@ public static CCharPointer runScriptInputOutputCallback( String inMime = CTypeConversion.toJavaString(inputMimeType); String inCharset = inputCharset.isNull() ? null : CTypeConversion.toJavaString(inputCharset); + ScriptRuntime runtime = ScriptRuntime.getInstance(); + return transformViaCallbacks(runtime, dwScript, inputs, inName, inMime, inCharset, + readCallback, writeCallback, ctx); + } + + /** + * Runs the input-feeder + output-streaming loop shared by the legacy singleton entrypoint + * ({@link #runScriptInputOutputCallback}) and the per-engine entrypoint + * ({@link #runScriptInputOutputCallbackEngine}). + */ + private static CCharPointer transformViaCallbacks( + ScriptRuntime runtime, String dwScript, String inputs, + String inName, String inMime, String inCharset, + NativeCallbacks.ReadCallback readCallback, NativeCallbacks.WriteCallback writeCallback, + PointerBase ctx) { + // Create a piped input stream session for the callback-supplied input InputStreamSession inputSession = new InputStreamSession(inMime, inCharset); long inputHandle = inputSession.register(); @@ -191,7 +218,6 @@ public static CCharPointer runScriptInputOutputCallback( feeder.start(); // Execute the script and stream output via the writeCallback - ScriptRuntime runtime = ScriptRuntime.getInstance(); StreamSession session = runtime.runStreaming(dwScript, mergedInputs); if (session.isError()) { @@ -330,239 +356,139 @@ private static CCharPointer toUnmanagedCString(String value) { return ptr; } - // ── Resolver-aware FFI Entrypoints ─────────────────────────────────── + // ── Multi-Engine FFI Entrypoints (W-23692110) ──────────────────────── /** - * Runs a DataWeave script with module resolver callback. + * Creates a new isolated engine (ClassLoader-only resolver) and returns its handle. * - *

This variant accepts a {@link NativeCallbacks.ResolveModuleCallback} to resolve - * external modules during script execution. The resolver is installed before script - * execution and remains active for the lifetime of the process.

+ * @param thread the isolate thread + * @return a non-zero handle identifying the new engine + */ + @CEntryPoint(name = "create_engine") + public static long createEngine(IsolateThread thread) { + return ScriptRuntime.register(new ScriptRuntime(null)); + } + + /** + * Creates a new isolated engine backed by a caller-supplied module resolver callback, + * and returns its handle. * - * @param thread GraalVM isolate thread - * @param script DataWeave script source (C string) - * @param inputsJson JSON string of inputs (C string) - * @param resolverCallback Callback for resolving external modules - * @return JSON result or error message (unmanaged C string, must be freed) + * @param thread the isolate thread + * @param resolverCallback callback used to resolve external modules for this engine only + * @param ctx opaque context pointer forwarded to every resolver invocation + * @return a non-zero handle identifying the new engine */ - @CEntryPoint(name = "run_script_with_resolver") - public static CCharPointer runScriptWithResolver( + @CEntryPoint(name = "create_engine_with_resolver") + public static long createEngineWithResolver( IsolateThread thread, - CCharPointer script, - CCharPointer inputsJson, - NativeCallbacks.ResolveModuleCallback resolverCallback) { - - try { - // Install resolver (idempotent if already set) - ScriptRuntime.setResolver(resolverCallback); - - // Delegate to existing run logic - String dwScript = CTypeConversion.toJavaString(script); - String inputs = CTypeConversion.toJavaString(inputsJson); + NativeCallbacks.ResolveModuleCallback resolverCallback, + PointerBase ctx) { + CallbackWeaveResourceResolver resolver = + new CallbackWeaveResourceResolver(resolverCallback, ctx); + return ScriptRuntime.register(new ScriptRuntime(resolver)); + } - ScriptRuntime runtime = ScriptRuntime.getInstance(); - String result = runtime.run(dwScript, inputs); - return toUnmanagedCString(result); - } catch (Exception e) { - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(e.getMessage()) + "\"}"); - } + /** + * Destroys an engine created by {@link #createEngine} / {@link #createEngineWithResolver}. + * A no-op if the handle is unknown or already destroyed. + * + * @param thread the isolate thread + * @param handle the engine handle to remove + */ + @CEntryPoint(name = "destroy_engine") + public static void destroyEngine(IsolateThread thread, long handle) { + ScriptRuntime.destroy(handle); } /** - * Runs a DataWeave script with streaming output and module resolver. + * Executes a DataWeave script against a specific engine. * - *

This variant combines streaming output via a write callback with external module - * resolution. The resolver is installed before script execution.

+ *

If {@code handle} does not identify a live engine, returns + * {@code {"success":false,"error":"Unknown engine handle"}} rather than throwing.

* - * @param thread GraalVM isolate thread - * @param script DataWeave script source (C string) + * @param thread the isolate thread + * @param handle the target engine's handle + * @param script the DataWeave script (C string) * @param inputsJson JSON-encoded inputs map (C string), may be null - * @param writeCallback function pointer invoked with each output chunk - * @param ctx opaque context pointer forwarded to callback - * @param resolverCallback Callback for resolving external modules - * @return an unmanaged C string with JSON metadata/error (must be freed) - * - *

NOTE: compiled/linked but intentionally NOT invoked from the Node binding's - * TypeScript layer. runStreaming() deliberately uses the resolver-less streaming entrypoint - * instead: streaming runs its native call on a background thread, and wiring a resolver - * callback there would call back into JS from a non-owning OS thread (undefined behavior / - * crash). Do not wire this up without first solving that cross-thread hazard.

+ * @return the script execution result (unmanaged C string, must be freed) */ - @CEntryPoint(name = "run_script_callback_with_resolver") - public static CCharPointer runScriptCallbackWithResolver( - IsolateThread thread, - CCharPointer script, - CCharPointer inputsJson, - NativeCallbacks.WriteCallback writeCallback, - PointerBase ctx, - NativeCallbacks.ResolveModuleCallback resolverCallback) { - - try { - // Install resolver - ScriptRuntime.setResolver(resolverCallback); - - // Delegate to existing streaming logic - String dwScript = CTypeConversion.toJavaString(script); - String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); - - ScriptRuntime runtime = ScriptRuntime.getInstance(); - StreamSession session = runtime.runStreaming(dwScript, inputs); - - if (session.isError()) { - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(session.getError()) + "\"}"); - } - - try { - byte[] buf = new byte[CALLBACK_BUFFER_SIZE]; - CCharPointer nativeBuf = UnmanagedMemory.malloc(CALLBACK_BUFFER_SIZE); - try { - int n; - while ((n = session.read(buf, buf.length)) > 0) { - for (int i = 0; i < n; i++) { - nativeBuf.write(i, buf[i]); - } - int rc = writeCallback.invoke(ctx, nativeBuf, n); - if (rc != 0) { - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + "Write callback returned error: " + rc + "\"}"); - } - } - } finally { - UnmanagedMemory.free(nativeBuf); - } - } catch (IOException e) { - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(e.getMessage()) + "\"}"); - } finally { - session.closeStream(); - } + @CEntryPoint(name = "run_script_engine") + public static CCharPointer runScriptEngine( + IsolateThread thread, long handle, CCharPointer script, CCharPointer inputsJson) { + ScriptRuntime runtime = ScriptRuntime.get(handle); + if (runtime == null) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Unknown engine handle\"}"); + } + String dwScript = CTypeConversion.toJavaString(script); + String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); + return toUnmanagedCString(runtime.run(dwScript, inputs)); + } - return toUnmanagedCString("{\"success\":true" - + ",\"mimeType\":\"" + session.getMimeType() + "\"" - + ",\"charset\":\"" + session.getCharset() + "\"" - + ",\"binary\":" + session.isBinary() - + "}"); - } catch (Exception e) { - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(e.getMessage()) + "\"}"); + /** + * Executes a DataWeave script against a specific engine, streaming the result to a + * caller-supplied write callback. See {@link #runScriptCallback} for the callback contract. + * + *

If {@code handle} does not identify a live engine, returns + * {@code {"success":false,"error":"Unknown engine handle"}} rather than throwing.

+ * + * @param thread the isolate thread + * @param handle the target engine's handle + * @param script the DataWeave script (C string) + * @param inputsJson JSON-encoded inputs map (C string), may be null + * @param writeCallback function pointer invoked with each output chunk; must return 0 on success + * @param ctx opaque context pointer forwarded to every callback invocation + * @return an unmanaged C string with JSON metadata/error + */ + @CEntryPoint(name = "run_script_callback_engine") + public static CCharPointer runScriptCallbackEngine( + IsolateThread thread, long handle, CCharPointer script, CCharPointer inputsJson, + NativeCallbacks.WriteCallback writeCallback, PointerBase ctx) { + ScriptRuntime runtime = ScriptRuntime.get(handle); + if (runtime == null) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Unknown engine handle\"}"); } + String dwScript = CTypeConversion.toJavaString(script); + String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); + return streamToWriteCallback(runtime, dwScript, inputs, writeCallback, ctx); } /** - * Runs a DataWeave script with streaming input/output and module resolver. + * Executes a DataWeave script against a specific engine, with a callback-supplied input + * and callback-streamed output. See {@link #runScriptInputOutputCallback} for the callback + * contract. * - *

This variant combines streaming input via read callback, streaming output via write - * callback, and external module resolution. The resolver is installed before script execution.

+ *

If {@code handle} does not identify a live engine, returns + * {@code {"success":false,"error":"Unknown engine handle"}} rather than throwing.

* - * @param thread GraalVM isolate thread - * @param script DataWeave script source (C string) - * @param inputsJson JSON-encoded inputs map (C string), may be null - * @param inputName the binding name for the callback-supplied input (C string) + * @param thread the isolate thread + * @param handle the target engine's handle + * @param script the DataWeave script (C string) + * @param inputsJson JSON-encoded inputs map (C string), may be null + * @param inputName the binding name for the callback-supplied input (C string) * @param inputMimeType the MIME type of the callback-supplied input (C string) - * @param inputCharset the charset of the callback-supplied input (C string), may be null - * @param readCallback function pointer invoked to read input chunks - * @param writeCallback function pointer invoked with output chunks - * @param ctx opaque context pointer forwarded to callbacks - * @param resolverCallback Callback for resolving external modules - * @return an unmanaged C string with JSON metadata/error (must be freed) - * - *

NOTE: compiled/linked but intentionally NOT invoked from the Node binding's - * TypeScript layer. runTransform() deliberately uses the resolver-less transform entrypoint - * instead: transform runs its native call on a background thread, and wiring a resolver - * callback there would call back into JS from a non-owning OS thread (undefined behavior / - * crash). Do not wire this up without first solving that cross-thread hazard.

+ * @param inputCharset the charset of the callback-supplied input (C string), may be null for UTF-8 + * @param readCallback function pointer invoked to read the next chunk + * @param writeCallback function pointer invoked with each output chunk; must return 0 on success + * @param ctx opaque context pointer forwarded to every callback invocation + * @return an unmanaged C string with JSON metadata/error */ - @CEntryPoint(name = "run_script_input_output_callback_with_resolver") - public static CCharPointer runScriptInputOutputCallbackWithResolver( - IsolateThread thread, - CCharPointer script, - CCharPointer inputsJson, - CCharPointer inputName, - CCharPointer inputMimeType, - CCharPointer inputCharset, - NativeCallbacks.ReadCallback readCallback, - NativeCallbacks.WriteCallback writeCallback, - PointerBase ctx, - NativeCallbacks.ResolveModuleCallback resolverCallback) { - - try { - // Install resolver - ScriptRuntime.setResolver(resolverCallback); - - // Delegate to existing streaming I/O logic - String dwScript = CTypeConversion.toJavaString(script); - String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); - String inName = CTypeConversion.toJavaString(inputName); - String inMime = CTypeConversion.toJavaString(inputMimeType); - String inCharset = inputCharset.isNull() ? null : CTypeConversion.toJavaString(inputCharset); - - // Create a piped input stream session for the callback-supplied input - InputStreamSession inputSession = new InputStreamSession(inMime, inCharset); - long inputHandle = inputSession.register(); - - // Merge the stream handle into the inputs JSON - String streamEntry = "{\"streamHandle\":\"" + inputHandle + "\",\"mimeType\":\"" + inMime + "\"" - + (inCharset != null ? ",\"charset\":\"" + inCharset + "\"" : "") + "}"; - String mergedInputs = mergeInputEntry(inputs, inName, streamEntry); - - // Start background thread for reading input - final long readCallbackAddr = readCallback.rawValue(); - final long ctxAddr = ctx.rawValue(); - Thread feeder = new Thread(new InputCallbackFeeder( - readCallbackAddr, ctxAddr, inputSession), "dw-input-callback-feeder"); - feeder.setDaemon(true); - feeder.start(); - - // Execute the script and stream output via the writeCallback - ScriptRuntime runtime = ScriptRuntime.getInstance(); - StreamSession session = runtime.runStreaming(dwScript, mergedInputs); - - if (session.isError()) { - cleanupFeeder(feeder, inputHandle); - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(session.getError()) + "\"}"); - } - - try { - byte[] buf = new byte[CALLBACK_BUFFER_SIZE]; - CCharPointer writeBuf = UnmanagedMemory.malloc(CALLBACK_BUFFER_SIZE); - try { - int n; - while ((n = session.read(buf, buf.length)) > 0) { - for (int i = 0; i < n; i++) { - writeBuf.write(i, buf[i]); - } - int rc = writeCallback.invoke(ctx, writeBuf, n); - if (rc != 0) { - cleanupFeeder(feeder, inputHandle); - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + "Write callback returned error: " + rc + "\"}"); - } - } - } finally { - UnmanagedMemory.free(writeBuf); - } - } catch (IOException e) { - cleanupFeeder(feeder, inputHandle); - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(e.getMessage()) + "\"}"); - } finally { - session.closeStream(); - } - - cleanupFeeder(feeder, inputHandle); - - return toUnmanagedCString("{\"success\":true" - + ",\"mimeType\":\"" + session.getMimeType() + "\"" - + ",\"charset\":\"" + session.getCharset() + "\"" - + ",\"binary\":" + session.isBinary() - + "}"); - } catch (Exception e) { - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(e.getMessage()) + "\"}"); + @CEntryPoint(name = "run_script_input_output_callback_engine") + public static CCharPointer runScriptInputOutputCallbackEngine( + IsolateThread thread, long handle, CCharPointer script, CCharPointer inputsJson, + CCharPointer inputName, CCharPointer inputMimeType, CCharPointer inputCharset, + NativeCallbacks.ReadCallback readCallback, NativeCallbacks.WriteCallback writeCallback, + PointerBase ctx) { + ScriptRuntime runtime = ScriptRuntime.get(handle); + if (runtime == null) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Unknown engine handle\"}"); } + String dwScript = CTypeConversion.toJavaString(script); + String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); + String inName = CTypeConversion.toJavaString(inputName); + String inMime = CTypeConversion.toJavaString(inputMimeType); + String inCharset = inputCharset.isNull() ? null : CTypeConversion.toJavaString(inputCharset); + return transformViaCallbacks(runtime, dwScript, inputs, inName, inMime, inCharset, + readCallback, writeCallback, ctx); } } diff --git a/native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java b/native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java index 3371127..d8db13b 100644 --- a/native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java +++ b/native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java @@ -20,9 +20,16 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.util.Base64; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; /** - * Singleton wrapper around a {@link DWScriptingEngine} used to compile and execute DataWeave scripts. + * Wrapper around a {@link DWScriptingEngine} used to compile and execute DataWeave scripts. + * + *

Each {@link ScriptRuntime} instance owns its own engine (and therefore its own module + * resolver and script cache), so multiple isolated engines can coexist within one process. + * Instances are tracked in a handle-keyed registry so native callers can address a specific + * engine by an opaque {@code long} handle.

* *

Execution results are returned as a JSON string containing a base64-encoded payload plus metadata * (mime type, charset, and whether the result is binary). Errors are returned as a JSON string with @@ -30,84 +37,82 @@ */ public class ScriptRuntime { - private static final ScriptRuntime INSTANCE = new ScriptRuntime(); + // ── Handle registry ────────────────────────────────────────────────── + private static final ConcurrentHashMap REGISTRY = new ConcurrentHashMap<>(); + private static final AtomicLong NEXT_HANDLE = new AtomicLong(1); + + /** Registers a runtime and returns its non-zero handle. */ + public static long register(ScriptRuntime runtime) { + long handle = NEXT_HANDLE.getAndIncrement(); + REGISTRY.put(handle, runtime); + return handle; + } + + /** Returns the runtime for a handle, or {@code null} if unknown/destroyed. */ + public static ScriptRuntime get(long handle) { + return REGISTRY.get(handle); + } + + /** Removes a runtime; returns {@code true} if one was present. */ + public static boolean destroy(long handle) { + return REGISTRY.remove(handle) != null; + } - // Static field for callback resolver, volatile for thread-safe double-checked locking - private static volatile CallbackWeaveResourceResolver resolver = null; + // ── Legacy singleton (ClassLoader-only) for Python entrypoints ──────── + private static volatile ScriptRuntime defaultInstance = null; /** - * Returns the singleton instance. + * Returns the process-wide legacy singleton instance (ClassLoader-only resolver). * * @return the shared {@link ScriptRuntime} */ public static ScriptRuntime getInstance() { - return INSTANCE; + ScriptRuntime local = defaultInstance; + if (local == null) { + synchronized (ScriptRuntime.class) { + local = defaultInstance; + if (local == null) { + local = new ScriptRuntime(null); + defaultInstance = local; + } + } + } + return local; } + // ── Per-instance engine ─────────────────────────────────────────────── + private final DWScriptingEngine engine; + /** - * Sets the module resolver callback and rebuilds the engine. - * Can only be called once per process (engine is a singleton). - * Thread-safe but should be called early in application lifecycle before script execution. - * - *

IMPORTANT: The callback function must be thread-safe if using - * GraalVM's threadsafe function pointers, as it may be invoked from multiple threads - * during concurrent module resolution.

+ * Builds an engine whose resolver is Composite(ClassLoader-built-ins + {@code customResolver}); + * a null {@code customResolver} yields ClassLoader-only. * - * @param callback Thread-safe function pointer for resolving modules + * @param customResolver additional resolver for user-supplied modules, or {@code null} */ - public static synchronized void setResolver(NativeCallbacks.ResolveModuleCallback callback) { - if (resolver != null) { - System.err.println("WARNING: Module resolver already set for this process. " + - "Only one resolver configuration is supported. Ignoring new resolver."); - return; - } - - if (callback.isNull()) { - System.err.println("WARNING: Attempted to set null resolver, ignoring."); - return; - } - - resolver = new CallbackWeaveResourceResolver(callback); - - // Rebuild engine with composite resolver (built-ins + callback) - synchronized (INSTANCE) { - INSTANCE.engine = DWScriptingEngine.builder() - .withDWModuleComponentsFactory(createModuleComponentsFactory()) - .build(); - } + public ScriptRuntime(WeaveResourceResolver customResolver) { + this.engine = DWScriptingEngine.builder() + .withDWModuleComponentsFactory(createModuleComponentsFactory(customResolver)) + .build(); } /** - * Creates composite resolver: ClassLoader (built-ins) + Callback (user modules). - * If no callback resolver is set, returns ClassLoader only. + * Creates composite resolver: ClassLoader (built-ins) + custom (user modules). + * If no custom resolver is provided, returns ClassLoader only. */ - private static WeaveResourceResolver compositeResolver() { + private static WeaveResourceResolver compositeResolver(WeaveResourceResolver customResolver) { WeaveResourceResolver classLoaderResolver = ClassLoaderWeaveResourceResolver.apply(); - - CallbackWeaveResourceResolver currentResolver = resolver; - if (currentResolver == null) { + if (customResolver == null) { return classLoaderResolver; } - return CompositeWeaveResourceResolver.apply( classLoaderResolver, // Try built-ins first - currentResolver // Then callback for user modules + customResolver // Then callback for user modules ); } - private static DWModuleComponentsFactory createModuleComponentsFactory() { + private static DWModuleComponentsFactory createModuleComponentsFactory(WeaveResourceResolver customResolver) { return DWModuleComponentsFactory.createSimpleDWModuleComponentsFactoryBuilder() - .withWeaveResourceResolver(compositeResolver()) - .build(); - } - - // Instance field for the scripting engine, access synchronized in setResolver - private volatile DWScriptingEngine engine; - - private ScriptRuntime() { - // Initialize with ClassLoader-only resolver (no callback yet) - engine = DWScriptingEngine.builder() - .withDWModuleComponentsFactory(createModuleComponentsFactory()) + .withWeaveResourceResolver(compositeResolver(customResolver)) .build(); } diff --git a/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeTest.java b/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeTest.java index 70f8044..d3a2f3e 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeTest.java @@ -580,6 +580,83 @@ void callbackOutputStreamingError() { System.out.println("=".repeat(50)); } + // --- Multi-engine registry (W-23692110) --- + + /** In-memory WeaveResourceResolver fake — the JVM-constructable seam standing + * in for CallbackWeaveResourceResolver (a CFunctionPointer, which cannot be + * built in test mode). */ + static final class MapResolver + implements org.mule.weave.v2.sdk.WeaveResourceResolver { + private final java.util.Map modules; + MapResolver(java.util.Map modules) { this.modules = modules; } + + @Override + public scala.Option resolve( + org.mule.weave.v2.parser.ast.variables.NameIdentifier id) { + String path = org.mule.weave.v2.sdk.NameIdentifierHelper.toWeaveFilePath(id, "/"); + String key = path.startsWith("/") ? path.substring(1) : path; + String src = modules.get(key); + if (src == null) return scala.Option.empty(); + return scala.Option.apply(org.mule.weave.v2.sdk.WeaveResource.apply(path, src)); + } + + @Override + public scala.collection.immutable.Seq resolveAll( + org.mule.weave.v2.parser.ast.variables.NameIdentifier id) { + scala.Option r = resolve(id); + if (r.isDefined()) { + return scala.collection.JavaConverters + .asScalaBuffer(java.util.Collections.singletonList(r.get())).toList(); + } + return (scala.collection.immutable.Seq) + scala.collection.immutable.Seq$.MODULE$.empty(); + } + } + + private static final String IMPORT_A = + "%dw 2.0\nimport org::test::a\noutput application/json\n---\na::greet(\"X\")"; + private static final String IMPORT_B = + "%dw 2.0\nimport org::test::b\noutput application/json\n---\nb::greet(\"X\")"; + + @Test + void twoEnginesResolveOnlyTheirOwnModule() { + ScriptRuntime engineA = new ScriptRuntime(new MapResolver(java.util.Map.of( + "org/test/a.dwl", "%dw 2.0\nfun greet(n: String) = \"A:\" ++ n"))); + ScriptRuntime engineB = new ScriptRuntime(new MapResolver(java.util.Map.of( + "org/test/b.dwl", "%dw 2.0\nfun greet(n: String) = \"B:\" ++ n"))); + + long hA = ScriptRuntime.register(engineA); + long hB = ScriptRuntime.register(engineB); + assertNotNull(ScriptRuntime.get(hA)); + assertNotNull(ScriptRuntime.get(hB)); + + // Each engine resolves its own module... + assertEquals("\"A:X\"", Result.parse(ScriptRuntime.get(hA).run(IMPORT_A)).result); + assertEquals("\"B:X\"", Result.parse(ScriptRuntime.get(hB).run(IMPORT_B)).result); + + // ...and NOT the other's (no cross-talk). + assertNotNull(Result.parse(ScriptRuntime.get(hA).run(IMPORT_B)).error); + assertNotNull(Result.parse(ScriptRuntime.get(hB).run(IMPORT_A)).error); + + // destroy removes it; a fresh handle is distinct. + assertTrue(ScriptRuntime.destroy(hA)); + assertNull(ScriptRuntime.get(hA)); + assertFalse(ScriptRuntime.destroy(hA)); // already gone + assertNotNull(ScriptRuntime.get(hB)); + + ScriptRuntime.destroy(hB); + } + + @Test + void engineWithoutResolverStillRunsBuiltins() { + ScriptRuntime engine = new ScriptRuntime(null); // ClassLoader-only + long h = ScriptRuntime.register(engine); + String r = ScriptRuntime.get(h).run( + "%dw 2.0\nimport dw::core::Strings\noutput application/json\n---\nStrings::capitalize(\"hello\")"); + assertEquals("\"Hello\"", Result.parse(r).result); + ScriptRuntime.destroy(h); + } + static class Result { boolean success; String result; From 3e87d08e9382624ab7679dfa16019f658ec31c2b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 10:37:16 -0300 Subject: [PATCH 03/63] W-23692110: per-engine resolver bridge and handle-based N-API methods --- native-lib/node/src/addon.c | 477 +++++++++++++++++++----------------- 1 file changed, 248 insertions(+), 229 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 5aa3153..ed4ae2a 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -13,21 +13,19 @@ typedef void* (*run_script_fn)(void*, const char*, const char*); typedef void (*free_cstring_fn)(void*, void*); typedef int (*write_callback_t)(void* ctx, const char* buf, int len); typedef int (*read_callback_t)(void* ctx, char* buf, int buf_size); -typedef char* (*resolve_module_callback_t)(void* thread, const char* module_path); +typedef char* (*resolve_module_callback_t)(void* thread, void* ctx, const char* module_path); typedef void* (*run_script_callback_fn)(void*, const char*, const char*, write_callback_t, void*); typedef void* (*run_script_input_output_callback_fn)(void*, const char*, const char*, const char*, const char*, const char*, read_callback_t, write_callback_t, void*); -// Resolver-aware entrypoint types -// NOTE: run_script_with_resolver has no mimeType parameter on the native side -// (NativeLib.runScriptWithResolver(thread, script, inputsJson, resolverCallback) -// delegates to ScriptRuntime.run(script, inputsJson), which infers/hardcodes -// output mime type internally). The JS-facing mimeType argument is accepted -// for API symmetry with other entrypoints but is NOT forwarded across the FFI -// boundary — passing it here would misalign the native call's argument -// registers and corrupt the callback function pointer. -typedef char* (*run_script_with_resolver_fn)(void*, const char*, const char*, resolve_module_callback_t); -typedef void* (*run_script_callback_with_resolver_fn)(void*, const char*, const char*, const char*, write_callback_t, void*, resolve_module_callback_t); -typedef void* (*run_script_input_output_callback_with_resolver_fn)(void*, const char*, const char*, const char*, const char*, const char*, read_callback_t, write_callback_t, void*, resolve_module_callback_t); +// Per-engine entrypoint types. Handles are Java long values and MUST be C +// long long everywhere (plain long is 32-bit on Windows LLP64 and would +// truncate a 64-bit handle). +typedef long long (*create_engine_fn)(void*); +typedef long long (*create_engine_with_resolver_fn)(void*, resolve_module_callback_t, void*); +typedef void (*destroy_engine_fn)(void*, long long); +typedef void* (*run_script_engine_fn)(void*, long long, const char*, const char*); +typedef void* (*run_script_callback_engine_fn)(void*, long long, const char*, const char*, write_callback_t, void*); +typedef void* (*run_script_input_output_callback_engine_fn)(void*, long long, const char*, const char*, const char*, const char*, const char*, read_callback_t, write_callback_t, void*); // Global state static uv_lib_t g_lib; @@ -54,14 +52,28 @@ static free_cstring_fn fn_free_cstring = NULL; static run_script_callback_fn fn_run_script_callback = NULL; static run_script_input_output_callback_fn fn_run_script_input_output_callback = NULL; -// Resolver-aware entrypoints -static run_script_with_resolver_fn fn_run_script_with_resolver = NULL; -static run_script_callback_with_resolver_fn fn_run_script_callback_with_resolver = NULL; -static run_script_input_output_callback_with_resolver_fn fn_run_script_input_output_callback_with_resolver = NULL; +// Per-engine entrypoints +static create_engine_fn fn_create_engine = NULL; +static create_engine_with_resolver_fn fn_create_engine_with_resolver = NULL; +static destroy_engine_fn fn_destroy_engine = NULL; +static run_script_engine_fn fn_run_script_engine = NULL; +static run_script_callback_engine_fn fn_run_script_callback_engine = NULL; +static run_script_input_output_callback_engine_fn fn_run_script_input_output_callback_engine = NULL; + +// A single run may trigger resolve_module_callback multiple times (one script +// can import several modules). Native copies each returned buffer immediately, +// but the copy is made *after* our callback returns — we don't get a per-call +// "done freeing" signal, only "the whole run finished". So track every buffer +// allocated during one run and free them all once the native call returns. +typedef struct resolver_result_node { + char* buf; + struct resolver_result_node* next; +} resolver_result_node_t; -// Resolver bridge state (one resolver per process). +// Per-engine resolver bridge: one node per resolver-backed engine, passed to +// Java as the callback ctx word and forwarded back to resolve_module_callback. // -// Unlike the streaming/transform entrypoints, runWithResolver's native call +// Unlike the streaming/transform entrypoints, runScriptEngine's native call // executes synchronously on the very thread that invoked it from JS — no // background uv_thread is spawned. So when native code calls back into // resolve_module_callback(), we are already on the correct (JS) thread and @@ -70,51 +82,48 @@ static run_script_input_output_callback_with_resolver_fn fn_run_script_input_out // caller on a condition variable until it's serviced — but if the caller // *is* the JS thread, it can never service its own queued item, causing a // deadlock (a real bug fixed in this codebase — see Task 11 report). -static napi_env g_resolver_env = NULL; -static napi_ref g_resolver_ref = NULL; - -// The OS thread that first installed the resolver (see napi_run_with_resolver -// below). ScriptRuntime's engine is a process-wide singleton, so once a -// resolver is installed, resolve_module_callback() can be reached from ANY -// entrypoint that later compiles a script against that shared engine — -// including runScriptStreaming/runScriptTransform, whose native calls run on -// a background uv_thread (see streaming_thread_fn/transform_thread_fn), not -// the JS thread. napi_env/napi_ref are thread-affine; calling into them from -// a thread other than the one that created them is undefined behavior. We -// record the owning thread here so resolve_module_callback can detect the -// mismatch and fail closed (return "not found") instead of crashing. -static uv_thread_t g_resolver_thread; - -// A single runWithResolver call may trigger resolve_module_callback multiple -// times (one script can import several modules). Native copies each -// returned buffer immediately, but the copy is made *after* our callback -// returns — we don't get a per-call "done freeing" signal, only "the whole -// run finished". So track every buffer allocated during one call and free -// them all once fn_run_script_with_resolver returns. -typedef struct resolver_result_node { - char* buf; - struct resolver_result_node* next; -} resolver_result_node_t; -static resolver_result_node_t* g_resolver_results = NULL; - -static void resolver_results_track(char* buf) { - if (buf == NULL) return; +// +// napi_env/napi_ref are thread-affine; each bridge records the JS thread that +// created it (owner) so resolve_module_callback can detect a mismatch — e.g. a +// streamed/transform custom-module lookup arriving on the background uv_thread +// — and fail closed (return "not found") instead of crashing. +typedef struct engine_bridge { + long long handle; + napi_env env; + napi_ref resolver_js; // NULL => resolver-less engine (no bridge created) + uv_thread_t owner; // JS thread that created and must run this engine + resolver_result_node_t* results; // buffers to free after each run on this engine + struct engine_bridge* next; +} engine_bridge_t; +static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex + +static void resolver_results_track(engine_bridge_t* b, char* buf) { + if (b == NULL || buf == NULL) return; resolver_result_node_t* node = (resolver_result_node_t*)malloc(sizeof(resolver_result_node_t)); if (node == NULL) return; // Leak the buffer rather than crash; best-effort tracking. node->buf = buf; - node->next = g_resolver_results; - g_resolver_results = node; + node->next = b->results; + b->results = node; } -static void resolver_results_free_all(void) { - resolver_result_node_t* node = g_resolver_results; +static void resolver_results_free_all(engine_bridge_t* b) { + if (b == NULL) return; + resolver_result_node_t* node = b->results; while (node != NULL) { resolver_result_node_t* next = node->next; free(node->buf); free(node); node = next; } - g_resolver_results = NULL; + b->results = NULL; +} + +// Call under g_mutex. +static engine_bridge_t* bridge_find(long long handle) { + for (engine_bridge_t* b = g_bridges; b != NULL; b = b->next) { + if (b->handle == handle) return b; + } + return NULL; } // --- Initialization --- @@ -145,15 +154,13 @@ static void init_thread_fn(void* arg) { uv_dlsym(&g_lib, "run_script_callback", (void**)&fn_run_script_callback); uv_dlsym(&g_lib, "run_script_input_output_callback", (void**)&fn_run_script_input_output_callback); - // Load resolver-aware entrypoints (optional - newer symbols) - uv_dlsym(&g_lib, "run_script_with_resolver", (void**)&fn_run_script_with_resolver); - // fn_run_script_callback_with_resolver / fn_run_script_input_output_callback_with_resolver - // are resolved here but intentionally never called from this file. Wiring them into - // runScriptStreaming/runScriptTransform would put the resolver callback on a background - // uv_thread, which is unsafe for the same reason resolve_module_callback() above guards - // against cross-thread napi calls — do not wire these up without solving that hazard first. - uv_dlsym(&g_lib, "run_script_callback_with_resolver", (void**)&fn_run_script_callback_with_resolver); - uv_dlsym(&g_lib, "run_script_input_output_callback_with_resolver", (void**)&fn_run_script_input_output_callback_with_resolver); + // Load per-engine entrypoints (optional - newer symbols) + uv_dlsym(&g_lib, "create_engine", (void**)&fn_create_engine); + uv_dlsym(&g_lib, "create_engine_with_resolver", (void**)&fn_create_engine_with_resolver); + uv_dlsym(&g_lib, "destroy_engine", (void**)&fn_destroy_engine); + uv_dlsym(&g_lib, "run_script_engine", (void**)&fn_run_script_engine); + uv_dlsym(&g_lib, "run_script_callback_engine", (void**)&fn_run_script_callback_engine); + uv_dlsym(&g_lib, "run_script_input_output_callback_engine", (void**)&fn_run_script_input_output_callback_engine); if (!fn_create_isolate || !fn_run_script || !fn_free_cstring) { snprintf(args->error, sizeof(args->error), "Missing required symbols in library"); @@ -321,6 +328,7 @@ struct streaming_work { uv_thread_t tid; napi_threadsafe_function tsfn; napi_deferred deferred; + long long handle; char* script; char* inputs_json; }; @@ -386,8 +394,8 @@ static void streaming_thread_fn(void* arg) { snprintf(err, sizeof(err), "{\"success\":false,\"error\":\"Failed to attach thread (code %d)\"}", rc); meta_result = strdup(err); } else { - void* result_ptr = fn_run_script_callback( - worker_thread, w->script, w->inputs_json, streaming_write_cb, (void*)w->tsfn + void* result_ptr = fn_run_script_callback_engine( + worker_thread, w->handle, w->script, w->inputs_json, streaming_write_cb, (void*)w->tsfn ); if (result_ptr) { meta_result = strdup((const char*)result_ptr); @@ -404,38 +412,42 @@ static void streaming_thread_fn(void* arg) { napi_call_threadsafe_function(w->tsfn, sentinel, napi_tsfn_blocking); } -static napi_value napi_run_script_streaming(napi_env env, napi_callback_info info) { +static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_info info) { if (!g_initialized) { napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; } - if (!fn_run_script_callback) { - napi_throw_error(env, NULL, "run_script_callback not available in native library"); + if (!fn_run_script_callback_engine) { + napi_throw_error(env, NULL, "run_script_callback_engine not available in native library"); return NULL; } - size_t argc = 3; - napi_value argv[3]; + size_t argc = 4; + napi_value argv[4]; napi_get_cb_info(env, info, &argc, argv, NULL, NULL); - if (argc < 3) { - napi_throw_error(env, NULL, "runScriptStreaming requires (script, inputsJson, chunkCallback)"); + if (argc < 4) { + napi_throw_error(env, NULL, "runScriptStreamingEngine requires (handle, script, inputsJson, chunkCallback)"); return NULL; } + int64_t handle64; + napi_get_value_int64(env, argv[0], &handle64); + size_t script_len, inputs_len; - napi_get_value_string_utf8(env, argv[0], NULL, 0, &script_len); - napi_get_value_string_utf8(env, argv[1], NULL, 0, &inputs_len); + napi_get_value_string_utf8(env, argv[1], NULL, 0, &script_len); + napi_get_value_string_utf8(env, argv[2], NULL, 0, &inputs_len); struct streaming_work* w = calloc(1, sizeof(struct streaming_work)); + w->handle = (long long)handle64; w->script = malloc(script_len + 1); w->inputs_json = malloc(inputs_len + 1); - napi_get_value_string_utf8(env, argv[0], w->script, script_len + 1, NULL); - napi_get_value_string_utf8(env, argv[1], w->inputs_json, inputs_len + 1, NULL); + napi_get_value_string_utf8(env, argv[1], w->script, script_len + 1, NULL); + napi_get_value_string_utf8(env, argv[2], w->inputs_json, inputs_len + 1, NULL); napi_value resource_name; napi_create_string_utf8(env, "dwStreaming", NAPI_AUTO_LENGTH, &resource_name); - napi_create_threadsafe_function(env, argv[2], NULL, resource_name, 0, 1, NULL, NULL, w, call_js_write, &w->tsfn); + napi_create_threadsafe_function(env, argv[3], NULL, resource_name, 0, 1, NULL, NULL, w, call_js_write, &w->tsfn); napi_value promise; napi_create_promise(env, &w->deferred, &promise); @@ -455,6 +467,7 @@ struct transform_work { napi_threadsafe_function read_tsfn; napi_threadsafe_function write_tsfn; napi_deferred deferred; + long long handle; char* script; char* inputs_json; char* input_name; @@ -635,8 +648,8 @@ static void transform_thread_fn(void* arg) { snprintf(err, sizeof(err), "{\"success\":false,\"error\":\"Failed to attach thread (code %d)\"}", rc); meta_result = strdup(err); } else { - void* result_ptr = fn_run_script_input_output_callback( - worker_thread, w->script, w->inputs_json, + void* result_ptr = fn_run_script_input_output_callback_engine( + worker_thread, w->handle, w->script, w->inputs_json, w->input_name, w->input_mime_type, w->input_charset, transform_read_cb, transform_write_cb, (void*)w ); @@ -656,50 +669,54 @@ static void transform_thread_fn(void* arg) { napi_call_threadsafe_function(w->write_tsfn, sentinel, napi_tsfn_blocking); } -static napi_value napi_run_script_transform(napi_env env, napi_callback_info info) { +static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_info info) { if (!g_initialized) { napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; } - if (!fn_run_script_input_output_callback) { - napi_throw_error(env, NULL, "run_script_input_output_callback not available in native library"); + if (!fn_run_script_input_output_callback_engine) { + napi_throw_error(env, NULL, "run_script_input_output_callback_engine not available in native library"); return NULL; } - size_t argc = 7; - napi_value argv[7]; + size_t argc = 8; + napi_value argv[8]; napi_get_cb_info(env, info, &argc, argv, NULL, NULL); - if (argc < 7) { - napi_throw_error(env, NULL, "runScriptTransform requires 7 arguments"); + if (argc < 8) { + napi_throw_error(env, NULL, "runScriptTransformEngine requires 8 arguments"); return NULL; } struct transform_work* w = calloc(1, sizeof(struct transform_work)); size_t len; - napi_get_value_string_utf8(env, argv[0], NULL, 0, &len); - w->script = malloc(len + 1); - napi_get_value_string_utf8(env, argv[0], w->script, len + 1, NULL); + int64_t handle64; + napi_get_value_int64(env, argv[0], &handle64); + w->handle = (long long)handle64; napi_get_value_string_utf8(env, argv[1], NULL, 0, &len); - w->inputs_json = malloc(len + 1); - napi_get_value_string_utf8(env, argv[1], w->inputs_json, len + 1, NULL); + w->script = malloc(len + 1); + napi_get_value_string_utf8(env, argv[1], w->script, len + 1, NULL); napi_get_value_string_utf8(env, argv[2], NULL, 0, &len); - w->input_name = malloc(len + 1); - napi_get_value_string_utf8(env, argv[2], w->input_name, len + 1, NULL); + w->inputs_json = malloc(len + 1); + napi_get_value_string_utf8(env, argv[2], w->inputs_json, len + 1, NULL); napi_get_value_string_utf8(env, argv[3], NULL, 0, &len); + w->input_name = malloc(len + 1); + napi_get_value_string_utf8(env, argv[3], w->input_name, len + 1, NULL); + + napi_get_value_string_utf8(env, argv[4], NULL, 0, &len); w->input_mime_type = malloc(len + 1); - napi_get_value_string_utf8(env, argv[3], w->input_mime_type, len + 1, NULL); + napi_get_value_string_utf8(env, argv[4], w->input_mime_type, len + 1, NULL); napi_valuetype type; - napi_typeof(env, argv[4], &type); + napi_typeof(env, argv[5], &type); if (type == napi_string) { - napi_get_value_string_utf8(env, argv[4], NULL, 0, &len); + napi_get_value_string_utf8(env, argv[5], NULL, 0, &len); w->input_charset = malloc(len + 1); - napi_get_value_string_utf8(env, argv[4], w->input_charset, len + 1, NULL); + napi_get_value_string_utf8(env, argv[5], w->input_charset, len + 1, NULL); } else { w->input_charset = NULL; } @@ -707,8 +724,8 @@ static napi_value napi_run_script_transform(napi_env env, napi_callback_info inf napi_value resource_name; napi_create_string_utf8(env, "dwTransform", NAPI_AUTO_LENGTH, &resource_name); - napi_create_threadsafe_function(env, argv[5], NULL, resource_name, 0, 1, NULL, NULL, NULL, call_js_read, &w->read_tsfn); - napi_create_threadsafe_function(env, argv[6], NULL, resource_name, 0, 1, NULL, NULL, w, call_js_transform_write, &w->write_tsfn); + napi_create_threadsafe_function(env, argv[6], NULL, resource_name, 0, 1, NULL, NULL, NULL, call_js_read, &w->read_tsfn); + napi_create_threadsafe_function(env, argv[7], NULL, resource_name, 0, 1, NULL, NULL, w, call_js_transform_write, &w->write_tsfn); napi_value promise; napi_create_promise(env, &w->deferred, &promise); @@ -724,35 +741,36 @@ static napi_value napi_run_script_transform(napi_env env, napi_callback_info inf // --- Resolver callback bridge --- // Called by native code, synchronously, on the same JS thread that invoked -// runWithResolver (see the comment on g_resolver_env above for why this must -// NOT hop through napi_threadsafe_function). Calls the JS resolver directly -// and returns its result copied onto the heap; the caller (napi_run_with_resolver) -// frees it via g_resolver_last_result after the native side has copied it. -static char* resolve_module_callback(void* thread, const char* module_path) { +// runScriptEngine for a resolver-backed engine (see the comment on +// engine_bridge_t above for why this must NOT hop through +// napi_threadsafe_function). The ctx word is the engine's own engine_bridge_t*, +// passed to Java in create_engine_with_resolver and forwarded back here. Calls +// the JS resolver directly and returns its result copied onto the heap; the +// caller frees the tracked buffers after the native side has copied them. +static char* resolve_module_callback(void* thread, void* ctx, const char* module_path) { (void)thread; - if (g_resolver_env == NULL || g_resolver_ref == NULL) { - return NULL; // No resolver set + engine_bridge_t* bridge = (engine_bridge_t*)ctx; + if (bridge == NULL || bridge->env == NULL || bridge->resolver_js == NULL) { + return NULL; // No resolver for this engine } - // Guard against cross-thread napi calls. The engine that triggers this - // callback is a process-wide singleton shared by run()/runStreaming()/ - // runTransform(); streaming and transform execute their native call on a - // background uv_thread (streaming_thread_fn/transform_thread_fn), not the - // JS thread that registered g_resolver_env/g_resolver_ref. If we're not - // on the thread that owns this napi_env, calling napi_get_reference_value + // Guard against cross-thread napi calls. Streaming and transform execute + // their native call on a background uv_thread (streaming_thread_fn/ + // transform_thread_fn), not the JS thread that created this bridge. If we're + // not on the thread that owns this napi_env, calling napi_get_reference_value // or napi_call_function here is undefined behavior (typically a crash). // Fail closed instead: report "not found", which matches the documented // built-ins-only fallback for streaming/transform. uv_thread_t current = uv_thread_self(); - if (!uv_thread_equal(¤t, &g_resolver_thread)) { + if (!uv_thread_equal(¤t, &bridge->owner)) { return NULL; } - napi_env env = g_resolver_env; + napi_env env = bridge->env; napi_value js_callback; - if (napi_get_reference_value(env, g_resolver_ref, &js_callback) != napi_ok) { + if (napi_get_reference_value(env, bridge->resolver_js, &js_callback) != napi_ok) { return NULL; } @@ -849,130 +867,114 @@ static char* resolve_module_callback(void* thread, const char* module_path) { } // null/undefined/other → not found (result_source stays NULL) - resolver_results_track(result_source); + resolver_results_track(bridge, result_source); return result_source; // Native copies this immediately; we free the original after the call. } -// N-API method: runWithResolver -static napi_value napi_run_with_resolver(napi_env env, napi_callback_info info) { - if (!g_initialized) { - napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); - return NULL; - } - if (!fn_run_script_with_resolver) { - napi_throw_error(env, NULL, "run_script_with_resolver not available in native library"); - return NULL; - } +// --- Per-engine N-API methods --- - size_t argc = 5; - napi_value args[5]; - napi_get_cb_info(env, info, &argc, args, NULL, NULL); +// createEngine() -> number +static napi_value napi_create_engine(napi_env env, napi_callback_info info) { + (void)info; + if (!g_initialized) { napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; } + if (!fn_create_engine) { napi_throw_error(env, NULL, "create_engine not available in native library"); return NULL; } + void* thread = NULL; + if (fn_attach_thread(g_isolate, &thread) != 0) { napi_throw_error(env, NULL, "Failed to attach thread"); return NULL; } + long long handle = fn_create_engine(thread); + fn_detach_thread(thread); + napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; +} - if (argc < 5) { - napi_throw_error(env, NULL, "Expected 5 arguments: script, inputs, mimeType, resolverCallback, isolate"); - return NULL; +// createEngineWithResolver(resolver) -> number +static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_info info) { + if (!g_initialized) { napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; } + if (!fn_create_engine_with_resolver) { napi_throw_error(env, NULL, "create_engine_with_resolver not available in native library"); return NULL; } + size_t argc = 1; napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + if (argc < 1) { napi_throw_error(env, NULL, "createEngineWithResolver requires (resolverCallback)"); return NULL; } + + engine_bridge_t* bridge = (engine_bridge_t*)calloc(1, sizeof(engine_bridge_t)); + if (bridge == NULL) { napi_throw_error(env, NULL, "Failed to allocate engine bridge"); return NULL; } + if (napi_create_reference(env, argv[0], 1, &bridge->resolver_js) != napi_ok) { + free(bridge); napi_throw_error(env, NULL, "Failed to reference resolver callback"); return NULL; } + bridge->env = env; bridge->owner = uv_thread_self(); bridge->results = NULL; - // Extract script, inputs, mimeType - size_t script_len, inputs_len, mime_len; - napi_get_value_string_utf8(env, args[0], NULL, 0, &script_len); - napi_get_value_string_utf8(env, args[1], NULL, 0, &inputs_len); - napi_get_value_string_utf8(env, args[2], NULL, 0, &mime_len); + void* thread = NULL; + if (fn_attach_thread(g_isolate, &thread) != 0) { + napi_delete_reference(env, bridge->resolver_js); free(bridge); + napi_throw_error(env, NULL, "Failed to attach thread"); return NULL; + } + long long handle = fn_create_engine_with_resolver(thread, resolve_module_callback, (void*)bridge); + fn_detach_thread(thread); - char* script = (char*)malloc(script_len + 1); - char* inputs = (char*)malloc(inputs_len + 1); - char* mime_type = (char*)malloc(mime_len + 1); + bridge->handle = handle; + uv_mutex_lock(&g_mutex); bridge->next = g_bridges; g_bridges = bridge; uv_mutex_unlock(&g_mutex); + napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; +} - if (script == NULL || inputs == NULL || mime_type == NULL) { - free(script); - free(inputs); - free(mime_type); - napi_throw_error(env, NULL, "Failed to allocate memory for arguments"); - return NULL; +// destroyEngine(handle) -> void +static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { + if (!g_initialized) return NULL; + size_t argc = 1; napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + if (argc < 1) { napi_throw_error(env, NULL, "destroyEngine requires (handle)"); return NULL; } + int64_t handle64; napi_get_value_int64(env, argv[0], &handle64); + long long handle = (long long)handle64; + + if (fn_destroy_engine) { + void* thread = NULL; + if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, handle); fn_detach_thread(thread); } } - - napi_get_value_string_utf8(env, args[0], script, script_len + 1, NULL); - napi_get_value_string_utf8(env, args[1], inputs, inputs_len + 1, NULL); - napi_get_value_string_utf8(env, args[2], mime_type, mime_len + 1, NULL); - - // Resolver is installed once per process lifetime. Subsequent calls with - // different resolver callbacks will reuse the first resolver, as enforced by - // ScriptRuntime.setResolver() on the native side (one resolver per engine). - // - // No thread-hop machinery is needed: fn_run_script_with_resolver() below - // runs on this very thread, so resolve_module_callback() (invoked from - // inside that call) can call directly back into JS via the stored - // napi_ref. See the comment on g_resolver_env for why napi_threadsafe_function - // must NOT be used here. uv_mutex_lock(&g_mutex); - if (g_resolver_ref == NULL) { - napi_status status = napi_create_reference(env, args[3], 1, &g_resolver_ref); - if (status != napi_ok) { - uv_mutex_unlock(&g_mutex); - free(script); - free(inputs); - free(mime_type); - napi_throw_error(env, NULL, "Failed to reference resolver callback"); - return NULL; - } - g_resolver_env = env; - g_resolver_thread = uv_thread_self(); - } - // Note: subsequent calls reuse the first resolver for this process lifetime. + engine_bridge_t** pp = &g_bridges; engine_bridge_t* found = NULL; + while (*pp != NULL) { if ((*pp)->handle == handle) { found = *pp; *pp = found->next; break; } pp = &(*pp)->next; } uv_mutex_unlock(&g_mutex); + if (found != NULL) { + if (found->resolver_js != NULL && found->env != NULL) napi_delete_reference(found->env, found->resolver_js); + resolver_results_free_all(found); free(found); + } + return NULL; +} + +// runScriptEngine(handle, script, inputsJson) -> string +static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) { + if (!g_initialized) { napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; } + if (!fn_run_script_engine) { napi_throw_error(env, NULL, "run_script_engine not available in native library"); return NULL; } + size_t argc = 3; napi_value argv[3]; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + if (argc < 3) { napi_throw_error(env, NULL, "runScriptEngine requires (handle, script, inputsJson)"); return NULL; } + int64_t handle64; napi_get_value_int64(env, argv[0], &handle64); + long long handle = (long long)handle64; + + size_t script_len, inputs_len; + napi_get_value_string_utf8(env, argv[1], NULL, 0, &script_len); + napi_get_value_string_utf8(env, argv[2], NULL, 0, &inputs_len); + char* script = (char*)malloc(script_len + 1); + char* inputs = (char*)malloc(inputs_len + 1); + if (script == NULL || inputs == NULL) { free(script); free(inputs); napi_throw_error(env, NULL, "OOM"); return NULL; } + napi_get_value_string_utf8(env, argv[1], script, script_len + 1, NULL); + napi_get_value_string_utf8(env, argv[2], inputs, inputs_len + 1, NULL); - // Need to attach thread for this call void* thread = NULL; - int rc = fn_attach_thread(g_isolate, &thread); - if (rc != 0) { - free(script); - free(inputs); - free(mime_type); - napi_throw_error(env, NULL, "Failed to attach thread"); - return NULL; - } + if (fn_attach_thread(g_isolate, &thread) != 0) { free(script); free(inputs); napi_throw_error(env, NULL, "Failed to attach thread"); return NULL; } - // Call native with resolver callback. mime_type is accepted from JS for API - // symmetry but is not part of the native run_script_with_resolver signature - // (see run_script_with_resolver_fn typedef comment) — do not forward it. - char* result = fn_run_script_with_resolver( - thread, - script, - inputs, - resolve_module_callback - ); + char* result = (char*)fn_run_script_engine(thread, handle, script, inputs); - // Native has copied every resolver result returned during this call; free - // our copies now that it's done. - resolver_results_free_all(); + uv_mutex_lock(&g_mutex); + engine_bridge_t* bridge = bridge_find(handle); + uv_mutex_unlock(&g_mutex); + if (bridge != NULL) resolver_results_free_all(bridge); - // result (if non-NULL) is a GraalVM UnmanagedMemory.malloc'd buffer, like - // every other native result pointer in this file; it must be released via - // fn_free_cstring(), not libc free(), and while the isolate thread is - // still attached. Copy it to a libc-owned buffer first so we can build - // the JS string after detaching, matching the strdup + fn_free_cstring - // pattern used by run_script_thread_fn/streaming_thread_fn/transform_thread_fn. char* result_copy = result ? strdup(result) : NULL; - if (result != NULL) { - fn_free_cstring(thread, result); - } - + if (result != NULL) fn_free_cstring(thread, result); fn_detach_thread(thread); + free(script); free(inputs); - free(script); - free(inputs); - free(mime_type); - - if (result_copy == NULL) { - napi_throw_error(env, NULL, "Script execution failed"); - return NULL; - } - - napi_value result_str; - napi_create_string_utf8(env, result_copy, NAPI_AUTO_LENGTH, &result_str); - free(result_copy); - - return result_str; + napi_value out; + if (result_copy) { napi_create_string_utf8(env, result_copy, NAPI_AUTO_LENGTH, &out); free(result_copy); } + else { napi_create_string_utf8(env, "", 0, &out); } + return out; } // --- Cleanup (must run on a separate thread to avoid V8 signal handler conflict) --- @@ -1000,13 +1002,21 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { if (g_initialized) { g_ref_count--; if (g_ref_count <= 0) { - // Clean up resolver reference - if (g_resolver_ref != NULL && g_resolver_env != NULL) { - napi_delete_reference(g_resolver_env, g_resolver_ref); + // Tear down any engine bridges never explicitly destroyed. We already + // hold g_mutex here, so walk g_bridges inline (no re-lock): delete each + // bridge's napi_ref on its own env, free its tracked result buffers, and + // free the node. + engine_bridge_t* b = g_bridges; + while (b != NULL) { + engine_bridge_t* next = b->next; + if (b->resolver_js != NULL && b->env != NULL) { + napi_delete_reference(b->env, b->resolver_js); + } + resolver_results_free_all(b); + free(b); + b = next; } - g_resolver_ref = NULL; - g_resolver_env = NULL; - resolver_results_free_all(); + g_bridges = NULL; uv_thread_t tid; uv_thread_options_t opts; @@ -1042,14 +1052,23 @@ static napi_value Init(napi_env env, napi_value exports) { napi_create_function(env, "runScript", NAPI_AUTO_LENGTH, dw_napi_run_script, NULL, &fn); napi_set_named_property(env, exports, "runScript", fn); - napi_create_function(env, "runScriptStreaming", NAPI_AUTO_LENGTH, napi_run_script_streaming, NULL, &fn); - napi_set_named_property(env, exports, "runScriptStreaming", fn); + napi_create_function(env, "createEngine", NAPI_AUTO_LENGTH, napi_create_engine, NULL, &fn); + napi_set_named_property(env, exports, "createEngine", fn); + + napi_create_function(env, "createEngineWithResolver", NAPI_AUTO_LENGTH, napi_create_engine_with_resolver, NULL, &fn); + napi_set_named_property(env, exports, "createEngineWithResolver", fn); + + napi_create_function(env, "destroyEngine", NAPI_AUTO_LENGTH, napi_destroy_engine, NULL, &fn); + napi_set_named_property(env, exports, "destroyEngine", fn); + + napi_create_function(env, "runScriptEngine", NAPI_AUTO_LENGTH, napi_run_script_engine, NULL, &fn); + napi_set_named_property(env, exports, "runScriptEngine", fn); - napi_create_function(env, "runScriptTransform", NAPI_AUTO_LENGTH, napi_run_script_transform, NULL, &fn); - napi_set_named_property(env, exports, "runScriptTransform", fn); + napi_create_function(env, "runScriptStreamingEngine", NAPI_AUTO_LENGTH, napi_run_script_streaming_engine, NULL, &fn); + napi_set_named_property(env, exports, "runScriptStreamingEngine", fn); - napi_create_function(env, "runWithResolver", NAPI_AUTO_LENGTH, napi_run_with_resolver, NULL, &fn); - napi_set_named_property(env, exports, "runWithResolver", fn); + napi_create_function(env, "runScriptTransformEngine", NAPI_AUTO_LENGTH, napi_run_script_transform_engine, NULL, &fn); + napi_set_named_property(env, exports, "runScriptTransformEngine", fn); napi_create_function(env, "cleanup", NAPI_AUTO_LENGTH, napi_cleanup, NULL, &fn); napi_set_named_property(env, exports, "cleanup", fn); From c7272b44e9334865b552a8393bb8e4cddceb6472 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 10:53:11 -0300 Subject: [PATCH 04/63] W-23692110: per-instance engine handles in Node binding + isolation regression test Rewires ffi.ts and dataweave.ts to call the new handle-based N-API methods (createEngine/createEngineWithResolver/destroyEngine/ runScriptEngine/runScriptStreamingEngine/runScriptTransformEngine) added in Task 3, removing runWithResolver. Each DataWeave instance now owns its own engineHandle, created on initialize() and destroyed on cleanup(), so multiple instances with different resolvers no longer cross-talk in the same process. Adds independent-engines.test.ts proving two resolver-backed instances resolve only their own modules, that a genuine script error on the new handle-based run() path surfaces as success:false rather than an unhandled throw (runScriptEngine now returns "" instead of throwing on a NULL native result), and that runStreaming/runTransform correctly thread the handle through addon.c's argument-shifted N-API wiring. Deletes the now-obsolete first-resolver-wins regression test and fixture, and rewrites dataweave-resolver.test.ts so each test builds its own minimal resolver map instead of sharing a process-wide "first resolver wins" module map. --- native-lib/node/src/dataweave.ts | 53 +++++---- native-lib/node/src/ffi.ts | 65 +++++++---- .../integration/dataweave-resolver.test.ts | 79 ++++--------- .../integration/first-resolver-wins.test.ts | 34 ------ .../fixtures/first-resolver-wins.cjs | 103 ----------------- .../integration/independent-engines.test.ts | 105 ++++++++++++++++++ 6 files changed, 198 insertions(+), 241 deletions(-) delete mode 100644 native-lib/node/tests/integration/first-resolver-wins.test.ts delete mode 100644 native-lib/node/tests/integration/fixtures/first-resolver-wins.cjs create mode 100644 native-lib/node/tests/integration/independent-engines.test.ts diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index dbaa63a..c628477 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -23,23 +23,10 @@ export interface DataWeaveOptions { * * MUST be synchronous (cannot return Promise). * - * Note: the native layer installs at most one resolver per process - * lifetime, bound on the first resolver-backed {@link DataWeave.run} call - * (not on {@link DataWeave.initialize}, which only loads/ref-counts the - * native library) and to the thread (main thread or `worker_threads` - * Worker) that made that first call. If you construct multiple `DataWeave` - * instances with different `resolveModule` callbacks in the same process, - * whichever instance's `run()` executes first wins; later instances - * silently reuse that resolver instead of their own. If a later instance's - * `run()` executes on a *different* thread, its resolver is not invoked at - * all and custom module paths resolve as "not found" (see - * docs/external-modules.md#multiple-resolvers-in-one-process). - * - * Concurrency warning: calling a resolver-backed `run()` concurrently from - * more than one Worker is not just unsupported — it is memory-unsafe (see - * docs/external-modules.md, Worker threads section). Restrict - * resolver-backed execution to a single thread, or serialize calls across - * Workers. + * Each DataWeave instance owns an independent native engine, so multiple + * instances with different resolvers coexist in one process with no + * cross-talk. Streaming/transform still resolve only built-in modules for a + * resolver-backed engine (custom modules fail closed); see external-modules.md. * * Security: the resolver runs with full process permissions and no * sandboxing (same trust model as the CLI resolving `.dwl` files from @@ -62,6 +49,7 @@ export class DataWeave { private readonly libPath: string; private readonly resolveModule?: ModuleResolver; private initialized = false; + private engineHandle: number | null = null; /** * @param options - Configuration options or a legacy libPath string. @@ -88,6 +76,9 @@ export class DataWeave { if (this.initialized) return; try { ffi.initialize(this.libPath); + this.engineHandle = this.resolveModule + ? ffi.createEngineWithResolver(this.resolveModule) + : ffi.createEngine(); } catch (e: unknown) { throw new DataWeaveError(`Failed to initialize: ${e instanceof Error ? e.message : e}`); } @@ -100,6 +91,10 @@ export class DataWeave { */ cleanup(): void { if (!this.initialized) return; + if (this.engineHandle !== null) { + ffi.destroyEngine(this.engineHandle); + this.engineHandle = null; + } ffi.cleanup(); this.initialized = false; } @@ -119,14 +114,7 @@ export class DataWeave { this.ensureInitialized(); const inputsJson = buildInputsJson(inputs ?? {}); - let raw: string; - if (this.resolveModule) { - // Use resolver-aware entrypoint - raw = ffi.runWithResolver(script, inputsJson, "application/json", this.resolveModule); - } else { - // Use standard entrypoint (backward compatible) - raw = ffi.runScript(script, inputsJson); - } + const raw = ffi.runScriptEngine(this.engineHandle!, script, inputsJson); const result = parseNativeResponse(raw); @@ -150,7 +138,9 @@ export class DataWeave { async *runStreaming(script: string, inputs?: Inputs): AsyncGenerator { this.ensureInitialized(); const inputsJson = buildInputsJson(inputs ?? {}); - return yield* streamFromNative((chunkCb) => ffi.runScriptStreaming(script, inputsJson, chunkCb)); + return yield* streamFromNative((chunkCb) => + ffi.runScriptStreamingEngine(this.engineHandle!, script, inputsJson, chunkCb) + ); } /** @@ -185,7 +175,16 @@ export class DataWeave { const readCb = await createChunkReader(input); return yield* streamFromNative((writeCb) => - ffi.runScriptTransform(script, inputsJson, inputName, inputMimeType, inputCharset, readCb, writeCb) + ffi.runScriptTransformEngine( + this.engineHandle!, + script, + inputsJson, + inputName, + inputMimeType, + inputCharset, + readCb, + writeCb + ) ); } diff --git a/native-lib/node/src/ffi.ts b/native-lib/node/src/ffi.ts index 924de43..cc05a23 100644 --- a/native-lib/node/src/ffi.ts +++ b/native-lib/node/src/ffi.ts @@ -4,8 +4,18 @@ import type { ModuleResolver } from "./resolver"; interface NativeAddon { initialize(libPath: string): void; runScript(script: string, inputsJson: string): string; - runScriptStreaming(script: string, inputsJson: string, chunkCb: (chunk: Buffer) => void): Promise; - runScriptTransform( + createEngine(): number; + createEngineWithResolver(resolver: ModuleResolver): number; + destroyEngine(handle: number): void; + runScriptEngine(handle: number, script: string, inputsJson: string): string; + runScriptStreamingEngine( + handle: number, + script: string, + inputsJson: string, + chunkCb: (chunk: Buffer) => void + ): Promise; + runScriptTransformEngine( + handle: number, script: string, inputsJson: string, inputName: string, @@ -14,13 +24,6 @@ interface NativeAddon { readCb: (bufSize: number) => Buffer | null, writeCb: (chunk: Buffer) => void ): Promise; - runWithResolver( - script: string, - inputsJson: string, - mimeType: string, - resolverCallback: ModuleResolver, - isolate: null - ): string; cleanup(): void; } @@ -42,15 +45,33 @@ export function runScript(script: string, inputsJson: string): string { return getAddon().runScript(script, inputsJson); } -export function runScriptStreaming( +export function createEngine(): number { + return getAddon().createEngine(); +} + +export function createEngineWithResolver(resolver: ModuleResolver): number { + return getAddon().createEngineWithResolver(resolver); +} + +export function destroyEngine(handle: number): void { + getAddon().destroyEngine(handle); +} + +export function runScriptEngine(handle: number, script: string, inputsJson: string): string { + return getAddon().runScriptEngine(handle, script, inputsJson); +} + +export function runScriptStreamingEngine( + handle: number, script: string, inputsJson: string, chunkCb: (chunk: Buffer) => void ): Promise { - return getAddon().runScriptStreaming(script, inputsJson, chunkCb); + return getAddon().runScriptStreamingEngine(handle, script, inputsJson, chunkCb); } -export function runScriptTransform( +export function runScriptTransformEngine( + handle: number, script: string, inputsJson: string, inputName: string, @@ -59,16 +80,16 @@ export function runScriptTransform( readCb: (bufSize: number) => Buffer | null, writeCb: (chunk: Buffer) => void ): Promise { - return getAddon().runScriptTransform(script, inputsJson, inputName, inputMimeType, inputCharset, readCb, writeCb); -} - -export function runWithResolver( - script: string, - inputsJson: string, - mimeType: string, - resolverCallback: ModuleResolver -): string { - return getAddon().runWithResolver(script, inputsJson, mimeType, resolverCallback, null); + return getAddon().runScriptTransformEngine( + handle, + script, + inputsJson, + inputName, + inputMimeType, + inputCharset, + readCb, + writeCb + ); } export function cleanup(): void { diff --git a/native-lib/node/tests/integration/dataweave-resolver.test.ts b/native-lib/node/tests/integration/dataweave-resolver.test.ts index 6578bb6..deeaacb 100644 --- a/native-lib/node/tests/integration/dataweave-resolver.test.ts +++ b/native-lib/node/tests/integration/dataweave-resolver.test.ts @@ -6,10 +6,10 @@ import { modulesFromMap } from '../../src/resolver'; // than the module-level singleton) so each can configure its own resolver. // `cleanup()` above only releases the *singleton* (`globalInstance`), which // nothing in this file ever creates -- so without this tracking, every -// explicit instance's native library reference (and the shared addon-level -// ref-count, see addon.c's g_ref_count) would leak for the lifetime of the -// test process. Track every instance created in this file and release them -// all in afterAll. +// explicit instance's native library reference (and its own engine handle, +// see addon.c's create_engine/destroy_engine) would leak for the lifetime of +// the test process. Track every instance created in this file and release +// them all in afterAll. const instances: DataWeave[] = []; function trackedDataWeave(...args: ConstructorParameters): DataWeave { const dw = new DataWeave(...args); @@ -24,24 +24,12 @@ afterAll(() => { cleanup(); }); -// ScriptRuntime installs at most one resolver for the whole process lifetime -// (see ScriptRuntime.setResolver()): whichever DataWeave instance's resolver -// gets installed first "wins", and every later DataWeave instance in this -// file — regardless of its own resolveModule map — silently reuses it. Since -// vitest runs the `it` blocks in this file sequentially in the same process, -// that's always this first module-map, so it must contain every module path -// any test below needs to resolve for the first time (including the -// cross-thread regression test's two never-before-resolved paths). -const SHARED_RESOLVER_MODULES: Record = { - 'org/test/lib.dwl': '%dw 2.0\nfun greet(n: String) = "Hello " ++ n', - 'org/test/resolverGuardInstall.dwl': '%dw 2.0\nfun greet(n: String) = "Hello " ++ n', - 'org/test/resolverGuardStreamed.dwl': '%dw 2.0\nfun greet(n: String) = "Hello " ++ n', -}; - describe('DataWeave with resolver', () => { it('resolves imported module from map', () => { const dw = trackedDataWeave({ - resolveModule: modulesFromMap(SHARED_RESOLVER_MODULES), + resolveModule: modulesFromMap({ + 'org/test/lib.dwl': '%dw 2.0\nfun greet(n: String) = "Hello " ++ n', + }), }); dw.initialize(); @@ -106,46 +94,27 @@ describe('DataWeave with resolver', () => { expect(JSON.parse(result.getString()!)).toBe("Hello"); }); - // Regression test for the cross-thread resolver hazard: ScriptRuntime's engine - // is a process-wide singleton, so once any .run() call installs a resolver on - // it, that same composite resolver is used by ALL later execution paths -- - // including runStreaming()/runTransform(), whose native call executes on a - // background uv_thread (see addon.c's streaming_thread_fn), not the JS thread - // that registered the resolver. Before the thread-identity guard in addon.c's - // resolve_module_callback, a streamed script importing a non-built-in module - // would trigger a napi call from that background thread -- undefined behavior, - // typically a crash of the whole process. After the guard, the callback fails - // closed (reports "not found" instead of calling back into JS), so the script - // fails cleanly with a compile error and the process survives. - it('runStreaming fails cleanly (does not crash) for a custom module on the shared singleton engine', async () => { - // Once a module name has been resolved anywhere in the process, the - // DataWeave compiler caches it and won't call back into the resolver for - // that same name again — so the install script and the streaming script - // below import two module paths that no earlier test in this file has - // imported yet (both pre-registered in SHARED_RESOLVER_MODULES above, - // since only the first-installed resolver's map is ever consulted). + // Regression test for the cross-thread resolver hazard: each DataWeave + // instance now owns its own native engine (see engine_bridge_t in addon.c), + // but a resolver-backed engine's runStreaming()/runTransform() still + // executes the native call on a background uv_thread (see addon.c's + // streaming_thread_fn/transform_thread_fn), not the JS thread that created + // the engine and its resolver bridge. resolve_module_callback detects that + // thread-identity mismatch and fails closed (reports "not found" instead of + // calling back into JS) rather than making an unsafe cross-thread napi + // call, so the script fails cleanly with a compile error and the process + // survives. + it('runStreaming fails cleanly for a custom module on its own resolver-backed engine', async () => { const dw = trackedDataWeave({ - resolveModule: modulesFromMap(SHARED_RESOLVER_MODULES), + resolveModule: modulesFromMap({ + 'org/test/resolverGuardStreamed.dwl': '%dw 2.0\nfun greet(n: String) = "Hello " ++ n', + }), }); dw.initialize(); - // Install (or confirm already-installed) resolver on the shared singleton - // engine via a synchronous run() call. Per ScriptRuntime.setResolver(), only - // the first resolver registered for the process is ever used, so this is - // safe to call even if an earlier test in this file already installed one. - const installResult = dw.run(` - %dw 2.0 - import org::test::resolverGuardInstall - output application/json - --- - resolverGuardInstall::greet("Installer") - `); - expect(installResult.success).toBe(true); - - // Now stream a script that imports a DIFFERENT non-built-in module, never - // resolved before in this process. The singleton engine's composite - // resolver (ClassLoader + Callback) will miss in the ClassLoader half (not - // a built-in) and fall through to the Callback half, invoking + // Stream a script that imports a non-built-in module. This engine's + // composite resolver (ClassLoader + Callback) misses in the ClassLoader + // half (not a built-in) and falls through to the Callback half, invoking // resolve_module_callback from runStreaming's background thread. const chunks: Buffer[] = []; const gen = dw.runStreaming(` diff --git a/native-lib/node/tests/integration/first-resolver-wins.test.ts b/native-lib/node/tests/integration/first-resolver-wins.test.ts deleted file mode 100644 index 75e7da5..0000000 --- a/native-lib/node/tests/integration/first-resolver-wins.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Verifies the process-wide "first resolver wins" behavior documented in -// docs/external-modules.md#multiple-resolvers-in-one-process and -// ScriptRuntime.setResolver(): once a DataWeave instance's resolver is -// installed on the native engine singleton, a second instance constructed -// with a *different* resolver in the same process never has its resolver -// installed. That's only observable when the second instance's resolver is -// the second one ever installed for the whole process, so — like -// init-bad-path.test.ts — this runs in a dedicated child process rather than -// in-lane, making it order- and pool-configuration-independent. -import { describe, it, expect } from "vitest"; -import { execFileSync } from "node:child_process"; -import { join } from "node:path"; -import { existsSync } from "node:fs"; - -const FIXTURE = join(__dirname, "fixtures", "first-resolver-wins.cjs"); -const DIST_ENTRY = join(__dirname, "..", "..", "dist", "index.js"); - -describe("first-resolver-wins (isolated process)", () => { - it("a second DataWeave instance's resolver is silently ignored in favor of the first", () => { - expect(existsSync(DIST_ENTRY), `built entry missing at ${DIST_ENTRY} — run \`npm run build:ts\``).toBe(true); - - // execFileSync throws on a non-zero exit, so a "wrong resolver won" / - // native-crash outcome in the child fails this test. A timeout is also - // required: execFileSync blocks synchronously with no way for Vitest to - // interrupt it, so a native deadlock in the child would otherwise hang - // the whole suite instead of failing this one test. - const stdout = execFileSync(process.execPath, [FIXTURE], { - encoding: "utf-8", - timeout: 30_000, - }); - - expect(stdout).toContain("OK:first-resolver-wins"); - }); -}); diff --git a/native-lib/node/tests/integration/fixtures/first-resolver-wins.cjs b/native-lib/node/tests/integration/fixtures/first-resolver-wins.cjs deleted file mode 100644 index 3dc2fd4..0000000 --- a/native-lib/node/tests/integration/fixtures/first-resolver-wins.cjs +++ /dev/null @@ -1,103 +0,0 @@ -// Child-process fixture for the first-resolver-wins regression test. -// -// Runs in a FRESH process (spawned by first-resolver-wins.test.ts) so the -// process-wide ScriptRuntime singleton in the native layer starts with no -// resolver installed (see ScriptRuntime.setResolver(): once any DataWeave -// instance's resolver is installed, every later instance's resolver is -// silently ignored — a warning is logged and the first resolver keeps being -// used). That behavior is only observable on the FIRST resolver installation -// of a process, so this fixture -- not an in-lane vitest test -- is the only -// reliable way to exercise it. -// -// Contract with the parent: -// - Requires the built CommonJS entry at ../../../dist/index.js. -// - Constructs dw1 with a resolver for 'first.dwl' and dw2 with a -// *different* resolver for 'second.dwl', then initializes both. -// - Runs a script through dw1 that imports 'first.dwl' to force-install -// dw1's resolver on the singleton engine (must succeed). -// - Runs a script through dw2 that imports 'second.dwl'. Per the singleton -// semantics, dw2's resolver is never installed, so this import must fail. -// - Runs a THIRD script, through dw2, that imports 'first.dwl' again and -// asserts it still returns "Hello World". This is the check that actually -// distinguishes "the first resolver remains active" from "custom -// resolution broke entirely after the first call" — the second script -// alone would fail identically under either explanation. -// - Always calls cleanup() on both instances via try/finally, so teardown -// is exercised even on failure, then exits naturally (no process.exit()). -// - Prints "OK:first-resolver-wins" when all three expectations hold, or -// "FAIL:" (with a non-zero exitCode) otherwise. A native crash -// surfaces as a non-zero signal exit, which the parent also treats as -// failure. -const path = require("node:path"); - -const { DataWeave, modulesFromMap } = require(path.join(__dirname, "..", "..", "..", "dist", "index.js")); - -const dw1 = new DataWeave({ - resolveModule: modulesFromMap({ - "first.dwl": '%dw 2.0\nfun greet(n: String) = "Hello " ++ n', - }), -}); - -const dw2 = new DataWeave({ - resolveModule: modulesFromMap({ - "second.dwl": '%dw 2.0\nfun shout(n: String) = n ++ "!"', - }), -}); - -let failure = null; - -try { - dw1.initialize(); - dw2.initialize(); - - const firstResult = dw1.run(` - %dw 2.0 - import first - output application/json - --- - first::greet("World") - `); - - if (!firstResult.success) { - failure = "first-resolver-did-not-resolve:" + firstResult.error; - } else { - const secondResult = dw2.run(` - %dw 2.0 - import second - output application/json - --- - second::shout("hi") - `); - - if (secondResult.success) { - failure = "second-resolver-unexpectedly-won"; - } else { - // Prove the first resolver is still ACTIVE on dw2 (not merely that - // dw2's own resolver lost). A resolver that died entirely after the - // first call would also make second.dwl fail above -- this second - // check on dw2 is what actually distinguishes "first resolver wins" - // from "custom resolution stopped working after the first run". - const stillFirstResult = dw2.run(` - %dw 2.0 - import first - output application/json - --- - first::greet("World") - `); - - if (!stillFirstResult.success || JSON.parse(stillFirstResult.getString()) !== "Hello World") { - failure = "first-resolver-no-longer-active-on-dw2:" + (stillFirstResult.error || stillFirstResult.getString()); - } - } - } -} finally { - dw1.cleanup(); - dw2.cleanup(); -} - -if (failure) { - console.log("FAIL:" + failure); - process.exitCode = 1; -} else { - console.log("OK:first-resolver-wins"); -} diff --git a/native-lib/node/tests/integration/independent-engines.test.ts b/native-lib/node/tests/integration/independent-engines.test.ts new file mode 100644 index 0000000..fa5aa10 --- /dev/null +++ b/native-lib/node/tests/integration/independent-engines.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { DataWeave, cleanup } from "../../src/dataweave"; +import { modulesFromMap } from "../../src/resolver"; + +const instances: DataWeave[] = []; +function tracked(...args: ConstructorParameters): DataWeave { + const dw = new DataWeave(...args); + instances.push(dw); + return dw; +} +afterAll(() => { for (const dw of instances) dw.cleanup(); cleanup(); }); + +const scriptImporting = (mod: string) => + `%dw 2.0\nimport org::test::${mod}\noutput application/json\n---\n${mod}::greet("X")`; + +describe("independent engines (W-23692110)", () => { + it("two instances resolve only their OWN module, with no cross-talk", () => { + const dwA = tracked({ resolveModule: modulesFromMap({ + "org/test/a.dwl": '%dw 2.0\nfun greet(n: String) = "A:" ++ n' }) }); + const dwB = tracked({ resolveModule: modulesFromMap({ + "org/test/b.dwl": '%dw 2.0\nfun greet(n: String) = "B:" ++ n' }) }); + dwA.initialize(); + dwB.initialize(); + + expect(JSON.parse(dwA.run(scriptImporting("a")).getString()!)).toBe("A:X"); + expect(JSON.parse(dwB.run(scriptImporting("b")).getString()!)).toBe("B:X"); + + // Each engine misses the other's module. + expect(dwA.run(scriptImporting("b")).success).toBe(false); + expect(dwB.run(scriptImporting("a")).success).toBe(false); + }); + + it("built-in modules resolve in a resolver-backed engine", () => { + const dw = tracked({ resolveModule: modulesFromMap({ "x.dwl": "..." }) }); + dw.initialize(); + const r = dw.run('%dw 2.0\nimport dw::core::Strings\noutput application/json\n---\nStrings::capitalize("hello")'); + expect(r.success).toBe(true); + expect(JSON.parse(r.getString()!)).toBe("Hello"); + }); + + // Carried forward from Task 3's review: runScriptEngine now returns "" (not + // a thrown error) for a NULL native result, pushing error interpretation + // entirely to parseNativeResponse() in this TS layer. A genuine script + // error (as opposed to a NULL/empty native response) must still surface as + // an ordinary unsuccessful ExecutionResult through the new handle-based + // path -- not an unhandled parse exception or process crash. + it("a genuine script error on a resolver-backed engine surfaces as success:false, not a throw", () => { + const dw = tracked({ resolveModule: modulesFromMap({ "x.dwl": "..." }) }); + dw.initialize(); + + let result: ReturnType | undefined; + expect(() => { result = dw.run("invalid_var_xyz"); }).not.toThrow(); + expect(result!.success).toBe(false); + expect(result!.error).toBeTruthy(); + }); + + // Confirms addon.c's argument-shifted runScriptStreamingEngine wiring (handle + // as first argument, per Task 3) actually threads the handle through to a + // real per-engine streaming run, not just the non-streaming run() path + // exercised above. Uses a built-in import (not a custom resolver module): + // runStreaming's native call executes on a background uv_thread whose + // identity differs from the engine's owner thread, so a resolver-backed + // engine fails closed for *custom* modules over streaming by design (see + // dataweave-resolver.test.ts) -- that's not what this test is checking. + it("runStreaming produces output on its own resolver-backed engine", async () => { + const dw = tracked({ resolveModule: modulesFromMap({ "x.dwl": "..." }) }); + dw.initialize(); + + const chunks: Buffer[] = []; + const gen = dw.runStreaming( + '%dw 2.0\nimport dw::core::Strings\noutput application/json\n---\nStrings::capitalize("stream")' + ); + let result = await gen.next(); + while (!result.done) { + chunks.push(result.value); + result = await gen.next(); + } + const metadata = result.value; + + expect(metadata.success).toBe(true); + expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toBe("Stream"); + }); + + // Confirms addon.c's argument-shifted runScriptTransformEngine wiring + // likewise threads the handle through to a real per-engine transform run. + it("runTransform produces output on its own resolver-backed engine", async () => { + const dw = tracked({ resolveModule: modulesFromMap({ "x.dwl": "..." }) }); + dw.initialize(); + + const inputData = [Buffer.from("[1, 2, 3]")]; + const script = "output application/json\n---\npayload map ($ * 10)"; + + const chunks: Buffer[] = []; + const gen = dw.runTransform(script, inputData, { mimeType: "application/json" }); + let result = await gen.next(); + while (!result.done) { + chunks.push(result.value); + result = await gen.next(); + } + const metadata = result.value; + + expect(metadata.success).toBe(true); + expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toEqual([10, 20, 30]); + }); +}); From d032ce7fb43f235b54b74b5774e5d82cbcf1dc3f Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 11:03:27 -0300 Subject: [PATCH 05/63] W-23692110: fix native library ref-count leak on partial DataWeave.initialize() failure If ffi.initialize() succeeded but engine creation (createEngine/ createEngineWithResolver) then threw, this.initialized stayed false, so cleanup()'s early-return guard meant ffi.cleanup() was never called -- permanently leaking that instance's increment of the native library's ref-counted handle. initialize()'s catch block now releases that ref-count itself (ffi.cleanup()) when ffi.initialize() already succeeded, before wrapping and re-throwing. Adds tests/unit/dataweave-initialize.test.ts, a new unit-lane test (mocked ffi module, no dwlib required) exercising this exact sequencing bug plus the surrounding invariants: no cleanup() call when ffi.initialize() itself fails, no residual state after a failed attempt, and no spurious cleanup() call on the successful path. --- native-lib/node/src/dataweave.ts | 13 +++ .../tests/unit/dataweave-initialize.test.ts | 102 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 native-lib/node/tests/unit/dataweave-initialize.test.ts diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index c628477..9075acd 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -74,12 +74,25 @@ export class DataWeave { */ initialize(): void { if (this.initialized) return; + let libRefAcquired = false; try { ffi.initialize(this.libPath); + libRefAcquired = true; this.engineHandle = this.resolveModule ? ffi.createEngineWithResolver(this.resolveModule) : ffi.createEngine(); } catch (e: unknown) { + // If ffi.initialize() already succeeded but engine creation then threw, + // we already hold an increment of the native library's ref-counted + // handle. this.initialized stays false below (we're about to throw), + // so cleanup()'s early-return guard (`if (!this.initialized) return;`) + // means nothing else will ever call ffi.cleanup() for this instance -- + // release the ref-count ourselves here or it leaks for the process + // lifetime. + if (libRefAcquired) { + ffi.cleanup(); + } + this.engineHandle = null; throw new DataWeaveError(`Failed to initialize: ${e instanceof Error ? e.message : e}`); } this.initialized = true; diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts new file mode 100644 index 0000000..3bda370 --- /dev/null +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Pure-logic test of DataWeave.initialize()'s lifecycle/error handling, with +// the native addon mocked out entirely -- no dwlib required (see the "unit" +// project in vitest.config.ts). This covers a ref-count leak that is only +// observable in the sequencing of calls into ffi.ts, not in any externally +// visible native state, so a real end-to-end native failure isn't a +// practical way to assert on it (see task-4-report.md's fix report for why). +vi.mock("../../src/ffi", () => ({ + initialize: vi.fn(), + createEngine: vi.fn(), + createEngineWithResolver: vi.fn(), + destroyEngine: vi.fn(), + runScriptEngine: vi.fn(), + runScriptStreamingEngine: vi.fn(), + runScriptTransformEngine: vi.fn(), + cleanup: vi.fn(), +})); + +import * as ffi from "../../src/ffi"; +import { DataWeave } from "../../src/dataweave"; +import { DataWeaveError } from "../../src/errors"; + +describe("DataWeave.initialize() native ref-count safety", () => { + beforeEach(() => { + vi.mocked(ffi.initialize).mockReset(); + vi.mocked(ffi.createEngine).mockReset(); + vi.mocked(ffi.createEngineWithResolver).mockReset(); + vi.mocked(ffi.destroyEngine).mockReset(); + vi.mocked(ffi.cleanup).mockReset(); + }); + + it("releases the native library ref-count if engine creation fails after ffi.initialize() succeeded", () => { + vi.mocked(ffi.initialize).mockImplementation(() => {}); + vi.mocked(ffi.createEngineWithResolver).mockImplementation(() => { + throw new Error("native engine creation boom"); + }); + + const dw = new DataWeave({ libPath: "mock-lib-path", resolveModule: () => null }); + + expect(() => dw.initialize()).toThrow(DataWeaveError); + + // ffi.initialize() already succeeded, incrementing the native library's + // ref count. Since `initialized` never became true, cleanup()'s + // early-return guard means nothing else would ever call ffi.cleanup() -- + // initialize()'s own catch block must have released it. + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + }); + + it("does not call ffi.cleanup() when ffi.initialize() itself is what fails", () => { + vi.mocked(ffi.initialize).mockImplementation(() => { + throw new Error("library not found"); + }); + + const dw = new DataWeave({ libPath: "mock-lib-path" }); + + expect(() => dw.initialize()).toThrow(DataWeaveError); + + // No ref count was ever acquired, so there is nothing to release. + expect(ffi.cleanup).not.toHaveBeenCalled(); + }); + + it("leaves engineHandle unset and the instance cleanly re-initializable after a failed attempt", () => { + vi.mocked(ffi.initialize).mockImplementation(() => {}); + vi.mocked(ffi.createEngine) + .mockImplementationOnce(() => { + throw new Error("transient native failure"); + }) + .mockImplementationOnce(() => 42); + + const dw = new DataWeave({ libPath: "mock-lib-path" }); + + expect(() => dw.initialize()).toThrow(DataWeaveError); + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + + // A later initialize() call (e.g. once the transient failure clears) + // must succeed cleanly -- the failed attempt must not have left the + // instance permanently "half-initialized" (this.initialized stuck true + // without an engine handle, or vice versa). + vi.mocked(ffi.cleanup).mockClear(); + dw.initialize(); + expect(ffi.createEngine).toHaveBeenCalledTimes(2); + + dw.cleanup(); + expect(ffi.destroyEngine).toHaveBeenCalledWith(42); + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + }); + + it("does not call ffi.cleanup() from initialize() on the successful path", () => { + vi.mocked(ffi.initialize).mockImplementation(() => {}); + vi.mocked(ffi.createEngine).mockImplementation(() => 7); + + const dw = new DataWeave({ libPath: "mock-lib-path" }); + dw.initialize(); + + expect(ffi.cleanup).not.toHaveBeenCalled(); + + dw.cleanup(); + expect(ffi.destroyEngine).toHaveBeenCalledWith(7); + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + }); +}); From 7514e71786ec55bd367699670ed851f91225df50 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 11:13:17 -0300 Subject: [PATCH 06/63] W-23692110: document independent per-instance engines --- native-lib/node/README.md | 18 ++--- native-lib/node/docs/external-modules.md | 90 ++++++++++-------------- 2 files changed, 48 insertions(+), 60 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 3f135c4..bfc5c68 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -444,16 +444,16 @@ The Node.js binding uses **N-API** (Node-API) for C addon integration: **Important:** Do not share a single `DataWeave` instance across Worker threads. Use the module-level functions (which use a global singleton) or create separate instances per thread. -**Custom module resolvers and Worker threads:** the native layer installs at -most one resolver callback for the whole process lifetime, and it is bound to -the Worker (main thread or a `worker_threads` Worker) that registered it -first — see [External Modules: Multiple Resolvers](docs/external-modules.md#multiple-resolvers-in-one-process). +**Custom module resolvers and Worker threads:** each resolver-backed +`DataWeave` instance's native engine is bound to the thread that created it +(main thread or a `worker_threads` Worker) — see +[External Modules: Multiple Independent Engines](docs/external-modules.md#multiple-independent-engines). Custom-module resolution attempted from any *other* thread is not routed to -that thread's own `resolveModule` callback; it silently falls back to -built-in modules only (custom module paths resolve as "not found" rather than -crashing or hanging). If you need per-Worker custom modules, resolve them on -the thread that first constructs a resolver-backed `DataWeave` instance, or -avoid resolver-backed instances in worker pools altogether. +that engine's `resolveModule` callback; it silently falls back to built-in +modules only (custom module paths resolve as "not found" rather than +crashing or hanging). If you need custom modules on multiple Workers, +construct and use a separate resolver-backed `DataWeave` instance on each +Worker, created on that Worker itself. ## Platform Support diff --git a/native-lib/node/docs/external-modules.md b/native-lib/node/docs/external-modules.md index 6b67ae9..6aa65c1 100644 --- a/native-lib/node/docs/external-modules.md +++ b/native-lib/node/docs/external-modules.md @@ -26,7 +26,7 @@ console.log(result.getString()); // "Hello World" **Important:** The module-level convenience functions (`run()`, `runStreaming()`, `runTransform()` exported directly from `@dataweave/native`) operate on a lazily-initialized singleton that takes no constructor options and therefore cannot be configured with `resolveModule` — you **must** construct your own `DataWeave` instance to use external modules, as shown above. -Additionally, external module resolution is currently supported only through `.run()` (the synchronous API). `.runStreaming()` and `.runTransform()` do not yet support external modules and will only have access to built-in modules. +Additionally, external module resolution is currently supported only through `.run()` (the synchronous API). For a resolver-backed engine, `.runStreaming()` and `.runTransform()` execute on a background thread and cannot invoke that engine's `resolveModule` callback — they always resolve only built-in modules, and any custom-module import fails closed (module "not found") rather than crashing or hanging. ## Resolver Factories @@ -113,7 +113,7 @@ Best for: Layered resolution with fallbacks (overrides, shared libraries, vendor ## How It Works -- **One resolver per process**: The native engine maintains a single resolver per process lifetime. Only the first resolver registered is used; subsequent `DataWeave` instances with different resolvers will silently reuse the first one. +- **Independent engines**: each `DataWeave` instance owns its own native engine, resolver, and script cache; instances with different resolvers coexist with no cross-talk. - **Resolution at compile time**: The resolver is invoked during script compilation, not per execution. - **Synchronous resolution**: The resolver callback must be synchronous (no `async`/`await`, no Promise return). - **Built-in modules**: Built-in modules (CompositeResolver) are always available and work alongside custom resolvers. @@ -173,9 +173,12 @@ if (!result.success) { **Debugging:** By default, a resolver failure logs only a fixed, content-free diagnostic line to stderr — the actual exception message and stack are suppressed, since they can carry resolver-controlled data (module source, credentials, filesystem paths). To see the detailed message and stack for diagnosing a failing resolver (e.g., directory does not exist, file unreadable due to permissions), set `DATAWEAVE_RESOLVER_DEBUG=1` in the process environment before running. Only enable this in a trusted debugging context, since the detailed output may expose sensitive resolver-controlled data. -### Multiple Resolvers in One Process +### Multiple Independent Engines -If you construct multiple `DataWeave` instances with different resolvers in the same process: +Each `DataWeave` instance owns its own native engine, resolver, and script +cache. You can construct as many resolver-backed instances as you want in the +same process — each one only ever resolves its own modules, with no +cross-talk between instances: ```typescript const dw1 = new DataWeave({ @@ -186,57 +189,42 @@ dw1.initialize(); const dw2 = new DataWeave({ resolveModule: modulesFromMap({ 'b.dwl': '...' }), }); -dw2.initialize(); // Only loads/ref-counts the native library — does NOT register a resolver +dw2.initialize(); -dw1.run('...'); // First resolver-backed run() in the process: installs dw1's resolver -dw2.run('...'); // Logs warning, silently reuses dw1's resolver instead of dw2's +dw1.run('...'); // Only 'a.dwl' is available to dw1 +dw2.run('...'); // Only 'b.dwl' is available to dw2 — dw1's modules are not visible here -// Both dw1 and dw2 use dw1's resolver (only 'a.dwl' is available) +dw1.cleanup(); +dw2.cleanup(); ``` -**The rule is "first resolver-backed `run()` wins," not "first `initialize()` wins."** -`initialize()` only loads and ref-counts the native library; the resolver -itself is registered lazily, on whichever instance's `run()` executes first -with a resolver configured. If `dw2.run()` happens to execute before -`dw1.run()` — even though `dw1.initialize()` ran first — `dw2`'s resolver -wins instead. - -**Workaround:** Use `composeResolvers()` to combine all modules into a single resolver: - -```typescript -const resolver = composeResolvers( - modulesFromMap({ 'a.dwl': '...' }), - modulesFromMap({ 'b.dwl': '...' }) -); - -const dw1 = new DataWeave({ resolveModule: resolver }); -dw1.initialize(); - -const dw2 = new DataWeave({ resolveModule: resolver }); -dw2.initialize(); // Both use the same resolver -``` - -**Worker threads:** the same one-resolver-per-process rule applies across -`worker_threads` Workers, not just across instances on one thread. The -resolver callback is additionally bound to the specific thread that first -registered it. A resolver-backed `DataWeave` constructed and initialized on a -Worker other than the one that registered the process's resolver will not -have its `resolveModule` invoked at all — custom module paths resolve as "not -found" (falling back to built-ins only) rather than crashing. There is -currently no supported way to run distinct custom-module resolvers on -different Workers in the same process; either resolve modules on the thread -that owns the process's resolver, or avoid resolver-backed instances in -worker pools. - -**Concurrent resolver-backed runs across Workers are unsupported and -memory-unsafe.** Beyond the "not found" fallback described above, calling a -resolver-backed `run()` concurrently from more than one Worker is not just -unsupported behavior — it is a memory-safety hazard. The native layer tracks -in-flight resolver results in unsynchronized, process-global state, and one -Worker's cleanup can free memory another Worker's concurrent call is still -using. Restrict resolver-backed execution to a single thread (or fully -serialize resolver-backed calls across Workers) until a future release -isolates per-instance engine state. +**`cleanup()` is required for every instance.** Each `DataWeave` instance's +engine is tracked in a native registry keyed by handle. `cleanup()` destroys +the engine and removes its registry entry; an instance that is never +`cleanup()`'d keeps its engine (and the JS `resolveModule` closure it holds a +reference to) alive for the lifetime of the process, even if the `DataWeave` +object itself is garbage-collected on the JS side. Always `cleanup()` in a +`finally` block, as shown throughout this document. + +`composeResolvers()` is not a workaround for any resolver-sharing limitation +— each engine already has its own resolver. It's simply a layering tool for +building one resolver out of several fallback sources (overrides, then a +shared directory, then vendor JARs); see [composeResolvers](#composeresolvers) +above. + +**Worker threads and thread ownership:** each resolver-backed engine is bound +to the thread that created it (the thread that called `new DataWeave(...)` +and `initialize()` with a `resolveModule` configured). Only that thread's +synchronous `run()` calls can invoke the engine's `resolveModule` callback. +`runStreaming()` and `runTransform()` execute on a background thread even +when called from the owner thread, so they can never invoke that engine's +resolver — nor can `run()` calls made from any other `worker_threads` Worker. +In all of these cases the engine fails closed: custom module paths resolve as +"not found" (falling back to built-ins only) rather than crashing or hanging. +There is no supported way to invoke one engine's resolver from a thread other +than the one that created it; if you need custom modules on multiple +Workers, construct and use a separate resolver-backed `DataWeave` instance +on each Worker. ## Security / Trust Model From 01a659bbe86b088cbe84eec98141cf1ca53ea547 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 17:38:23 -0300 Subject: [PATCH 07/63] W-23692110: Make Node engine bridge teardown safe against in-flight ops (F1, F2) Resolver-backed engine bridges could be freed while a background streaming/ transform uv_thread still dereferenced them via resolve_module_callback (F1), and napi_cleanup deleted thread-affine napi_refs from whatever thread made the last release (F2, undefined behavior across Workers). F1: add in_flight/destroy_pending accounting (under g_mutex). Streaming/transform setup pins the bridge via bridge_begin_op before spawning the worker thread; the completion sentinel releases it via bridge_end_op on the owner thread. destroyEngine unlinks immediately but defers the free (napi_ref delete + struct free) to the last draining op when in_flight > 0. F2: register a per-env cleanup hook (napi_add_env_cleanup_hook) per bridge at creation so each Worker/main env disposes its own napi_ref on its own thread; destroyEngine removes the hook before an early free. napi_cleanup no longer touches g_bridges and only performs the process-global GraalVM isolate teardown once. Co-Authored-By: Claude Sonnet 5 --- docs/reviews/pr-157-code-review-andy.md | 8 ++ native-lib/node/src/addon.c | 167 +++++++++++++++++++++--- 2 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 docs/reviews/pr-157-code-review-andy.md diff --git a/docs/reviews/pr-157-code-review-andy.md b/docs/reviews/pr-157-code-review-andy.md new file mode 100644 index 0000000..d2b9e8d --- /dev/null +++ b/docs/reviews/pr-157-code-review-andy.md @@ -0,0 +1,8 @@ +Findings +1. High native-lib/node/src/addon.c:925-936, native-lib/node/src/dataweave.ts:105-111 + cleanup() destroys the Java engine and immediately frees its resolver bridge. An active runStreaming() or runTransform() worker may already have retrieved that ScriptRuntime; a later module lookup then invokes resolve_module_callback() with the freed engine_bridge_t context. This is a use-after-free and can crash the Node process. Destruction needs to wait for active engine execution or retain/ref-count the bridge until completion. +2. Medium native-lib/node/src/addon.c:157-163,880, native-lib/node/src/dataweave.ts:81-83 + The addon treats the new engine symbols as optional when loading dwlib, but every DataWeave.initialize() now requires createEngine or createEngineWithResolver. Supplying an older, previously compatible dwlib through libPath will load successfully and then fail initialization even for resolver-less callers. Either require/check the new ABI up front with a clear compatibility error, or retain the legacy resolver-less path. +3. Medium Missing coverage for resolver-backed reinitialization and resolver errors. native-lib/node/tests/integration/edge-cases.test.ts:86-99 verifies reinitialization only without a resolver, and native-lib/node/tests/integration/dataweave-resolver.test.ts does not exercise a throwing resolver. These are the lifecycle/error paths newly affected by per-engine bridge allocation, destruction, and exception clearing. + There were no existing PR review comments or reviews to incorporate. I reviewed the PR description, design document, commits, and diff in an isolated detached worktree at: + /var/folders/qq/l28gmrtn0q15g333pg6nr8qw0000gn/T/opencode/pr157-review \ No newline at end of file diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index ed4ae2a..048e4d0 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -3,6 +3,7 @@ #include #include #include +#include // GraalVM function pointer types typedef int (*graal_create_isolate_fn)(void*, void**, void**); @@ -93,6 +94,14 @@ typedef struct engine_bridge { napi_ref resolver_js; // NULL => resolver-less engine (no bridge created) uv_thread_t owner; // JS thread that created and must run this engine resolver_result_node_t* results; // buffers to free after each run on this engine + // Lifecycle accounting, mutated only under g_mutex. A streaming/transform op + // runs the native call on a background uv_thread that can still call back into + // resolve_module_callback with this bridge as ctx, so the bridge must outlive + // every in-flight op. in_flight counts ops that can still dereference this + // bridge; destroy_pending marks that destroyEngine ran while in_flight > 0 and + // freeing was deferred to the last op draining on the owner thread. + int in_flight; + bool destroy_pending; struct engine_bridge* next; } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex @@ -126,6 +135,88 @@ static engine_bridge_t* bridge_find(long long handle) { return NULL; } +// Fully dispose of a bridge: delete its napi_ref, free tracked result buffers, +// free the struct. napi_ref/napi_env are thread-affine, so this MUST run on the +// bridge's owner thread (the JS/Worker thread that created it) while that env is +// still alive. The bridge must already be unlinked from g_bridges. Do NOT hold +// g_mutex across this call — it invokes N-API. Callers that freed a bridge +// *early* (destroyEngine / streaming completion) must first drop the env cleanup +// hook via napi_remove_env_cleanup_hook so Node never invokes it on freed memory; +// the hook path itself (bridge_env_cleanup) must not remove itself and calls this +// directly. +static void bridge_finalize(engine_bridge_t* b) { + if (b == NULL) return; + if (b->resolver_js != NULL && b->env != NULL) { + napi_delete_reference(b->env, b->resolver_js); + } + resolver_results_free_all(b); + free(b); +} + +// Env cleanup hook (F2): registered per resolver-backed bridge at creation via +// napi_add_env_cleanup_hook, so each Worker/main env disposes its OWN bridges on +// its OWN thread when that env tears down — instead of napi_cleanup deleting +// refs from whichever thread happens to release the last DataWeave instance, +// which is undefined behavior for thread-affine napi_env/napi_ref. Runs on the +// owner thread with the env still alive, which is exactly where napi_ref deletion +// is legal. +static void bridge_env_cleanup(void* arg) { + engine_bridge_t* b = (engine_bridge_t*)arg; + if (b == NULL) return; + + uv_mutex_lock(&g_mutex); + // Unlink from g_bridges if still present (destroyEngine may have already + // unlinked it while deferring a free — see below). + engine_bridge_t** pp = &g_bridges; + while (*pp != NULL) { + if (*pp == b) { *pp = b->next; break; } + pp = &(*pp)->next; + } + // An in-flight streaming/transform op holds a live threadsafe function that + // keeps this env's event loop alive, so the env should never tear down while + // in_flight > 0. Guard defensively anyway: mark destroy_pending and let the + // op's completion path drain and finalize it (do NOT finalize here, the op's + // background thread could still dereference this bridge). + if (b->in_flight > 0) { + b->destroy_pending = true; + uv_mutex_unlock(&g_mutex); + return; + } + uv_mutex_unlock(&g_mutex); + + // We are inside Node's invocation of this hook, so we must not (and need not) + // call napi_remove_env_cleanup_hook for ourselves here. + bridge_finalize(b); +} + +// Begin a streaming/transform op on a resolver-backed engine: look up the bridge +// and mark one op in flight so it (and its napi_ref) cannot be freed while the +// background uv_thread can still call resolve_module_callback with it (F1). +// Returns the bridge pointer (stable for the op's lifetime, since in_flight > 0 +// blocks both destroyEngine and the env cleanup hook from freeing it) or NULL for +// a resolver-less engine / unknown handle, in which case there is nothing to +// protect and completion must not call bridge_end_op. +static engine_bridge_t* bridge_begin_op(long long handle) { + uv_mutex_lock(&g_mutex); + engine_bridge_t* b = bridge_find(handle); + if (b != NULL) b->in_flight++; + uv_mutex_unlock(&g_mutex); + return b; +} + +// End a streaming/transform op. Runs on the owner (JS) thread from the completion +// sentinel. If destroyEngine (or the env cleanup hook) ran while this op was in +// flight, it deferred the free — already unlinked from g_bridges — so the last op +// to drain finalizes the bridge here, on the legal (owner) thread. +static void bridge_end_op(engine_bridge_t* b) { + if (b == NULL) return; + uv_mutex_lock(&g_mutex); + b->in_flight--; + bool finalize = (b->destroy_pending && b->in_flight == 0); + uv_mutex_unlock(&g_mutex); + if (finalize) bridge_finalize(b); +} + // --- Initialization --- struct init_args { @@ -331,6 +422,9 @@ struct streaming_work { long long handle; char* script; char* inputs_json; + // Non-NULL only for resolver-backed engines: the bridge whose in_flight count + // this op holds. The completion sentinel calls bridge_end_op on it (F1). + engine_bridge_t* bridge; }; static void call_js_write(napi_env env, napi_value js_callback, void* context, void* data) { @@ -350,6 +444,10 @@ static void call_js_write(napi_env env, napi_value js_callback, void* context, v uv_thread_join(&w->tid); napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); + // Drop the in-flight hold last, on this owner thread: if destroyEngine ran + // during the op it deferred the free to here (F1). After this the bridge may + // be freed, so touch nothing on it afterward. + bridge_end_op(w->bridge); free(w); return; } @@ -452,6 +550,13 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i napi_value promise; napi_create_promise(env, &w->deferred, &promise); + // Pin the resolver bridge (if any) for the whole op so it outlives a concurrent + // destroyEngine/cleanup and the background thread can safely call back into + // resolve_module_callback (F1). NULL for resolver-less engines. Must happen + // before spawning the thread; the completion sentinel releases it via + // bridge_end_op. No early return exists between here and the spawn. + w->bridge = bridge_begin_op(w->handle); + uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; @@ -473,6 +578,9 @@ struct transform_work { char* input_name; char* input_mime_type; char* input_charset; + // Non-NULL only for resolver-backed engines: the bridge whose in_flight count + // this op holds. The completion sentinel calls bridge_end_op on it (F1). + engine_bridge_t* bridge; }; struct read_request { @@ -620,6 +728,10 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* uv_thread_join(&w->tid); napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); napi_release_threadsafe_function(w->write_tsfn, napi_tsfn_release); + // Drop the in-flight hold last, on this owner thread: if destroyEngine ran + // during the op it deferred the free to here (F1). After this the bridge may + // be freed, so touch nothing on it afterward. + bridge_end_op(w->bridge); free(w); return; } @@ -730,6 +842,13 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i napi_value promise; napi_create_promise(env, &w->deferred, &promise); + // Pin the resolver bridge (if any) for the whole op so it outlives a concurrent + // destroyEngine/cleanup and the background thread can safely call back into + // resolve_module_callback (F1). NULL for resolver-less engines. Must happen + // before spawning the thread; the completion sentinel releases it via + // bridge_end_op. No early return exists between here and the spawn. + w->bridge = bridge_begin_op(w->handle); + uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; @@ -910,6 +1029,11 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i bridge->handle = handle; uv_mutex_lock(&g_mutex); bridge->next = g_bridges; g_bridges = bridge; uv_mutex_unlock(&g_mutex); + // Register a per-env cleanup hook so THIS Worker/main thread disposes this + // bridge's napi_ref on its own thread when its env tears down (F2). napi_cleanup + // no longer touches bridge refs. destroyEngine removes this hook before an + // early free so Node never calls it on freed memory. + napi_add_env_cleanup_hook(env, bridge_env_cleanup, bridge); napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; } @@ -926,13 +1050,27 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { void* thread = NULL; if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, handle); fn_detach_thread(thread); } } + // Unlink the bridge from g_bridges, but only free it now if no streaming/ + // transform op is still in flight. A background op can still call back into + // resolve_module_callback with this bridge as ctx (F1), so if in_flight > 0 + // we mark destroy_pending and defer the free to the completion sentinel, + // which drains on this same owner thread. Deleting the napi_ref is only legal + // on the owner thread, and destroyEngine is called from it, so we finalize + // here in the common (not-in-flight) case. uv_mutex_lock(&g_mutex); engine_bridge_t** pp = &g_bridges; engine_bridge_t* found = NULL; while (*pp != NULL) { if ((*pp)->handle == handle) { found = *pp; *pp = found->next; break; } pp = &(*pp)->next; } + bool defer = false; + if (found != NULL) { + if (found->in_flight > 0) { found->destroy_pending = true; defer = true; } + } uv_mutex_unlock(&g_mutex); if (found != NULL) { - if (found->resolver_js != NULL && found->env != NULL) napi_delete_reference(found->env, found->resolver_js); - resolver_results_free_all(found); free(found); + // Drop the env cleanup hook: whether we finalize now or defer to the + // draining op, the free happens explicitly, so Node must never invoke + // the hook on this (soon-to-be or already) freed bridge. + napi_remove_env_cleanup_hook(env, bridge_env_cleanup, found); + if (!defer) bridge_finalize(found); } return NULL; } @@ -1002,22 +1140,15 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { if (g_initialized) { g_ref_count--; if (g_ref_count <= 0) { - // Tear down any engine bridges never explicitly destroyed. We already - // hold g_mutex here, so walk g_bridges inline (no re-lock): delete each - // bridge's napi_ref on its own env, free its tracked result buffers, and - // free the node. - engine_bridge_t* b = g_bridges; - while (b != NULL) { - engine_bridge_t* next = b->next; - if (b->resolver_js != NULL && b->env != NULL) { - napi_delete_reference(b->env, b->resolver_js); - } - resolver_results_free_all(b); - free(b); - b = next; - } - g_bridges = NULL; - + // F2: do NOT walk g_bridges to delete napi_refs here. napi_env/napi_ref are + // thread-affine, and this last-release call can arrive on any Worker thread — + // not necessarily the one that owns a given bridge. Deleting a reference from + // the wrong thread is undefined behavior. Instead, each resolver-backed bridge + // registered a per-env cleanup hook (bridge_env_cleanup) at creation, so its + // owning Worker/main thread disposes its own napi_ref on its own thread when + // that env tears down. Any bridge still linked in g_bridges is owned by such a + // hook and must be left alone here. Only the process-global GraalVM isolate + // teardown below is safe to run once, on the last release, from this thread. uv_thread_t tid; uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; From 8d78d428ca9609ec58e84696f21c5108d57a7c63 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 17:46:24 -0300 Subject: [PATCH 08/63] W-23692110: Reject invalid engine handles and fix resolver-buffer leak (F3, F4) create_engine/create_engine_with_resolver are GraalVM @CEntryPoints; if Java construction throws, the entrypoint returns the long long default value (0) instead of propagating. Treat any handle <= 0 as invalid: throw an N-API error and unwind the bridge (delete napi_ref, free struct) before it's ever linked into g_bridges or given a cleanup hook, instead of returning/inserting a bogus handle. Also fix a resolver-source buffer leak: if the malloc for the tracking node itself fails, the buffer was previously left untracked and unfreeable. resolver_results_track now reports tracking failure so resolve_module_callback can free the buffer and report "unresolved" instead of leaking it. --- native-lib/node/src/addon.c | 38 +++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 048e4d0..8d14b44 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -106,13 +106,18 @@ typedef struct engine_bridge { } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex -static void resolver_results_track(engine_bridge_t* b, char* buf) { - if (b == NULL || buf == NULL) return; +// Returns true if the buffer is now tracked (or there was nothing to track). +// Returns false only when a buffer was supplied but the tracking node could +// not be allocated — in that case the caller owns `buf` again and MUST free +// it itself, since it will never be reachable from b->results. +static bool resolver_results_track(engine_bridge_t* b, char* buf) { + if (b == NULL || buf == NULL) return true; resolver_result_node_t* node = (resolver_result_node_t*)malloc(sizeof(resolver_result_node_t)); - if (node == NULL) return; // Leak the buffer rather than crash; best-effort tracking. + if (node == NULL) return false; // OOM: caller must free buf to avoid leaking it untracked. node->buf = buf; node->next = b->results; b->results = node; + return true; } static void resolver_results_free_all(engine_bridge_t* b) { @@ -986,7 +991,13 @@ static char* resolve_module_callback(void* thread, void* ctx, const char* module } // null/undefined/other → not found (result_source stays NULL) - resolver_results_track(bridge, result_source); + if (!resolver_results_track(bridge, result_source)) { + // Tracking-node allocation failed (OOM): result_source would otherwise + // be an untracked buffer that nothing ever frees. Free it here and + // report "unresolved" instead of leaking it. + free(result_source); + return NULL; + } return result_source; // Native copies this immediately; we free the original after the call. } @@ -1001,6 +1012,12 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { if (fn_attach_thread(g_isolate, &thread) != 0) { napi_throw_error(env, NULL, "Failed to attach thread"); return NULL; } long long handle = fn_create_engine(thread); fn_detach_thread(thread); + // A GraalVM @CEntryPoint that throws on the Java side returns the return + // type's default value instead of propagating the exception — 0 for a + // long long. The real handle registry only ever hands out handles >= 1, so + // any handle <= 0 means construction failed; never hand that back to JS as + // if it were usable. + if (handle <= 0) { napi_throw_error(env, NULL, "create_engine returned an invalid handle"); return NULL; } napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; } @@ -1027,6 +1044,19 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i long long handle = fn_create_engine_with_resolver(thread, resolve_module_callback, (void*)bridge); fn_detach_thread(thread); + // Same invalid-handle guard as napi_create_engine: a Java-side construction + // failure surfaces here as handle == 0 (GraalVM @CEntryPoint default-value + // semantics), and any handle <= 0 is never valid. Reject before this bridge + // is linked into g_bridges or a cleanup hook is registered for it — at this + // point neither has happened yet, so tearing the bridge down is just + // deleting the napi_ref and freeing the struct. + if (handle <= 0) { + napi_delete_reference(env, bridge->resolver_js); + free(bridge); + napi_throw_error(env, NULL, "create_engine_with_resolver returned an invalid handle"); + return NULL; + } + bridge->handle = handle; uv_mutex_lock(&g_mutex); bridge->next = g_bridges; g_bridges = bridge; uv_mutex_unlock(&g_mutex); // Register a per-env cleanup hook so THIS Worker/main thread disposes this From bdb8173b0a2a2ab6273c6c9282043d7c0cd8faca Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 17:53:26 -0300 Subject: [PATCH 09/63] W-23692110: Use bridge_finalize in create_engine_with_resolver reject path The handle <= 0 rejection path did manual napi_delete_reference + free(bridge) instead of bridge_finalize, so any resolver-callback buffers already tracked via resolver_results_track (if resolve_module_callback ran during a failed eager module setup before construction was reported as failed) were leaked. bridge_finalize already frees tracked buffers before freeing the struct and is a safe drop-in here since the bridge was never linked into g_bridges or given a cleanup hook at this point. --- native-lib/node/src/addon.c | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 8d14b44..3a8cdad 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1048,11 +1048,14 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i // failure surfaces here as handle == 0 (GraalVM @CEntryPoint default-value // semantics), and any handle <= 0 is never valid. Reject before this bridge // is linked into g_bridges or a cleanup hook is registered for it — at this - // point neither has happened yet, so tearing the bridge down is just - // deleting the napi_ref and freeing the struct. + // point neither has happened, so there's nothing to unlink/unhook. Still use + // bridge_finalize (not a manual napi_delete_reference+free) because the failed + // construction may have called resolve_module_callback (e.g. during eager + // module setup) before ultimately failing, which can have already populated + // bridge->results via resolver_results_track; bridge_finalize frees those + // tracked buffers too, so nothing is dropped on the floor. if (handle <= 0) { - napi_delete_reference(env, bridge->resolver_js); - free(bridge); + bridge_finalize(bridge); napi_throw_error(env, NULL, "create_engine_with_resolver returned an invalid handle"); return NULL; } From c3e2a9692d965e6694c2e071a1c61994b0711018 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 18:02:04 -0300 Subject: [PATCH 10/63] Require per-engine ABI symbols at load and align resolver log policy Node addon.c: fail initialize() with a clear message when dwlib lacks the per-engine symbols (create_engine, create_engine_with_resolver, destroy_engine, run_script_engine, run_script_callback_engine, run_script_input_output_callback_engine) instead of deferring to a confusing per-call error, since every initialize() now creates an engine. CallbackWeaveResourceResolver.resolve(): suppress exception detail by default and only log e.getMessage() when DATAWEAVE_RESOLVER_DEBUG=1, matching the C-side resolve_module_callback policy in addon.c. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 20 ++++++++++++++++++- .../lib/CallbackWeaveResourceResolver.java | 12 +++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 3a8cdad..ee594a9 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -250,7 +250,10 @@ static void init_thread_fn(void* arg) { uv_dlsym(&g_lib, "run_script_callback", (void**)&fn_run_script_callback); uv_dlsym(&g_lib, "run_script_input_output_callback", (void**)&fn_run_script_input_output_callback); - // Load per-engine entrypoints (optional - newer symbols) + // Load per-engine entrypoints. Every initialize() call creates an engine via + // create_engine/create_engine_with_resolver (see dataweave.ts), so these are + // load-time required, not optional, even though they are newer than the + // legacy singleton symbols above. uv_dlsym(&g_lib, "create_engine", (void**)&fn_create_engine); uv_dlsym(&g_lib, "create_engine_with_resolver", (void**)&fn_create_engine_with_resolver); uv_dlsym(&g_lib, "destroy_engine", (void**)&fn_destroy_engine); @@ -264,6 +267,21 @@ static void init_thread_fn(void* arg) { return; } + // Fail fast, with a clear message, if the loaded dwlib predates the + // per-engine ABI (W-23692110). Without this check, the library would load + // "successfully" here and every initialize() call would still fail later + // deep inside createEngine()/createEngineWithResolver() with a confusing + // "not available in native library" error instead of this one. + if (!fn_create_engine || !fn_create_engine_with_resolver || !fn_destroy_engine || + !fn_run_script_engine || !fn_run_script_callback_engine || + !fn_run_script_input_output_callback_engine) { + snprintf(args->error, sizeof(args->error), + "dwlib is missing required per-engine symbols (expected in dwlib " + "built with W-23692110 or later) - rebuild/upgrade the native library"); + args->result = -2; + return; + } + void* boot_thread = NULL; rc = fn_create_isolate(NULL, &g_isolate, &boot_thread); if (rc != 0) { diff --git a/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java b/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java index 9f85a7f..a71973b 100644 --- a/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java +++ b/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java @@ -63,8 +63,16 @@ public Option resolve(NameIdentifier nameIdentifier) { ); } } catch (Exception e) { - // Log and return empty on any error - System.err.println("Error resolving module " + path + ": " + e.getMessage()); + // Log and return empty on any error. Mirrors the C-side resolver bridge's + // policy (see resolve_module_callback in addon.c): the exception message + // may carry resolver-controlled data (module source, file paths, + // credentials), so suppress it by default and only include it when the + // caller has opted in via DATAWEAVE_RESOLVER_DEBUG=1. + if ("1".equals(System.getenv("DATAWEAVE_RESOLVER_DEBUG"))) { + System.err.println("Error resolving module " + path + ": " + e.getMessage()); + } else { + System.err.println("Error resolving module: " + path); + } return Option.empty(); } } From e513c4d2ac071585197cc0293d08764830bbf044 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 18:10:13 -0300 Subject: [PATCH 11/63] Make default resolver-error log fully content-free, not just message-free The default (non-debug) branch of CallbackWeaveResourceResolver.resolve()'s catch block still logged the module path unconditionally, which is dynamic, resolver-controlled content. Drop path too in the default branch so the log line is fully static, matching the C-side resolve_module_callback's actual default behavior in addon.c. Co-Authored-By: Claude Sonnet 5 --- .../weave/lib/CallbackWeaveResourceResolver.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java b/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java index a71973b..c259608 100644 --- a/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java +++ b/native-lib/src/main/java/org/mule/weave/lib/CallbackWeaveResourceResolver.java @@ -64,14 +64,19 @@ public Option resolve(NameIdentifier nameIdentifier) { } } catch (Exception e) { // Log and return empty on any error. Mirrors the C-side resolver bridge's - // policy (see resolve_module_callback in addon.c): the exception message - // may carry resolver-controlled data (module source, file paths, - // credentials), so suppress it by default and only include it when the - // caller has opted in via DATAWEAVE_RESOLVER_DEBUG=1. + // policy (see resolve_module_callback in addon.c): both the exception + // message AND the module path are resolver-controlled/dynamic content + // (module source, file paths, credentials can leak through either), so + // the default log line is fully static/content-free, with no path and no + // message. Only include them when the caller has opted in via + // DATAWEAVE_RESOLVER_DEBUG=1. if ("1".equals(System.getenv("DATAWEAVE_RESOLVER_DEBUG"))) { System.err.println("Error resolving module " + path + ": " + e.getMessage()); } else { - System.err.println("Error resolving module: " + path); + System.err.println( + "Error resolving module (details suppressed; set " + + "DATAWEAVE_RESOLVER_DEBUG=1 to log path/message — may expose " + + "resolver-controlled data)."); } return Option.empty(); } From e23bdb0c2a8febe8550c7484df9fcccb47f7c6ff Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 18:20:03 -0300 Subject: [PATCH 12/63] test(node): add lifecycle/error coverage for F1/F4/F6 remediation Adds four resolver-backed integration tests to dataweave-resolver.test.ts that exercise paths untested by prior remediation commits: - a throwing resolveModule() causes run() to fail cleanly (success:false) rather than crash, exercising resolve_module_callback's exception catch/clear/log-gated-by-DATAWEAVE_RESOLVER_DEBUG path. - a resolver-backed instance's initialize -> cleanup -> initialize cycle still resolves a custom module afterwards (fresh engine_bridge_t). - cleanup() raced against an in-flight resolver-backed runStreaming() does not crash -- the regression test for the F1 in-flight-refcount fix, started deterministically by calling gen.next() without awaiting it before calling cleanup(), so the native call is already handed to the libuv worker thread when cleanup() runs on the JS thread. - run() after cleanup() throws DataWeaveError via dataweave.ts's ensureInitialized() guard (the TS-level half of the destroyed/unknown engine handle contract). --- .../integration/dataweave-resolver.test.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/native-lib/node/tests/integration/dataweave-resolver.test.ts b/native-lib/node/tests/integration/dataweave-resolver.test.ts index deeaacb..65ad626 100644 --- a/native-lib/node/tests/integration/dataweave-resolver.test.ts +++ b/native-lib/node/tests/integration/dataweave-resolver.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, afterAll } from "vitest"; import { DataWeave, cleanup } from '../../src/dataweave'; +import { DataWeaveError } from '../../src/errors'; import { modulesFromMap } from '../../src/resolver'; // Every test below constructs its own explicit DataWeave instance (rather @@ -136,4 +137,152 @@ describe('DataWeave with resolver', () => { expect(metadata.error).toBeTruthy(); expect(chunks.length).toBe(0); }); + + // resolve_module_callback in addon.c catches a JS exception thrown by the + // user-supplied resolver (napi_call_function returning napi_pending_exception), + // clears it via napi_get_and_clear_last_exception, logs a content-free + // diagnostic (see the DATAWEAVE_RESOLVER_DEBUG gating), and reports "not + // found" back to the DataWeave runtime -- rather than letting the pending + // exception leak into a later napi call or crash the process. This is a + // synchronous run() on the JS thread that created the bridge (the "owner" + // thread check in resolve_module_callback passes), so the callback is + // actually invoked, unlike the streaming/transform cross-thread case above. + it('throwing resolver makes run() fail cleanly instead of crashing the process', () => { + const dw = trackedDataWeave({ + resolveModule: () => { + throw new Error('resolver blew up'); + }, + }); + dw.initialize(); + + const result = dw.run(` + %dw 2.0 + import org::test::throwingResolverLib + output application/json + --- + {} + `); + + // The test itself completing (no uncaught exception / segfault) is the + // crash-check; we don't assert on the internal error message wording. + expect(result.success).toBe(false); + }); + + // Regression test for a resolver-backed engine's initialize -> cleanup -> + // initialize cycle. Unlike the resolver-less reinit test in + // edge-cases.test.ts, this exercises createEngineWithResolver's bridge + // (engine_bridge_t) lifecycle: cleanup() destroys the bridge and its engine + // handle, and the following initialize() must build a brand new bridge + // (new napi_ref on the resolver, new owner-thread record) that resolves + // custom modules again, not a stale or dangling one. + it('resolver-backed instance resolves a custom module again after initialize -> cleanup -> initialize', () => { + const dw = trackedDataWeave({ + resolveModule: modulesFromMap({ + 'org/test/reinitLib.dwl': '%dw 2.0\nfun greet(n: String) = "Hello " ++ n', + }), + }); + dw.initialize(); + dw.cleanup(); + dw.initialize(); + + const result = dw.run(` + %dw 2.0 + import org::test::reinitLib + output application/json + --- + reinitLib::greet("Reinit") + `); + + expect(result.success).toBe(true); + expect(JSON.parse(result.getString()!)).toBe("Hello Reinit"); + }); + + // Regression test for the F1 use-after-free fix: a resolver-backed engine's + // engine_bridge_t used to be freed by destroy_engine (called from cleanup()) + // even while a background uv_thread (streaming_thread_fn) was still + // mid-flight and could call resolve_module_callback with that bridge as + // ctx -- a use-after-free. The fix adds in-flight accounting under g_mutex: + // destroy_engine now defers the actual free until the background operation + // decrements in_flight back to zero in its completion sentinel. + // + // To race cleanup() against the in-flight operation deterministically, we + // start the generator's *first* `.next()` call but do not await it before + // calling cleanup(). Calling an async generator's .next() runs its body + // synchronously up to the first suspension point (an `await`); by that + // point runStreaming's synchronous prefix -- including the native + // runScriptStreamingEngine call that hands the operation to a libuv + // worker-pool thread -- has already executed. cleanup() is then called + // from the JS thread while that native call may already be running + // concurrently on the worker thread, which is exactly the race the F1 fix + // guards against. Before that fix this was a real crash/UAF risk; after it, + // this must complete cleanly (settle, not crash, not hang) regardless of + // which side of the race wins. + it('cleanup() racing an in-flight resolver-backed runStreaming() does not crash (F1 regression)', async () => { + const dw = trackedDataWeave({ + resolveModule: modulesFromMap({ + 'org/test/cleanupDuringStream.dwl': '%dw 2.0\nfun greet(n: String) = "Hello " ++ n', + }), + }); + dw.initialize(); + + const gen = dw.runStreaming(` + %dw 2.0 + import org::test::cleanupDuringStream + output application/json + --- + cleanupDuringStream::greet("Streaming") + `); + + // Start the native call without awaiting it, then immediately race + // cleanup() against it. + const firstNext = gen.next(); + dw.cleanup(); + + // The outcome (a settled chunk, the terminal metadata, or a rejection) + // doesn't matter -- what matters is that it settles instead of crashing + // the process or hanging, and that no unhandled rejection escapes this + // test. We explicitly catch here (rather than asserting a specific + // resolution) and prove settlement, one way or the other. + let settled = false; + try { + await firstNext; + settled = true; + } catch (err) { + settled = true; + expect(err).toBeDefined(); + } + expect(settled).toBe(true); + + // Drain whatever remains so no background callback fires after this test + // (and this file's process) moves on. + try { + let result = await gen.next(); + while (!result.done) { + result = await gen.next(); + } + } catch { + // Draining after a mid-stream cleanup may itself reject; that's fine. + } + }); + + // Node-layer contract (F4-adjacent): once cleanup() has torn an instance + // down, run() must be rejected by dataweave.ts's own ensureInitialized() + // guard -- a DataWeaveError with a "not initialized" message -- rather than + // reaching the native addon at all with a handle that no longer refers to a + // live engine. This is the TS-level half of the destroyed/unknown-handle + // contract; the native "Unknown engine handle" string is the deeper + // contract the addon enforces if it were ever called with a stale handle, + // which this guard prevents from happening via the public API. + it('run() after cleanup() throws a DataWeaveError via the TS-level ensureInitialized guard', () => { + const dw = trackedDataWeave({ + resolveModule: modulesFromMap({ + 'org/test/destroyedHandleLib.dwl': '...', + }), + }); + dw.initialize(); + dw.cleanup(); + + expect(() => dw.run('1 + 1')).toThrow(DataWeaveError); + expect(() => dw.run('1 + 1')).toThrow(/DataWeave runtime not initialized/); + }); }); From 16533e09d6738af30c445a71008534c9410df58d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 19:02:09 -0300 Subject: [PATCH 13/63] W-23692110: Add native-level test for unknown engine handle contract Extracts the "Unknown engine handle" JSON literal shared by run_script_engine, run_script_callback_engine, and run_script_input_output_callback_engine into a single package-visible constant (NativeLib.UNKNOWN_ENGINE_HANDLE_JSON), so the exact error contract can be asserted from a plain JVM unit test. The @CEntryPoint methods themselves can't be exercised directly from a JVM test since their GraalVM word-type parameters (IsolateThread, CCharPointer) only resolve inside a compiled native image. Adds ScriptRuntimeTest#unknownEngineHandleProducesExactErrorJson, which combines that constant assertion with the existing proof that ScriptRuntime.get() returns null for an unregistered handle. --- .../java/org/mule/weave/lib/NativeLib.java | 17 ++++++++++--- .../org/mule/weave/lib/ScriptRuntimeTest.java | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/native-lib/src/main/java/org/mule/weave/lib/NativeLib.java b/native-lib/src/main/java/org/mule/weave/lib/NativeLib.java index 30b1ed6..f635ccf 100644 --- a/native-lib/src/main/java/org/mule/weave/lib/NativeLib.java +++ b/native-lib/src/main/java/org/mule/weave/lib/NativeLib.java @@ -19,6 +19,17 @@ */ public class NativeLib { + /** + * The exact JSON error payload returned by the per-engine entrypoints + * ({@link #runScriptEngine}, {@link #runScriptCallbackEngine}, + * {@link #runScriptInputOutputCallbackEngine}) when {@code handle} does not identify a + * live engine. Package-visible (rather than embedded as a string literal at each call + * site) so the exact contract can be asserted directly from a JVM unit test, since the + * {@code @CEntryPoint} methods themselves rely on GraalVM word types that only resolve + * inside a compiled native image. + */ + static final String UNKNOWN_ENGINE_HANDLE_JSON = "{\"success\":false,\"error\":\"Unknown engine handle\"}"; + /** * Native method that executes a DataWeave script with inputs and returns the result. * Can be called from Python via FFI. @@ -417,7 +428,7 @@ public static CCharPointer runScriptEngine( IsolateThread thread, long handle, CCharPointer script, CCharPointer inputsJson) { ScriptRuntime runtime = ScriptRuntime.get(handle); if (runtime == null) { - return toUnmanagedCString("{\"success\":false,\"error\":\"Unknown engine handle\"}"); + return toUnmanagedCString(UNKNOWN_ENGINE_HANDLE_JSON); } String dwScript = CTypeConversion.toJavaString(script); String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); @@ -445,7 +456,7 @@ public static CCharPointer runScriptCallbackEngine( NativeCallbacks.WriteCallback writeCallback, PointerBase ctx) { ScriptRuntime runtime = ScriptRuntime.get(handle); if (runtime == null) { - return toUnmanagedCString("{\"success\":false,\"error\":\"Unknown engine handle\"}"); + return toUnmanagedCString(UNKNOWN_ENGINE_HANDLE_JSON); } String dwScript = CTypeConversion.toJavaString(script); String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); @@ -480,7 +491,7 @@ public static CCharPointer runScriptInputOutputCallbackEngine( PointerBase ctx) { ScriptRuntime runtime = ScriptRuntime.get(handle); if (runtime == null) { - return toUnmanagedCString("{\"success\":false,\"error\":\"Unknown engine handle\"}"); + return toUnmanagedCString(UNKNOWN_ENGINE_HANDLE_JSON); } String dwScript = CTypeConversion.toJavaString(script); String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); diff --git a/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeTest.java b/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeTest.java index d3a2f3e..bf35264 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeTest.java @@ -657,6 +657,31 @@ void engineWithoutResolverStillRunsBuiltins() { ScriptRuntime.destroy(h); } + /** + * Locks in the hard contract for the per-engine FFI entrypoints + * ({@code run_script_engine}, {@code run_script_callback_engine}, + * {@code run_script_input_output_callback_engine} in {@link NativeLib}): running a + * script against an unknown or already-destroyed engine handle must return exactly + * {@code {"success":false,"error":"Unknown engine handle"}} rather than throwing. + * + *

The {@code @CEntryPoint} methods themselves cannot be invoked from a plain JVM + * unit test — they take GraalVM word types ({@code IsolateThread}, {@code CCharPointer}) + * whose boxing infrastructure is only initialized inside a compiled native image (calling + * e.g. {@code WordFactory.nullPointer()} from a hosted JVM test throws + * {@code NullPointerException} from {@code WordBoxFactory}). All three entrypoints funnel + * the unknown-handle case through the same {@code UNKNOWN_ENGINE_HANDLE_JSON} constant, so + * asserting on that constant — combined with {@link #twoEnginesResolveOnlyTheirOwnModule} + * proving {@link ScriptRuntime#get} returns {@code null} for an unregistered/destroyed + * handle — verifies the full contract without needing the native runtime.

+ */ + @Test + void unknownEngineHandleProducesExactErrorJson() { + long unregisteredHandle = Long.MAX_VALUE; + assertNull(ScriptRuntime.get(unregisteredHandle)); + assertEquals("{\"success\":false,\"error\":\"Unknown engine handle\"}", + NativeLib.UNKNOWN_ENGINE_HANDLE_JSON); + } + static class Result { boolean success; String result; From 51b10a6d7f04078c34cb1c961ff7541170036588 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 19:02:15 -0300 Subject: [PATCH 14/63] chore: remove PR-157 code review process notes from repo These were internal review artifacts incidentally committed during remediation work (one references a local temp worktree path); they aren't product documentation and shouldn't ship in the repo. --- docs/reviews/pr-157-code-review-andy.md | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 docs/reviews/pr-157-code-review-andy.md diff --git a/docs/reviews/pr-157-code-review-andy.md b/docs/reviews/pr-157-code-review-andy.md deleted file mode 100644 index d2b9e8d..0000000 --- a/docs/reviews/pr-157-code-review-andy.md +++ /dev/null @@ -1,8 +0,0 @@ -Findings -1. High native-lib/node/src/addon.c:925-936, native-lib/node/src/dataweave.ts:105-111 - cleanup() destroys the Java engine and immediately frees its resolver bridge. An active runStreaming() or runTransform() worker may already have retrieved that ScriptRuntime; a later module lookup then invokes resolve_module_callback() with the freed engine_bridge_t context. This is a use-after-free and can crash the Node process. Destruction needs to wait for active engine execution or retain/ref-count the bridge until completion. -2. Medium native-lib/node/src/addon.c:157-163,880, native-lib/node/src/dataweave.ts:81-83 - The addon treats the new engine symbols as optional when loading dwlib, but every DataWeave.initialize() now requires createEngine or createEngineWithResolver. Supplying an older, previously compatible dwlib through libPath will load successfully and then fail initialization even for resolver-less callers. Either require/check the new ABI up front with a clear compatibility error, or retain the legacy resolver-less path. -3. Medium Missing coverage for resolver-backed reinitialization and resolver errors. native-lib/node/tests/integration/edge-cases.test.ts:86-99 verifies reinitialization only without a resolver, and native-lib/node/tests/integration/dataweave-resolver.test.ts does not exercise a throwing resolver. These are the lifecycle/error paths newly affected by per-engine bridge allocation, destruction, and exception clearing. - There were no existing PR review comments or reviews to incorporate. I reviewed the PR description, design document, commits, and diff in an isolated detached worktree at: - /var/folders/qq/l28gmrtn0q15g333pg6nr8qw0000gn/T/opencode/pr157-review \ No newline at end of file From e44583fd1681040456cee2f6fc571b646916da66 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 10:01:43 -0300 Subject: [PATCH 15/63] docs: add design for cleanup()-during-active-stream deadlock fix The follow-up PR-157 review found that DataWeave.cleanup() can deadlock the process when called while a runStreaming()/runTransform() operation is still in flight: isolate teardown blocks the JS thread that a mid-delivery worker's threadsafe-function call depends on. This design makes teardown async and wait for active ops to drain via a dedicated waiter thread, instead of blocking inline. --- ...11-cleanup-teardown-deadlock-fix-design.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-cleanup-teardown-deadlock-fix-design.md diff --git a/docs/superpowers/specs/2026-08-11-cleanup-teardown-deadlock-fix-design.md b/docs/superpowers/specs/2026-08-11-cleanup-teardown-deadlock-fix-design.md new file mode 100644 index 0000000..4ae676c --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-cleanup-teardown-deadlock-fix-design.md @@ -0,0 +1,101 @@ +# Fix `cleanup()`-During-Active-Stream Deadlock — Design + +**Goal:** Eliminate a process-wide deadlock where calling `DataWeave.cleanup()` while any `runStreaming()`/`runTransform()` operation is still in flight (on any engine, in any thread) can freeze the process, by making isolate teardown wait for active operations to drain instead of blocking the JS thread they depend on. + +**Architecture:** `napi_cleanup` becomes async: when it's the last release and no ops are active, it keeps today's synchronous spawn+join fast path unchanged. When ops are active, it defers teardown to a dedicated waiter thread that blocks on a condition variable until every op drains, then performs teardown and signals completion back into JS via a `napi_threadsafe_function` — the same pattern this addon already uses for streaming chunk delivery. + +**Tech Stack:** N-API C addon (`napi_*`, `uv_thread`/`uv_mutex`/`uv_cond`), TypeScript (`DataWeave.cleanup()` signature change), vitest. + +## Global Constraints + +- Node binding only — do not touch `native-lib/python/**`. +- Legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`) and `ScriptRuntime.getInstance()` on the Java side are untouched by this fix; the bug and fix are entirely within `native-lib/node/src/addon.c` and `dataweave.ts`. +- Handle width stays C `long long` everywhere (unaffected by this fix, but any touched signature must not regress it). +- The existing per-bridge `in_flight`/`destroy_pending` accounting (F1 remediation, PR #157) is untouched — this fix adds a **separate, process-global** `g_active_ops` counter that covers all streaming/transform ops (resolver-backed or not), because isolate teardown blocks on *any* attached worker thread, not just resolver-backed ones. +- `DataWeave.cleanup()` signature changes from `void` to `Promise` (async). This is acceptable pre-GA; no external ABI-stability commitment exists yet for the Node package. +- The module-level `process.on("exit", () => cleanup())` hook (`dataweave.ts:222`) stays fire-and-forget — not awaited. This is a pre-existing, acceptable tradeoff, not a new one. + +--- + +## Background + +### The bug + +`napi_cleanup` (`addon.c:1189-1218`) decrements the process-global `g_ref_count`. When it drops to 0, it spawns a thread that calls `graal_tear_down_isolate`, then calls **`uv_thread_join` on that thread synchronously, blocking the calling JS thread** until teardown finishes. + +`graal_tear_down_isolate` blocks until every GraalVM-attached thread reaches a safepoint/detaches. A `runStreaming()`/`runTransform()` background worker (`streaming_thread_fn`/`transform_thread_fn`) stays attached to the isolate for the duration of its native call, and delivers each chunk via `napi_call_threadsafe_function(..., napi_tsfn_blocking)`, which requires the JS event loop to run the corresponding `call_js_write`/`call_js_transform_write` callback before the worker can proceed. + +If `cleanup()` is the call that drops `g_ref_count` to 0 while such a worker is still attached and mid-delivery, this produces a real circular wait: + +``` +JS thread: cleanup() -> uv_thread_join(teardown thread) -> blocked +Teardown thread: graal_tear_down_isolate() -> waiting for worker to detach -> blocked +Worker thread: napi_call_threadsafe_function(..., blocking) -> waiting for JS thread to run callback -> blocked +``` + +`g_isolate`/`g_ref_count` are process-global, so this is reachable even when the streaming op and the `cleanup()` call belong to different, unrelated `DataWeave` instances — not just same-instance self-cleanup. + +### Why the existing F1 regression test didn't catch it + +The Task 4 F1 test (added during the PR-157 remediation) uses a resolver that throws before emitting any data, so the streaming operation fails fast and the worker thread never reaches the mid-delivery, blocked-on-`napi_tsfn_blocking` state this bug requires. + +--- + +## Design + +### New global state (guarded by the existing `g_mutex`) + +- **`g_active_ops`** (`int`) — count of all currently-running streaming/transform native calls, across every engine (resolver-backed or not) and every Worker thread. +- **`g_teardown_pending`** (`bool`) — true from the moment `cleanup()` drops `g_ref_count` to 0 while `g_active_ops > 0`, until teardown actually completes. +- **`g_teardown_cond`** (`uv_cond_t`) — condition variable the waiter thread blocks on; signaled by each op's completion sentinel after decrementing `g_active_ops`. +- **`g_teardown_waiters`** (linked list, each node `{napi_env env, napi_deferred deferred, napi_threadsafe_function tsfn}`) — one entry per `cleanup()` call currently waiting on the same in-progress teardown. A list rather than a single slot because a second (or third) `cleanup()` call can arrive from a **different** `napi_env` (a different Worker thread) while the first teardown is still pending — `napi_env`/`napi_deferred`/`napi_threadsafe_function` are thread-affine, so each waiting caller needs its own tsfn created on its own env; there is no way to resolve one env's deferred from another env's thread. + +### Op accounting + +Every streaming/transform entrypoint (`napi_run_script_streaming_engine`, `napi_run_script_transform_engine`) increments `g_active_ops` under `g_mutex`, immediately alongside the existing `bridge_begin_op` call and before spawning its worker thread — same timing, same "no early return in between" invariant already documented for `bridge_begin_op`. + +The completion sentinel branch (`chunk->len == -1`) in `call_js_write`/`call_js_transform_write` decrements `g_active_ops` under `g_mutex`, alongside the existing `bridge_end_op` call, and signals `g_teardown_cond`. This is the only new responsibility added to the sentinel — it does not spawn anything or run teardown itself. + +### `napi_cleanup` behavior + +1. Lock `g_mutex`, decrement `g_ref_count` only if it's currently `> 0` (a second `cleanup()` call while one is already pending, with `g_ref_count` already at 0, must not decrement further into negative values). +2. If `g_ref_count > 0` after decrementing: unlock, return an already-resolved promise (today's "no-op until last release" behavior, promise-shaped). Every branch that returns "already resolved" (this one and case 4) creates a `napi_deferred`/promise and resolves it immediately before returning, rather than inventing a separate no-promise return path — keeps `napi_cleanup`'s return type uniformly "a promise" regardless of which branch runs. +3. If `g_ref_count <= 0` and `g_teardown_pending` is already true (re-entrant call — see Edge Cases): create a new deferred/promise + threadsafe function on *this call's* env, append it to `g_teardown_waiters`, unlock, return the pending promise. No second waiter thread is spawned — this call's node just joins the list the existing waiter thread will drain on completion. +4. If `g_ref_count <= 0`, `g_teardown_pending` is false, and `g_active_ops == 0`: unchanged fast path — spawn+join the teardown thread inline (`cleanup_thread_fn`, unmodified), reset `g_thread`/`g_isolate`/`g_initialized`/`g_ref_count`, unlock, return an already-resolved promise. +5. If `g_ref_count <= 0`, `g_teardown_pending` is false, and `g_active_ops > 0`: set `g_teardown_pending = true`; create a deferred/promise + threadsafe function on this env, append it as the first node of `g_teardown_waiters`; spawn the **waiter thread**; unlock; return the pending promise. + +### Waiter thread + +A dedicated thread (spawned only in case 5 above) that: +1. Locks `g_mutex`, waits on `g_teardown_cond` while `g_active_ops > 0`. +2. Once drained, runs teardown exactly as `cleanup_thread_fn` does today (attach a local thread to the isolate, call `graal_tear_down_isolate`, ignoring its return code — matching today's behavior of not propagating a teardown failure). +3. Resets `g_thread`/`g_isolate`/`g_initialized`/`g_ref_count`/`g_teardown_pending` under `g_mutex`, signals `g_teardown_cond` again (to release any `initialize()` call blocked in the re-entrant-init path below). +4. Walks `g_teardown_waiters`: for each node, calls its `tsfn` to resolve its `deferred` back on its own env, then releases that threadsafe function. Clears the list once every node has been signaled. + +This thread is dedicated to this one teardown — no unrelated Worker's event loop is ever blocked as a side effect of finishing its own streaming op (rejected alternative: piggybacking teardown onto the last op's own completion sentinel, which would stall whichever unrelated thread happens to run that sentinel for the full teardown duration). + +### `DataWeave.cleanup()` (TypeScript) + +`cleanup(): Promise` (was `void`). Awaits `ffi.cleanup()`'s now-Promise-returning addon call. Callers that need the old synchronous-fire-and-forget behavior (e.g. the module-level process-exit hook) simply don't await it — unchanged behavior for them, since the promise resolving or not doesn't block anything if nobody awaits it. + +--- + +## Edge Cases + +**Re-entrant `cleanup()` while teardown is pending, possibly from a different Worker/env.** Handled by case 3 above — `g_ref_count` doesn't go negative, no second waiter thread is spawned, and each caller's own env gets its own list node (deferred + tsfn) so it can be resolved on its own thread when teardown finishes, regardless of which env made the original triggering call. Preserves `cleanup()`'s documented idempotency (`dataweave.ts:105`, "a no-op if not initialized") at the addon layer, including across Workers. + +**`initialize()` called while a teardown is pending.** `napi_initialize` must not re-create the isolate while the old one is still tearing down (risk of two live isolates, or use of a half-torn-down one). Add a check: if `g_teardown_pending` is true, block on `g_teardown_cond` until it's false and `g_isolate == NULL` is confirmed, then proceed with the existing create-isolate logic. This is a narrow, rare path (re-initializing mid-drain) but must not be skipped. + +**`graal_tear_down_isolate` returning a non-zero/failure code.** Unchanged from today — the existing fast path already ignores this return value; the waiter thread preserves that (no new failure-propagation behavior invented for this fix). + +**Process exit while ops are active and teardown is pending.** No new behavior introduced; an active native worker thread at process exit is already an existing, out-of-scope condition handled by libuv/Node's own exit sequencing, not this addon. + +--- + +## Testing + +1. **Deadlock regression (the core test).** For both `runStreaming()` and `runTransform()`: start an operation whose script produces multiple chunks with real volume/delay between them (so the worker is genuinely attached and mid-delivery, not failing fast like the existing F1 test). Call `gen.next()` once to pin the operation, then `await dw.cleanup()` before draining the generator. Assert the returned promise resolves within a bounded timeout (test-level timeout or explicit `Promise.race`) rather than hanging, and that the streaming generator itself eventually settles. +2. **Fast-path regression guard.** `cleanup()` called after a stream has already fully drained (`g_active_ops == 0` at the moment of last release) still resolves via the unchanged inline fast path — confirms the new branch didn't silently become the only path. +3. **Idempotency / re-entrant cleanup.** Two concurrent (or sequential, unawaited-then-awaited) `cleanup()` calls while a stream is active both resolve off the same underlying teardown, without spawning a second waiter thread or throwing. +4. **Re-initialize during pending teardown.** Start a stream, call `cleanup()` without awaiting, then immediately call `initialize()` again — confirms it blocks until the pending teardown finishes and the instance is usable afterward (a subsequent `run()` succeeds). +5. **No regression in the existing suite.** All current streaming/transform/lifecycle tests, including the Task 4 F1/F4/F6 additions from the PR-157 remediation, continue passing unmodified. From b998eba9df4c5a8c1b5619f8a8d5a44b87fe910c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 10:51:45 -0300 Subject: [PATCH 16/63] Add process-global active-op accounting for streaming/transform --- native-lib/node/src/addon.c | 61 +++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index ee594a9..666cd97 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -106,6 +106,34 @@ typedef struct engine_bridge { } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex +// --- Teardown-vs-active-ops coordination (deadlock fix) --- +// +// napi_cleanup's last-release path used to synchronously join a thread that +// calls graal_tear_down_isolate(), which blocks until every GraalVM-attached +// thread detaches. A runStreaming()/runTransform() background worker stays +// attached and can be mid-delivery in napi_call_threadsafe_function(..., +// napi_tsfn_blocking), which needs the JS thread to run its callback -- but +// the JS thread is the one blocked in the join. g_active_ops tracks every +// in-flight streaming/transform op (resolver-backed or not, since teardown +// blocks on ANY attached worker) so napi_cleanup can wait for them to drain +// on a dedicated thread instead of blocking the calling JS thread. +static int g_active_ops = 0; +static bool g_teardown_pending = false; +static uv_cond_t g_teardown_cond; + +// One node per cleanup() call that arrived while a teardown was already +// pending. napi_env/napi_deferred/napi_threadsafe_function are thread-affine, +// so a second cleanup() call from a different Worker's env cannot have its +// promise resolved via another env's tsfn -- each waiting caller gets its own +// node, created on its own env, resolved by the waiter thread on completion. +typedef struct teardown_waiter { + napi_env env; + napi_deferred deferred; + napi_threadsafe_function tsfn; + struct teardown_waiter* next; +} teardown_waiter_t; +static teardown_waiter_t* g_teardown_waiters = NULL; // linked list, guarded by g_mutex + // Returns true if the buffer is now tracked (or there was nothing to track). // Returns false only when a buffer was supplied but the tracking node could // not be allocated — in that case the caller owns `buf` again and MUST free @@ -471,6 +499,14 @@ static void call_js_write(napi_env env, napi_value js_callback, void* context, v // during the op it deferred the free to here (F1). After this the bridge may // be freed, so touch nothing on it afterward. bridge_end_op(w->bridge); + // Mirror the decrement for the process-global op count and wake a + // pending teardown waiter (if any) once this op is fully done. This is + // the only new responsibility added here -- it does not spawn anything + // or perform teardown itself (see the waiter thread in Task 2). + uv_mutex_lock(&g_mutex); + g_active_ops--; + uv_cond_signal(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); free(w); return; } @@ -580,6 +616,14 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i // bridge_end_op. No early return exists between here and the spawn. w->bridge = bridge_begin_op(w->handle); + // Count this op globally so a concurrent cleanup() knows to wait for it + // before tearing down the isolate (see g_active_ops comment above). Same + // timing/invariant as bridge_begin_op: before spawning the worker thread, + // no early return in between. + uv_mutex_lock(&g_mutex); + g_active_ops++; + uv_mutex_unlock(&g_mutex); + uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; @@ -755,6 +799,14 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* // during the op it deferred the free to here (F1). After this the bridge may // be freed, so touch nothing on it afterward. bridge_end_op(w->bridge); + // Mirror the decrement for the process-global op count and wake a + // pending teardown waiter (if any) once this op is fully done. This is + // the only new responsibility added here -- it does not spawn anything + // or perform teardown itself (see the waiter thread in Task 2). + uv_mutex_lock(&g_mutex); + g_active_ops--; + uv_cond_signal(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); free(w); return; } @@ -872,6 +924,14 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i // bridge_end_op. No early return exists between here and the spawn. w->bridge = bridge_begin_op(w->handle); + // Count this op globally so a concurrent cleanup() knows to wait for it + // before tearing down the isolate (see g_active_ops comment above). Same + // timing/invariant as bridge_begin_op: before spawning the worker thread, + // no early return in between. + uv_mutex_lock(&g_mutex); + g_active_ops++; + uv_mutex_unlock(&g_mutex); + uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; @@ -1221,6 +1281,7 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { static void init_g_mutex(void) { uv_mutex_init(&g_mutex); + uv_cond_init(&g_teardown_cond); } static napi_value Init(napi_env env, napi_value exports) { From f0392bdbc4c6718585bea5d01126856c4bea11d3 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:08:09 -0300 Subject: [PATCH 17/63] Make napi_cleanup async: defer isolate teardown until active ops drain --- native-lib/node/src/addon.c | 217 ++++++++++++++++++++++++++++++++---- 1 file changed, 194 insertions(+), 23 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 666cd97..46a5d77 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1228,6 +1228,36 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) // --- Cleanup (must run on a separate thread to avoid V8 signal handler conflict) --- +// Called on each waiter's own env/thread (via its own napi_threadsafe_function) +// once the waiter thread has finished isolate teardown. Resolves that specific +// caller's promise, then releases its tsfn and frees the node. `data` is +// unused (NULL) -- there is nothing to report beyond "done". +// +// napi_call_threadsafe_function(..., napi_tsfn_blocking) only ENQUEUES this +// callback for the target env's event loop to run later; it does not wait for +// it to actually execute. So the waiter node and its tsfn must stay alive +// until this callback runs and must be released/freed HERE, not by the +// thread that enqueued the call (teardown_waiter_thread_fn) -- freeing there +// right after the enqueueing call would be a use-after-free once this +// callback later dereferences `context`. Same ownership pattern as +// call_js_write/call_js_transform_write freeing their own work struct from +// inside their own completion branch. +static void call_js_teardown_done(napi_env env, napi_value js_callback, void* context, void* data) { + (void)js_callback; + (void)data; + teardown_waiter_t* waiter = (teardown_waiter_t*)context; + if (waiter == NULL) return; + + if (env != NULL) { + napi_value undefined; + napi_get_undefined(env, &undefined); + napi_resolve_deferred(env, waiter->deferred, undefined); + } + + napi_release_threadsafe_function(waiter->tsfn, napi_tsfn_release); + free(waiter); +} + static void cleanup_thread_fn(void* arg) { (void)arg; // graal_tear_down_isolate() must be passed the IsolateThread belonging to the @@ -1246,35 +1276,176 @@ static void cleanup_thread_fn(void* arg) { fn_tear_down_isolate(local_thread); } +// Spawned only when napi_cleanup finds g_active_ops > 0 on the last release +// (case 5 in the design doc). Blocks until every active streaming/transform +// op has drained, performs isolate teardown exactly like cleanup_thread_fn +// does on the unchanged fast path, then resolves every caller who is waiting +// on this same teardown (there may be more than one -- see g_teardown_waiters). +static void teardown_waiter_thread_fn(void* arg) { + (void)arg; + + uv_mutex_lock(&g_mutex); + while (g_active_ops > 0) { + uv_cond_wait(&g_teardown_cond, &g_mutex); + } + uv_mutex_unlock(&g_mutex); + + // Perform teardown exactly as the unchanged fast path does: attach a local + // thread to the isolate (g_thread from graal_create_isolate's bootstrap + // thread is invalid here -- see cleanup_thread_fn's comment), then tear + // down. Ignore the return code, matching today's behavior. + if (fn_tear_down_isolate && fn_attach_thread && g_isolate) { + void* local_thread = NULL; + if (fn_attach_thread(g_isolate, &local_thread) == 0 && local_thread != NULL) { + fn_tear_down_isolate(local_thread); + } + } + + uv_mutex_lock(&g_mutex); + g_thread = NULL; + g_isolate = NULL; + g_initialized = 0; + g_ref_count = 0; + g_teardown_pending = false; + // Release any initialize() call blocked waiting for teardown to finish + // (see Task 3). + uv_cond_broadcast(&g_teardown_cond); + teardown_waiter_t* waiters = g_teardown_waiters; + g_teardown_waiters = NULL; + uv_mutex_unlock(&g_mutex); + + // Resolve every waiting caller's promise on its own env/thread via its own + // tsfn -- napi_deferred/napi_env are thread-affine, so this cannot be done + // from this waiter thread directly. napi_call_threadsafe_function only + // ENQUEUES the call for the target thread to run later; it does not wait + // for call_js_teardown_done to execute. So do NOT free/release here -- + // call_js_teardown_done owns and releases each node after it actually runs + // (freeing it here instead would be a use-after-free the moment the + // enqueued callback later dereferences it). + while (waiters != NULL) { + teardown_waiter_t* next = waiters->next; + napi_call_threadsafe_function(waiters->tsfn, waiters, napi_tsfn_blocking); + waiters = next; + } +} + +// Creates a promise, a threadsafe function bound to call_js_teardown_done for +// THIS call's env, and a teardown_waiter_t node carrying both. The node is +// NOT linked into g_teardown_waiters here -- the caller does that under +// g_mutex, since callers append at two different points in napi_cleanup +// (case 3: joining an existing pending teardown; case 5: starting a new one). +// Returns NULL (and throws) if node allocation fails. +static teardown_waiter_t* teardown_waiter_create(napi_env env, napi_value* out_promise) { + teardown_waiter_t* waiter = (teardown_waiter_t*)calloc(1, sizeof(teardown_waiter_t)); + if (waiter == NULL) { + napi_throw_error(env, NULL, "Failed to allocate teardown waiter"); + return NULL; + } + waiter->env = env; + + napi_create_promise(env, &waiter->deferred, out_promise); + + napi_value resource_name; + napi_create_string_utf8(env, "dwTeardown", NAPI_AUTO_LENGTH, &resource_name); + napi_create_threadsafe_function( + env, NULL, NULL, resource_name, 0, 1, NULL, NULL, waiter, call_js_teardown_done, &waiter->tsfn + ); + + return waiter; +} + +// Creates an already-resolved promise -- used by napi_cleanup's two +// "nothing to wait for" branches (not-the-last-release, and last-release +// with no active ops) so the function's return type is uniformly "a +// promise" regardless of which branch runs. +static napi_value already_resolved_promise(napi_env env) { + napi_deferred deferred; + napi_value promise; + napi_create_promise(env, &deferred, &promise); + napi_value undefined; + napi_get_undefined(env, &undefined); + napi_resolve_deferred(env, deferred, undefined); + return promise; +} + static napi_value napi_cleanup(napi_env env, napi_callback_info info) { + (void)info; uv_mutex_lock(&g_mutex); - if (g_initialized) { + + // Case 1/2: not the last release (or nothing was ever initialized). Decrement + // only if positive -- a second cleanup() call while g_ref_count is already at + // 0 (e.g. one already dropped it while teardown is pending) must not go + // negative. + if (g_ref_count > 0) { g_ref_count--; - if (g_ref_count <= 0) { - // F2: do NOT walk g_bridges to delete napi_refs here. napi_env/napi_ref are - // thread-affine, and this last-release call can arrive on any Worker thread — - // not necessarily the one that owns a given bridge. Deleting a reference from - // the wrong thread is undefined behavior. Instead, each resolver-backed bridge - // registered a per-env cleanup hook (bridge_env_cleanup) at creation, so its - // owning Worker/main thread disposes its own napi_ref on its own thread when - // that env tears down. Any bridge still linked in g_bridges is owned by such a - // hook and must be left alone here. Only the process-global GraalVM isolate - // teardown below is safe to run once, on the last release, from this thread. - uv_thread_t tid; - uv_thread_options_t opts; - opts.flags = UV_THREAD_HAS_STACK_SIZE; - opts.stack_size = 2 * 1024 * 1024; - uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, NULL); - uv_thread_join(&tid); - - g_thread = NULL; - g_isolate = NULL; - g_initialized = 0; - g_ref_count = 0; + } + if (g_ref_count > 0) { + uv_mutex_unlock(&g_mutex); + return already_resolved_promise(env); + } + + // Case 3: a teardown from an earlier cleanup() call is already pending + // (possibly triggered from a different Worker/env). Join its waiter list + // instead of spawning a second waiter thread. + if (g_teardown_pending) { + napi_value promise; + teardown_waiter_t* waiter = teardown_waiter_create(env, &promise); + if (waiter == NULL) { + uv_mutex_unlock(&g_mutex); + return NULL; // teardown_waiter_create already threw } + waiter->next = g_teardown_waiters; + g_teardown_waiters = waiter; + uv_mutex_unlock(&g_mutex); + return promise; } + + // Case 4: last release, no teardown pending, and nothing active -- the + // original, unchanged synchronous fast path. + if (g_active_ops == 0) { + uv_thread_t tid; + uv_thread_options_t opts; + opts.flags = UV_THREAD_HAS_STACK_SIZE; + opts.stack_size = 2 * 1024 * 1024; + uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, NULL); + uv_thread_join(&tid); + + g_thread = NULL; + g_isolate = NULL; + g_initialized = 0; + g_ref_count = 0; + uv_mutex_unlock(&g_mutex); + return already_resolved_promise(env); + } + + // Case 5: last release, but streaming/transform ops are still active. + // Defer teardown to a dedicated waiter thread instead of blocking this JS + // thread -- this is the deadlock fix. g_initialized/g_isolate/g_thread stay + // set until the waiter thread finishes, matching today's behavior of + // treating "still tearing down" as "still initialized" for concurrent + // initialize() calls (see Task 3). + g_teardown_pending = true; + napi_value promise; + teardown_waiter_t* waiter = teardown_waiter_create(env, &promise); + if (waiter == NULL) { + g_teardown_pending = false; + uv_mutex_unlock(&g_mutex); + return NULL; // teardown_waiter_create already threw + } + waiter->next = NULL; + g_teardown_waiters = waiter; + + uv_thread_t waiter_tid; + uv_thread_options_t waiter_opts; + waiter_opts.flags = UV_THREAD_HAS_STACK_SIZE; + waiter_opts.stack_size = 2 * 1024 * 1024; + uv_thread_create_ex(&waiter_tid, &waiter_opts, teardown_waiter_thread_fn, NULL); + // Deliberately not joined -- this thread finishes on its own and resolves + // every waiter's promise itself; joining here would reintroduce exactly + // the blocking-JS-thread problem this fix removes. + uv_mutex_unlock(&g_mutex); - return NULL; + return promise; } // --- Module init --- From cf4555bddd612a98018a046bdd034d66ed8f9815 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:16:02 -0300 Subject: [PATCH 18/63] Block initialize() while an isolate teardown is pending --- native-lib/node/src/addon.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 46a5d77..0bd8cfa 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -348,6 +348,18 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { napi_get_value_string_utf8(env, argv[0], lib_path, sizeof(lib_path), &len); uv_mutex_lock(&g_mutex); + + // If a teardown from a prior cleanup() is still draining (the isolate is + // being torn down on the waiter thread from Task 2), do not race a fresh + // graal_create_isolate against it -- wait until the isolate is fully gone + // (g_teardown_pending false AND g_isolate NULL) before proceeding. This is + // a narrow, rare path (re-initializing mid-drain), not a fast path, so a + // blocking wait here is acceptable and matches this function's existing + // fully-synchronous contract. + while (g_teardown_pending || (g_isolate != NULL && !g_initialized)) { + uv_cond_wait(&g_teardown_cond, &g_mutex); + } + if (g_initialized) { g_ref_count++; uv_mutex_unlock(&g_mutex); From a695ccb975a2bdea67ebb1484284e2a1dede820b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:35:43 -0300 Subject: [PATCH 19/63] Fix signal-stealing deadlock: use broadcast instead of signal for op-completion wakeups Changed uv_cond_signal to uv_cond_broadcast in the op-completion sentinels (call_js_write and call_js_transform_write) to prevent the signal from being stolen by a concurrent initialize() waiter, which would cause a deadlock where teardown_waiter_thread_fn never receives the wakeup it needs to detect g_active_ops reached 0. --- native-lib/node/src/addon.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 0bd8cfa..2d9abb9 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -517,7 +517,7 @@ static void call_js_write(napi_env env, napi_value js_callback, void* context, v // or perform teardown itself (see the waiter thread in Task 2). uv_mutex_lock(&g_mutex); g_active_ops--; - uv_cond_signal(&g_teardown_cond); + uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); free(w); return; @@ -817,7 +817,7 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* // or perform teardown itself (see the waiter thread in Task 2). uv_mutex_lock(&g_mutex); g_active_ops--; - uv_cond_signal(&g_teardown_cond); + uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); free(w); return; From 2f425e981a6d2b34d00e862213d8ad8fb31cb6f3 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:40:33 -0300 Subject: [PATCH 20/63] Change DataWeave.cleanup() to return Promise --- native-lib/node/src/dataweave.ts | 16 ++++++++++++---- native-lib/node/src/ffi.ts | 6 +++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 9075acd..46738a6 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -101,14 +101,21 @@ export class DataWeave { /** * Releases the native runtime. Idempotent — a no-op if not initialized. After * cleanup the instance can be re-initialized via {@link DataWeave.initialize}. + * + * Resolves once the underlying native isolate has actually finished tearing + * down. If a streaming/transform operation on this or any other instance is + * still in flight when the last reference is released, native teardown + * waits for it to drain before resolving — awaiting this rather than + * firing-and-forgetting avoids racing a subsequent {@link initialize} against + * an isolate that is still tearing down. */ - cleanup(): void { + async cleanup(): Promise { if (!this.initialized) return; if (this.engineHandle !== null) { ffi.destroyEngine(this.engineHandle); this.engineHandle = null; } - ffi.cleanup(); + await ffi.cleanup(); this.initialized = false; } @@ -259,9 +266,10 @@ export function runTransform( * Releases the shared {@link DataWeave} singleton, if one was created. A fresh * singleton is created lazily on the next convenience-API call. */ -export function cleanup(): void { +export async function cleanup(): Promise { if (globalInstance) { - globalInstance.cleanup(); + const instance = globalInstance; globalInstance = null; + await instance.cleanup(); } } \ No newline at end of file diff --git a/native-lib/node/src/ffi.ts b/native-lib/node/src/ffi.ts index cc05a23..24711ea 100644 --- a/native-lib/node/src/ffi.ts +++ b/native-lib/node/src/ffi.ts @@ -24,7 +24,7 @@ interface NativeAddon { readCb: (bufSize: number) => Buffer | null, writeCb: (chunk: Buffer) => void ): Promise; - cleanup(): void; + cleanup(): Promise; } let addon: NativeAddon | null = null; @@ -92,6 +92,6 @@ export function runScriptTransformEngine( ); } -export function cleanup(): void { - getAddon().cleanup(); +export function cleanup(): Promise { + return getAddon().cleanup(); } From 71ae4b71e276a3d23970e5ac80bb4f9dda660156 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:57:42 -0300 Subject: [PATCH 21/63] Await the now-async DataWeave.cleanup() in existing tests --- .../integration/dataweave-resolver.test.ts | 14 ++++++------- .../node/tests/integration/dataweave.test.ts | 8 ++++---- .../node/tests/integration/edge-cases.test.ts | 20 +++++++++---------- .../integration/independent-engines.test.ts | 5 ++++- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/native-lib/node/tests/integration/dataweave-resolver.test.ts b/native-lib/node/tests/integration/dataweave-resolver.test.ts index 65ad626..7a842bb 100644 --- a/native-lib/node/tests/integration/dataweave-resolver.test.ts +++ b/native-lib/node/tests/integration/dataweave-resolver.test.ts @@ -18,11 +18,11 @@ function trackedDataWeave(...args: ConstructorParameters): Dat return dw; } -afterAll(() => { +afterAll(async () => { for (const dw of instances) { - dw.cleanup(); + await dw.cleanup(); } - cleanup(); + await cleanup(); }); describe('DataWeave with resolver', () => { @@ -175,14 +175,14 @@ describe('DataWeave with resolver', () => { // handle, and the following initialize() must build a brand new bridge // (new napi_ref on the resolver, new owner-thread record) that resolves // custom modules again, not a stale or dangling one. - it('resolver-backed instance resolves a custom module again after initialize -> cleanup -> initialize', () => { + it('resolver-backed instance resolves a custom module again after initialize -> cleanup -> initialize', async () => { const dw = trackedDataWeave({ resolveModule: modulesFromMap({ 'org/test/reinitLib.dwl': '%dw 2.0\nfun greet(n: String) = "Hello " ++ n', }), }); dw.initialize(); - dw.cleanup(); + await dw.cleanup(); dw.initialize(); const result = dw.run(` @@ -273,14 +273,14 @@ describe('DataWeave with resolver', () => { // contract; the native "Unknown engine handle" string is the deeper // contract the addon enforces if it were ever called with a stale handle, // which this guard prevents from happening via the public API. - it('run() after cleanup() throws a DataWeaveError via the TS-level ensureInitialized guard', () => { + it('run() after cleanup() throws a DataWeaveError via the TS-level ensureInitialized guard', async () => { const dw = trackedDataWeave({ resolveModule: modulesFromMap({ 'org/test/destroyedHandleLib.dwl': '...', }), }); dw.initialize(); - dw.cleanup(); + await dw.cleanup(); expect(() => dw.run('1 + 1')).toThrow(DataWeaveError); expect(() => dw.run('1 + 1')).toThrow(/DataWeave runtime not initialized/); diff --git a/native-lib/node/tests/integration/dataweave.test.ts b/native-lib/node/tests/integration/dataweave.test.ts index bacf160..e5af460 100644 --- a/native-lib/node/tests/integration/dataweave.test.ts +++ b/native-lib/node/tests/integration/dataweave.test.ts @@ -3,8 +3,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { DataWeave, run, runStreaming, runTransform, cleanup } from "../../src/index"; -afterAll(() => { - cleanup(); +afterAll(async () => { + await cleanup(); }); describe("DataWeave Node.js API", () => { @@ -21,7 +21,7 @@ describe("DataWeave Node.js API", () => { expect(result.getString()).toBe("42"); }); - it("explicit instance lifecycle", () => { + it("explicit instance lifecycle", async () => { const dw = new DataWeave(); dw.initialize(); try { @@ -30,7 +30,7 @@ describe("DataWeave Node.js API", () => { const r2 = dw.run("sqrt(10000)"); expect(r2.getString()).toBe("100"); } finally { - dw.cleanup(); + await dw.cleanup(); } }); diff --git a/native-lib/node/tests/integration/edge-cases.test.ts b/native-lib/node/tests/integration/edge-cases.test.ts index d1077e6..099659a 100644 --- a/native-lib/node/tests/integration/edge-cases.test.ts +++ b/native-lib/node/tests/integration/edge-cases.test.ts @@ -6,8 +6,8 @@ import { describe, it, expect, afterAll } from "vitest"; import { DataWeave, run, runStreaming, runTransform, cleanup } from "../../src/index"; import type { StreamingResult } from "../../src/types"; -afterAll(() => { - cleanup(); +afterAll(async () => { + await cleanup(); }); /** Drains a streaming/transform generator, returning its chunks and terminal metadata. */ @@ -61,7 +61,7 @@ describe("runTransform with async-iterable input", () => { }); describe("multi-instance lifecycle", () => { - it("runs two independent instances and cleans them up independently", () => { + it("runs two independent instances and cleans them up independently", async () => { const a = new DataWeave(); const b = new DataWeave(); a.initialize(); @@ -70,8 +70,8 @@ describe("multi-instance lifecycle", () => { expect(a.run("1 + 1").getString()).toBe("2"); expect(b.run("2 + 3").getString()).toBe("5"); } finally { - a.cleanup(); - b.cleanup(); + await a.cleanup(); + await b.cleanup(); } // After cleanup, a fresh instance still works (runtime not permanently torn down). const c = new DataWeave(); @@ -79,22 +79,22 @@ describe("multi-instance lifecycle", () => { try { expect(c.run("6 * 7").getString()).toBe("42"); } finally { - c.cleanup(); + await c.cleanup(); } }); - it("initialize is idempotent and re-initialization after cleanup works", () => { + it("initialize is idempotent and re-initialization after cleanup works", async () => { const dw = new DataWeave(); dw.initialize(); dw.initialize(); // no-op, must not throw expect(dw.run("1").getString()).toBe("1"); - dw.cleanup(); - dw.cleanup(); // double cleanup, must not throw + await dw.cleanup(); + await dw.cleanup(); // double cleanup, must not throw dw.initialize(); // re-init try { expect(dw.run("2").getString()).toBe("2"); } finally { - dw.cleanup(); + await dw.cleanup(); } }); diff --git a/native-lib/node/tests/integration/independent-engines.test.ts b/native-lib/node/tests/integration/independent-engines.test.ts index fa5aa10..6dfdc7f 100644 --- a/native-lib/node/tests/integration/independent-engines.test.ts +++ b/native-lib/node/tests/integration/independent-engines.test.ts @@ -8,7 +8,10 @@ function tracked(...args: ConstructorParameters): DataWeave { instances.push(dw); return dw; } -afterAll(() => { for (const dw of instances) dw.cleanup(); cleanup(); }); +afterAll(async () => { + for (const dw of instances) await dw.cleanup(); + await cleanup(); +}); const scriptImporting = (mod: string) => `%dw 2.0\nimport org::test::${mod}\noutput application/json\n---\n${mod}::greet("X")`; From ec6b59ba62415ad0676dc90be64514dab9d5d293 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 12:47:05 -0300 Subject: [PATCH 22/63] Add cleanup()-during-active-stream/transform deadlock regression tests --- .../integration/dataweave-resolver.test.ts | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/native-lib/node/tests/integration/dataweave-resolver.test.ts b/native-lib/node/tests/integration/dataweave-resolver.test.ts index 7a842bb..6f5b5eb 100644 --- a/native-lib/node/tests/integration/dataweave-resolver.test.ts +++ b/native-lib/node/tests/integration/dataweave-resolver.test.ts @@ -265,6 +265,187 @@ describe('DataWeave with resolver', () => { } }); + // Deadlock regression: unlike the F1 test above (which races cleanup() + // against a stream that fails before emitting data), this test uses a + // script that produces real output with enough volume that the worker + // thread is genuinely attached and mid-delivery -- blocked in + // napi_call_threadsafe_function(..., napi_tsfn_blocking) -- when cleanup() + // drops the last native reference. Before the fix (napi_cleanup's + // synchronous uv_thread_join), this scenario hung the process; after the + // fix, cleanup() defers teardown to a waiter thread until this op drains, + // so both the cleanup() promise and the streaming generator settle. + it('cleanup() during an active, output-producing runStreaming() does not deadlock', async () => { + const dw = trackedDataWeave(); + dw.initialize(); + + const gen = dw.runStreaming( + 'output application/json --- (1 to 5000) map {id: $, name: "item_" ++ $}' + ); + + // Pin the operation without draining it: exactly one .next() call runs + // the generator's synchronous prefix (including the native call that + // hands the op to a background thread) up to its first await. + const firstNext = gen.next(); + + const cleanupPromise = dw.cleanup(); + + await expect( + Promise.race([ + cleanupPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('cleanup() timed out')), 10000)), + ]) + ).resolves.toBeUndefined(); + + // Drain whatever remains; the stream itself must also settle, not hang. + let result = await firstNext; + while (!result.done) { + result = await gen.next(); + } + expect(result.value).toBeDefined(); + }, 15000); + + // Same deadlock regression as above, for runTransform() -- the design doc + // notes the same problem applies to transform's write_tsfn delivery path. + it('cleanup() during an active, output-producing runTransform() does not deadlock', async () => { + const dw = trackedDataWeave(); + dw.initialize(); + + const parts: Buffer[] = [Buffer.from("[")]; + for (let i = 1; i <= 2000; i++) { + if (i > 1) parts.push(Buffer.from(",")); + parts.push(Buffer.from(`{"id":${i}}`)); + } + parts.push(Buffer.from("]")); + const inputData = [Buffer.concat(parts)]; + + const gen = dw.runTransform( + "output application/json\n---\npayload map $", + inputData, + { mimeType: "application/json" } + ); + + const firstNext = gen.next(); + + // Unlike runStreaming (whose native call is synchronous up to its first + // await), runTransform's generator body awaits createChunkReader(input) + // -- itself a microtask, not real async work for a sync-iterable input -- + // before reaching the native runScriptTransformEngine call. A single + // un-awaited .next() only advances the generator to that intermediate + // await, not past it, so the native op would not yet be dispatched + // (g_active_ops still 0) when cleanup() below fires. One extra microtask + // tick lets that internal await settle so the native call is actually + // in flight, which is what this test needs to race against. + await Promise.resolve(); + + const cleanupPromise = dw.cleanup(); + + await expect( + Promise.race([ + cleanupPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('cleanup() timed out')), 10000)), + ]) + ).resolves.toBeUndefined(); + + let result = await firstNext; + while (!result.done) { + result = await gen.next(); + } + expect(result.value).toBeDefined(); + }, 15000); + + // Fast-path regression guard: cleanup() called once a stream has already + // fully drained (g_active_ops back to 0 by the time the last reference is + // released) must still resolve via the original, unchanged inline fast + // path -- confirming the new deferred-teardown branch didn't silently + // become the only path through napi_cleanup. + it('cleanup() after a stream has already fully drained resolves via the fast path', async () => { + const dw = trackedDataWeave(); + dw.initialize(); + + const gen = dw.runStreaming('output application/json --- {a: 1}'); + let result = await gen.next(); + while (!result.done) { + result = await gen.next(); + } + expect(result.value.success).toBe(true); + + await expect(dw.cleanup()).resolves.toBeUndefined(); + }); + + // Idempotency / re-entrant cleanup: two cleanup() calls that both arrive + // while a stream is active must both resolve off the same underlying + // teardown -- without spawning a second waiter thread, throwing, or + // decrementing g_ref_count below 0. + it('two concurrent cleanup() calls during an active stream both resolve cleanly', async () => { + const dw = trackedDataWeave(); + dw.initialize(); + + const gen = dw.runStreaming( + 'output application/json --- (1 to 3000) map {id: $}' + ); + const firstNext = gen.next(); + + const [r1, r2] = await Promise.all([ + Promise.race([ + dw.cleanup(), + new Promise((_, reject) => setTimeout(() => reject(new Error('first cleanup() timed out')), 10000)), + ]), + Promise.race([ + dw.cleanup(), + new Promise((_, reject) => setTimeout(() => reject(new Error('second cleanup() timed out')), 10000)), + ]), + ]); + expect(r1).toBeUndefined(); + expect(r2).toBeUndefined(); + + let result = await firstNext; + while (!result.done) { + result = await gen.next(); + } + }, 15000); + + // Re-initialize during pending teardown: starting a stream, calling + // cleanup() without awaiting it, then immediately calling initialize() + // again must block (at the native layer, inside napi_initialize) until the + // pending teardown finishes, rather than racing a second + // graal_create_isolate against an isolate that is still tearing down. The + // instance must be fully usable afterward. + it('initialize() called during a pending teardown waits for it and then works', async () => { + const dw = trackedDataWeave(); + dw.initialize(); + + const gen = dw.runStreaming( + 'output application/json --- (1 to 3000) map {id: $}' + ); + const firstNext = gen.next(); + + // Deliberately not awaited -- this is the pending-teardown state under test. + const cleanupPromise = dw.cleanup(); + + // dw.cleanup() already set dw's own initialized flag false only after its + // internal await resolves; to exercise the *native* pending-teardown path + // independent of this specific instance's TS-level guard, drive a second, + // fresh instance's initialize() concurrently -- it shares the same + // process-global isolate/g_ref_count. + const dw2 = trackedDataWeave(); + const secondInitDone = new Promise((resolve) => { + dw2.initialize(); + resolve(); + }); + + await Promise.race([ + Promise.all([cleanupPromise, secondInitDone]), + new Promise((_, reject) => setTimeout(() => reject(new Error('initialize()-during-teardown timed out')), 10000)), + ]); + + expect(dw2.run("6 * 7").getString()).toBe("42"); + + let result = await firstNext; + while (!result.done) { + result = await gen.next(); + } + }, 15000); + // Node-layer contract (F4-adjacent): once cleanup() has torn an instance // down, run() must be rejected by dataweave.ts's own ensureInitialized() // guard -- a DataWeaveError with a "not initialized" message -- rather than From ac8d5200cfd24047e7107e8c0892dbac7990230b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 13:08:11 -0300 Subject: [PATCH 23/63] Fix napi_initialize deadlock: decrement g_active_ops from the worker thread, not the JS-thread callback --- native-lib/node/src/addon.c | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 2d9abb9..ad1475d 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -511,14 +511,6 @@ static void call_js_write(napi_env env, napi_value js_callback, void* context, v // during the op it deferred the free to here (F1). After this the bridge may // be freed, so touch nothing on it afterward. bridge_end_op(w->bridge); - // Mirror the decrement for the process-global op count and wake a - // pending teardown waiter (if any) once this op is fully done. This is - // the only new responsibility added here -- it does not spawn anything - // or perform teardown itself (see the waiter thread in Task 2). - uv_mutex_lock(&g_mutex); - g_active_ops--; - uv_cond_broadcast(&g_teardown_cond); - uv_mutex_unlock(&g_mutex); free(w); return; } @@ -575,6 +567,18 @@ static void streaming_thread_fn(void* arg) { fn_detach_thread(worker_thread); } + // Decrement here, once this thread has fully detached from the isolate -- + // not in call_js_write's completion branch. call_js_write only runs when + // the JS thread's event loop turns, and napi_initialize's pending-teardown + // wait (Task 3) can block that same event loop indefinitely; decrementing + // from the JS-thread callback made the two waits circular. Decrementing + // here ties g_active_ops to the actual invariant isolate teardown needs + // (no GraalVM-attached thread remains), independent of the event loop. + uv_mutex_lock(&g_mutex); + g_active_ops--; + uv_cond_broadcast(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); + struct chunk_data* sentinel = malloc(sizeof(struct chunk_data)); sentinel->buf = meta_result; sentinel->len = -1; @@ -811,14 +815,6 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* // during the op it deferred the free to here (F1). After this the bridge may // be freed, so touch nothing on it afterward. bridge_end_op(w->bridge); - // Mirror the decrement for the process-global op count and wake a - // pending teardown waiter (if any) once this op is fully done. This is - // the only new responsibility added here -- it does not spawn anything - // or perform teardown itself (see the waiter thread in Task 2). - uv_mutex_lock(&g_mutex); - g_active_ops--; - uv_cond_broadcast(&g_teardown_cond); - uv_mutex_unlock(&g_mutex); free(w); return; } @@ -862,6 +858,14 @@ static void transform_thread_fn(void* arg) { fn_detach_thread(worker_thread); } + // See streaming_thread_fn's comment: decrement here (after detach), not in + // call_js_transform_write's completion branch, to avoid the same + // circular-wait deadlock against napi_initialize's pending-teardown wait. + uv_mutex_lock(&g_mutex); + g_active_ops--; + uv_cond_broadcast(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); + struct chunk_data* sentinel = malloc(sizeof(struct chunk_data)); sentinel->buf = meta_result; sentinel->len = -1; From 3d5a4f1947749f59d84f0f5706f9579128d5e961 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 14:09:15 -0300 Subject: [PATCH 24/63] Document DataWeave.cleanup()'s Promise signature --- native-lib/node/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index bfc5c68..6efb31e 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -188,15 +188,15 @@ for await (const chunk of generator) { **Returns:** `StreamingResult` -#### `cleanup(): void` +#### `cleanup(): Promise` -Clean up the global DataWeave runtime instance. Called automatically on process exit. +Clean up the global DataWeave runtime instance. Called automatically on process exit (fire-and-forget — the exit hook does not await it). Resolves once native teardown has actually finished; if a streaming/transform operation is still in flight anywhere in the process, teardown waits for it to drain before resolving. ```javascript import { cleanup } from '@dataweave/native'; // Manual cleanup (usually not needed) -cleanup(); +await cleanup(); ``` ### Class-Based API @@ -213,13 +213,13 @@ try { const result = dw.run('2 + 2'); console.log(result.getString()); } finally { - dw.cleanup(); + await dw.cleanup(); } ``` **Methods:** - `initialize()`: Initialize the native library -- `cleanup()`: Release native resources +- `cleanup(): Promise`: Release native resources; resolves once native teardown finishes - `run(script, inputs?, opts?)`: Same as module-level `run()` - `runStreaming(script, inputs?)`: Same as module-level `runStreaming()` - `runTransform(script, input, opts?)`: Same as module-level `runTransform()` From 50db30af2aacc02bc5aba19d10e90f353fc56306 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 16:32:57 -0300 Subject: [PATCH 25/63] W-23692110: Unwind pre-spawn state on streaming/transform worker spawn failure If uv_thread_create_ex() fails for the streaming or transform background worker, nothing ever ran to decrement g_active_ops or release the resolver bridge hold, permanently wedging cleanup(). Capture the spawn return value and, on failure, unwind everything committed since the promise was created (g_active_ops decrement, bridge_end_op, threadsafe function release, deferred resolution with an error sentinel, and frees) in the same order as the existing completion branches, minus the thread join. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 53 +++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index ad1475d..c04e23d 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -643,7 +643,29 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - uv_thread_create_ex(&w->tid, &opts, streaming_thread_fn, w); + int spawn_rc = uv_thread_create_ex(&w->tid, &opts, streaming_thread_fn, w); + + if (spawn_rc != 0) { + // The worker never ran, so nothing will ever decrement g_active_ops, + // release the bridge hold, or resolve the promise -- unwind everything + // committed above ourselves, in reverse order, mirroring call_js_write's + // completion branch (minus uv_thread_join: there is no thread to join). + uv_mutex_lock(&g_mutex); + g_active_ops--; + uv_cond_broadcast(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); + + bridge_end_op(w->bridge); + napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); + + napi_value result; + napi_create_string_utf8(env, "{\"success\":false,\"error\":\"Failed to spawn streaming worker thread\"}", NAPI_AUTO_LENGTH, &result); + napi_resolve_deferred(env, w->deferred, result); + + free(w->script); + free(w->inputs_json); + free(w); + } return promise; } @@ -951,7 +973,34 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - uv_thread_create_ex(&w->tid, &opts, transform_thread_fn, w); + int spawn_rc = uv_thread_create_ex(&w->tid, &opts, transform_thread_fn, w); + + if (spawn_rc != 0) { + // The worker never ran, so nothing will ever decrement g_active_ops, + // release the bridge hold, or resolve the promise -- unwind everything + // committed above ourselves, in reverse order, mirroring + // call_js_transform_write's completion branch (minus uv_thread_join: + // there is no thread to join). + uv_mutex_lock(&g_mutex); + g_active_ops--; + uv_cond_broadcast(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); + + bridge_end_op(w->bridge); + napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); + napi_release_threadsafe_function(w->write_tsfn, napi_tsfn_release); + + napi_value result; + napi_create_string_utf8(env, "{\"success\":false,\"error\":\"Failed to spawn transform worker thread\"}", NAPI_AUTO_LENGTH, &result); + napi_resolve_deferred(env, w->deferred, result); + + free(w->script); + free(w->inputs_json); + free(w->input_name); + free(w->input_mime_type); + free(w->input_charset); + free(w); + } return promise; } From 1288815a8c498001b048e611564d943f8e2d9bc5 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 16:40:49 -0300 Subject: [PATCH 26/63] W-23692110: Roll back pending-teardown state on waiter spawn failure napi_cleanup case 5 ignored uv_thread_create_ex's return value when spawning the teardown waiter thread. If the spawn fails, g_teardown_pending would stay true forever, permanently blocking every future initialize() and cleanup() call. Capture the spawn result and, on failure, roll back g_teardown_pending, detach the enqueued waiter, resolve its promise inline, release its threadsafe function, and restore g_ref_count to 1 so the isolate is correctly treated as still live. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index c04e23d..c952706 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1504,11 +1504,36 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { uv_thread_options_t waiter_opts; waiter_opts.flags = UV_THREAD_HAS_STACK_SIZE; waiter_opts.stack_size = 2 * 1024 * 1024; - uv_thread_create_ex(&waiter_tid, &waiter_opts, teardown_waiter_thread_fn, NULL); + int spawn_rc = uv_thread_create_ex(&waiter_tid, &waiter_opts, teardown_waiter_thread_fn, NULL); // Deliberately not joined -- this thread finishes on its own and resolves // every waiter's promise itself; joining here would reintroduce exactly // the blocking-JS-thread problem this fix removes. + if (spawn_rc != 0) { + // Best-effort degradation: if the waiter thread never starts, nothing + // will ever clear g_teardown_pending, which would otherwise permanently + // wedge every future initialize()/cleanup() call. Roll back to "teardown + // did not start" -- the isolate stays up and the caller's promise still + // resolves, mirroring the fast path's ignore-teardown-return-code posture. + g_teardown_pending = false; + g_teardown_waiters = NULL; + + napi_value undefined; + napi_get_undefined(env, &undefined); + napi_resolve_deferred(env, waiter->deferred, undefined); + + napi_release_threadsafe_function(waiter->tsfn, napi_tsfn_release); + free(waiter); + + // The isolate never hit zero refs -- it is still live and un-torn-down, + // so the process must not believe otherwise. g_initialized/g_isolate stay + // untouched (still valid). + g_ref_count = 1; + + uv_mutex_unlock(&g_mutex); + return promise; + } + uv_mutex_unlock(&g_mutex); return promise; } From 81cdc3613490d265da67be175df41aef17b91099 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 16:46:43 -0300 Subject: [PATCH 27/63] fix(native-lib/node): signal read waiter on env==NULL teardown (F3) call_js_read early-returned without signaling req->cond when N-API invokes it with env == NULL during environment teardown (e.g. a Worker terminating mid-transform) while data is non-NULL. transform_read_cb blocks synchronously on that same condition variable, so the early return left it hung forever, stranding the worker thread's isolate detach. Restructure to treat env == NULL (with live data) as a terminal read error: set bytes_read = -1 and fall through to the existing signal block, so the blocked waiter always wakes exactly once. The data == NULL branch (nothing to signal) is untouched. Also added confirming comments on call_js_write and call_js_transform_write noting their env == NULL early-returns are not the same bug: their completion path is driven by a separately-enqueued sentinel chunk, not a synchronously-blocked waiter. --- native-lib/node/src/addon.c | 120 +++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 51 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index c952706..b876f93 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -491,6 +491,9 @@ struct streaming_work { }; static void call_js_write(napi_env env, napi_value js_callback, void* context, void* data) { + // env==NULL here is safe: no synchronously-blocked waiter, unlike call_js_read. + // Completion is driven by a separately-enqueued sentinel chunk (len == -1), not + // by a thread blocked on a condition variable waiting on this callback. if (env == NULL || data == NULL) return; struct chunk_data* chunk = (struct chunk_data*)data; struct streaming_work* w = (struct streaming_work*)context; @@ -698,66 +701,78 @@ struct read_request { }; static void call_js_read(napi_env env, napi_value js_callback, void* context, void* data) { - if (env == NULL || data == NULL) return; + if (data == NULL) return; // nothing to signal struct read_request* req = (struct read_request*)data; - napi_value buf_size_val; - napi_create_int32(env, req->buffer_size, &buf_size_val); + if (env == NULL) { + // N-API can invoke a threadsafe-function callback with env == NULL when + // the environment is tearing down with items still queued (e.g. a Worker + // terminating mid-transform). transform_read_cb is synchronously blocked + // on req->cond waiting for this callback to signal it -- unlike + // call_js_write/call_js_transform_write, there is no sentinel-driven path + // that would otherwise unblock it. Treat this as a terminal read error so + // the blocked thread wakes up, detects the failure via bytes_read == -1, + // and the worker can detach from the isolate instead of hanging forever. + req->bytes_read = -1; + } else { + napi_value buf_size_val; + napi_create_int32(env, req->buffer_size, &buf_size_val); - napi_value global; - napi_get_global(env, &global); + napi_value global; + napi_get_global(env, &global); - napi_value result; - napi_status status = napi_call_function(env, global, js_callback, 1, &buf_size_val, &result); - - if (status == napi_ok && result != NULL) { - bool is_buffer; - napi_is_buffer(env, result, &is_buffer); - if (is_buffer) { - void* buf_data; - size_t buf_len; - napi_get_buffer_info(env, result, &buf_data, &buf_len); - int n = (int)buf_len < req->buffer_size ? (int)buf_len : req->buffer_size; - if (n > 0) memcpy(req->buffer, buf_data, n); - req->bytes_read = n; - } else { - req->bytes_read = 0; - } - } else { - // Clear pending exception to prevent propagation - if (status == napi_pending_exception) { - napi_value exception; - napi_get_and_clear_last_exception(env, &exception); - - // Extract and log exception details before discarding - napi_value message_prop, stack_prop; - char message_buf[512] = {0}; - char stack_buf[2048] = {0}; - size_t message_len = 0, stack_len = 0; - - // Try to get the message property - if (napi_get_named_property(env, exception, "message", &message_prop) == napi_ok) { - napi_get_value_string_utf8(env, message_prop, message_buf, sizeof(message_buf), &message_len); + napi_value result; + napi_status status = napi_call_function(env, global, js_callback, 1, &buf_size_val, &result); + + if (status == napi_ok && result != NULL) { + bool is_buffer; + napi_is_buffer(env, result, &is_buffer); + if (is_buffer) { + void* buf_data; + size_t buf_len; + napi_get_buffer_info(env, result, &buf_data, &buf_len); + int n = (int)buf_len < req->buffer_size ? (int)buf_len : req->buffer_size; + if (n > 0) memcpy(req->buffer, buf_data, n); + req->bytes_read = n; + } else { + req->bytes_read = 0; } + } else { + // Clear pending exception to prevent propagation + if (status == napi_pending_exception) { + napi_value exception; + napi_get_and_clear_last_exception(env, &exception); + + // Extract and log exception details before discarding + napi_value message_prop, stack_prop; + char message_buf[512] = {0}; + char stack_buf[2048] = {0}; + size_t message_len = 0, stack_len = 0; + + // Try to get the message property + if (napi_get_named_property(env, exception, "message", &message_prop) == napi_ok) { + napi_get_value_string_utf8(env, message_prop, message_buf, sizeof(message_buf), &message_len); + } - // Try to get the stack property - if (napi_get_named_property(env, exception, "stack", &stack_prop) == napi_ok) { - napi_get_value_string_utf8(env, stack_prop, stack_buf, sizeof(stack_buf), &stack_len); - } + // Try to get the stack property + if (napi_get_named_property(env, exception, "stack", &stack_prop) == napi_ok) { + napi_get_value_string_utf8(env, stack_prop, stack_buf, sizeof(stack_buf), &stack_len); + } - // Log the exception to stderr for diagnostics - fprintf(stderr, "[DataWeave Node addon] Read callback threw exception:\n"); - if (message_len > 0) { - fprintf(stderr, " Message: %s\n", message_buf); - } - if (stack_len > 0) { - fprintf(stderr, " Stack:\n%s\n", stack_buf); - } - if (message_len == 0 && stack_len == 0) { - fprintf(stderr, " (Unable to extract exception details)\n"); + // Log the exception to stderr for diagnostics + fprintf(stderr, "[DataWeave Node addon] Read callback threw exception:\n"); + if (message_len > 0) { + fprintf(stderr, " Message: %s\n", message_buf); + } + if (stack_len > 0) { + fprintf(stderr, " Stack:\n%s\n", stack_buf); + } + if (message_len == 0 && stack_len == 0) { + fprintf(stderr, " (Unable to extract exception details)\n"); + } } + req->bytes_read = -1; // Signal error } - req->bytes_read = -1; // Signal error } uv_mutex_lock(&req->mutex); @@ -813,6 +828,9 @@ static int transform_write_cb(void* ctx, const char* buf, int len) { } static void call_js_transform_write(napi_env env, napi_value js_callback, void* context, void* data) { + // env==NULL here is safe: no synchronously-blocked waiter, unlike call_js_read. + // Completion is driven by a separately-enqueued sentinel chunk (len == -1), not + // by a thread blocked on a condition variable waiting on this callback. if (env == NULL || data == NULL) return; struct chunk_data* chunk = (struct chunk_data*)data; struct transform_work* w = (struct transform_work*)context; From 51e7d88638d35860dd41602765a58fab6167b201 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 16:51:54 -0300 Subject: [PATCH 28/63] W-23692110: Drain in-flight ops via beforeExit before exit fallback Node's exit hook runs synchronously, so an in-flight streaming/transform operation gets abandoned if the process exits normally while cleanup()'s drain hasn't finished. Add a beforeExit handler that awaits cleanup() for the graceful common case, keeping exit as a synchronous last-ditch fallback for process.exit()/signals where beforeExit never fires. A cleanupStarted guard prevents the two hooks from double-driving cleanup. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/README.md | 2 +- native-lib/node/src/dataweave.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 6efb31e..e7c4501 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -190,7 +190,7 @@ for await (const chunk of generator) { #### `cleanup(): Promise` -Clean up the global DataWeave runtime instance. Called automatically on process exit (fire-and-forget — the exit hook does not await it). Resolves once native teardown has actually finished; if a streaming/transform operation is still in flight anywhere in the process, teardown waits for it to drain before resolving. +Clean up the global DataWeave runtime instance. Called automatically on process shutdown via two hooks: `beforeExit` awaits it, so a streaming/transform operation still in flight drains gracefully before the process exits normally; `exit` is a synchronous last-ditch fallback for `process.exit()`, uncaught exceptions, and fatal signals — cases where `beforeExit` never fires — and cannot await the drain. Called manually, it resolves once native teardown has actually finished; if a streaming/transform operation is still in flight anywhere in the process, teardown waits for it to drain before resolving. ```javascript import { cleanup } from '@dataweave/native'; diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 46738a6..27fdf9b 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -217,16 +217,40 @@ export class DataWeave { // Module-level convenience API with lazy singleton let globalInstance: DataWeave | null = null; +// Guards against beforeExit and exit both driving cleanup for the same +// shutdown. Belt-and-suspenders on top of cleanup()'s own idempotency. +let cleanupStarted = false; /** * Returns the process-wide {@link DataWeave} singleton, creating and - * initializing it (and registering a process-exit cleanup hook) on first use. + * initializing it (and registering exit-cleanup hooks) on first use. + * + * Two hooks are registered, covering complementary cases: + * - `beforeExit` fires when the event loop is about to drain naturally and + * CAN run async work (Node keeps the loop alive until it settles), so it + * drains any in-flight streaming/transform operation gracefully. This is + * the common case. + * - `exit` fires unconditionally but runs strictly synchronously — it is + * the last-ditch fallback for `process.exit()`, uncaught exceptions, and + * fatal signals, none of which trigger `beforeExit`. It can only perform + * a best-effort synchronous cleanup, so an in-flight async operation may + * still be abandoned in that narrow set of cases. + * The `cleanupStarted` guard ensures only one of the two hooks actually + * runs cleanup for a given shutdown. */ function getGlobalInstance(): DataWeave { if (!globalInstance) { globalInstance = new DataWeave(); globalInstance.initialize(); - process.on("exit", () => cleanup()); + process.on("beforeExit", async () => { + if (cleanupStarted) return; + cleanupStarted = true; + await cleanup(); // beforeExit can await: drains in-flight ops + }); + process.on("exit", () => { + if (cleanupStarted) return; // beforeExit already handled it + cleanup(); // fallback: best-effort sync fast path + }); } return globalInstance; } From 291e99cd367e381a83030a4d2c25bd476e6559ac Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 17:02:58 -0300 Subject: [PATCH 29/63] fix: reset cleanupStarted guard after singleton teardown completes Previously the guard latched true on the first beforeExit and was never reset, so a singleton revived after a beforeExit-driven cleanup would register a new hook pair that could never fire cleanup() at the real exit, silently defeating the graceful-drain guarantee. Resetting the flag as the last step of cleanup() (after the drain finishes) fixes this without affecting the exit handler's own guard check. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 27fdf9b..ac6be12 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -295,5 +295,12 @@ export async function cleanup(): Promise { const instance = globalInstance; globalInstance = null; await instance.cleanup(); + // Reset the guard only after the drain has fully completed, so a + // revived singleton (created by a later getGlobalInstance() call) + // gets its own live hooks for the next real exit. This must stay + // last: resetting earlier could let a concurrent `exit` firing on + // this same shutdown re-enter cleanup while the async drain above + // is still in flight. + cleanupStarted = false; } } \ No newline at end of file From 029da96562a41c21e3def21f68a26fc3c5c93faa Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 18:10:00 -0300 Subject: [PATCH 30/63] fix(node): guard uv_thread_join on spawn-failure in three sync paths (F1) Capture the return value of uv_thread_create_ex() at three sites (napi_initialize, napi_cleanup case 4, and dw_napi_run_script) and only call uv_thread_join() if the spawn succeeded. Fixes undefined behavior on thread/resource exhaustion when joining an uninitialized thread handle. Site A (napi_initialize): fail early with explicit error. Site B (napi_cleanup case 4): best-effort degradation, clear global state unconditionally (isolate teardown is a best-effort concern here). Site C (dw_napi_run_script): fail fast with explicit error; same pattern as Site A since runScript has no valid degraded fallback and no deferred result. Free script/inputs buffers on error path to avoid leak. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index b876f93..881068a 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -375,7 +375,12 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 16 * 1024 * 1024; - uv_thread_create_ex(&tid, &opts, init_thread_fn, &args); + int spawn_rc = uv_thread_create_ex(&tid, &opts, init_thread_fn, &args); + if (spawn_rc != 0) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Failed to spawn initialization thread"); + return NULL; + } uv_thread_join(&tid); if (args.result != 0) { @@ -454,7 +459,13 @@ static napi_value dw_napi_run_script(napi_env env, napi_callback_info info) { uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - uv_thread_create_ex(&tid, &opts, run_script_thread_fn, &call_args); + int spawn_rc = uv_thread_create_ex(&tid, &opts, run_script_thread_fn, &call_args); + if (spawn_rc != 0) { + free(script); + free(inputs); + napi_throw_error(env, NULL, "Failed to spawn script execution thread"); + return NULL; + } uv_thread_join(&tid); free(script); @@ -1490,9 +1501,15 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, NULL); - uv_thread_join(&tid); - + int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, NULL); + if (spawn_rc == 0) { + uv_thread_join(&tid); + } + // Whether or not the teardown thread ran, treat this as the last release: + // clear global state so the addon is back to an uninitialized, re-initializable + // state. If the spawn failed the isolate may not have been torn down (a + // best-effort degradation, matching the fast path's existing ignore-return + // posture), but we must not join an uninitialized tid (UB). g_thread = NULL; g_isolate = NULL; g_initialized = 0; From 275e84077ddba508b29a3c5174a9430ab6c56a02 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 18:29:00 -0300 Subject: [PATCH 31/63] Fix resource leak in write-completion callbacks when env == NULL call_js_write and call_js_transform_write early-returned on env == NULL, skipping all native cleanup (worker thread join, threadsafe function release, bridge_end_op, and every heap free) for the completion sentinel when Node invokes the tsfn callback during env/Worker teardown. This could leak the work struct and strand a bridge marked for deferred destruction indefinitely. Restructure both callbacks so env == NULL still performs full native finalization on the sentinel path (join, tsfn release(s), bridge_end_op, frees), skipping only the napi-value/JS-calling calls (napi_create_string_utf8/napi_resolve_deferred) that require a live env. A non-sentinel data chunk arriving with env == NULL now frees chunk->buf/ chunk instead of leaking them, without touching the work struct. Confirmed via the N-API docs that napi_release_threadsafe_function (whose signature takes no env and is documented as callable from any thread) and uv_thread_join are legal to call during this env == NULL invocation; only JS-calling/napi-value-producing APIs are restricted. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 62 ++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 881068a..e0592ff 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -502,17 +502,25 @@ struct streaming_work { }; static void call_js_write(napi_env env, napi_value js_callback, void* context, void* data) { - // env==NULL here is safe: no synchronously-blocked waiter, unlike call_js_read. - // Completion is driven by a separately-enqueued sentinel chunk (len == -1), not - // by a thread blocked on a condition variable waiting on this callback. - if (env == NULL || data == NULL) return; + // data == NULL: nothing was queued, nothing to free or finalize. + if (data == NULL) return; struct chunk_data* chunk = (struct chunk_data*)data; struct streaming_work* w = (struct streaming_work*)context; if (chunk->len == -1) { - napi_value result; - napi_create_string_utf8(env, chunk->buf, strlen(chunk->buf), &result); - napi_resolve_deferred(env, w->deferred, result); + // Completion sentinel. env == NULL means the environment is tearing down + // (e.g. a Worker terminating mid-op): we must not call any napi value or + // JS-calling API (napi_create_string_utf8/napi_resolve_deferred need a + // live env), but we must still perform every bit of native finalization + // -- join the worker, release the tsfn, drop the bridge in-flight hold, + // and free every heap field -- exactly once. Skipping this on env == NULL + // would leak `w` and could strand a bridge marked for deferred destruction + // indefinitely. + if (env != NULL) { + napi_value result; + napi_create_string_utf8(env, chunk->buf, strlen(chunk->buf), &result); + napi_resolve_deferred(env, w->deferred, result); + } free(chunk->buf); free(chunk); @@ -529,6 +537,15 @@ static void call_js_write(napi_env env, napi_value js_callback, void* context, v return; } + // Non-sentinel data chunk. If env == NULL the environment is gone and we + // cannot deliver it to JS; free it and return without touching `w` (its + // finalization happens only on the sentinel, above). + if (env == NULL) { + free(chunk->buf); + free(chunk); + return; + } + napi_value buffer; void* buf_data; napi_create_buffer_copy(env, chunk->len, chunk->buf, &buf_data, &buffer); @@ -839,17 +856,25 @@ static int transform_write_cb(void* ctx, const char* buf, int len) { } static void call_js_transform_write(napi_env env, napi_value js_callback, void* context, void* data) { - // env==NULL here is safe: no synchronously-blocked waiter, unlike call_js_read. - // Completion is driven by a separately-enqueued sentinel chunk (len == -1), not - // by a thread blocked on a condition variable waiting on this callback. - if (env == NULL || data == NULL) return; + // data == NULL: nothing was queued, nothing to free or finalize. + if (data == NULL) return; struct chunk_data* chunk = (struct chunk_data*)data; struct transform_work* w = (struct transform_work*)context; if (chunk->len == -1) { - napi_value result; - napi_create_string_utf8(env, chunk->buf, strlen(chunk->buf), &result); - napi_resolve_deferred(env, w->deferred, result); + // Completion sentinel. env == NULL means the environment is tearing down + // (e.g. a Worker terminating mid-op): we must not call any napi value or + // JS-calling API (napi_create_string_utf8/napi_resolve_deferred need a + // live env), but we must still perform every bit of native finalization + // -- join the worker, release both tsfns, drop the bridge in-flight hold, + // and free every heap field -- exactly once. Skipping this on env == NULL + // would leak `w` and could strand a bridge marked for deferred destruction + // indefinitely. + if (env != NULL) { + napi_value result; + napi_create_string_utf8(env, chunk->buf, strlen(chunk->buf), &result); + napi_resolve_deferred(env, w->deferred, result); + } free(chunk->buf); free(chunk); @@ -870,6 +895,15 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* return; } + // Non-sentinel data chunk. If env == NULL the environment is gone and we + // cannot deliver it to JS; free it and return without touching `w` (its + // finalization happens only on the sentinel, above). + if (env == NULL) { + free(chunk->buf); + free(chunk); + return; + } + napi_value buffer; void* buf_data; napi_create_buffer_copy(env, chunk->len, chunk->buf, &buf_data, &buffer); From 2c12bdba01993a8abe769bc74c97833439879438 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 18:34:07 -0300 Subject: [PATCH 32/63] Only clear global isolate state in teardown_waiter_thread_fn on success teardown_waiter_thread_fn unconditionally cleared g_thread, g_isolate, g_initialized, and g_ref_count after attempting to attach a waiter thread and tear down the isolate, even if the attach step failed. When attach fails, the underlying isolate is still alive but becomes unreachable through the addon's globals, so a later initialize() would create a second isolate and the original could never be torn down. Track whether teardown actually happened (or whether there was nothing to tear down in the first place) and only clear those four globals in that case. g_teardown_pending still clears unconditionally, since leaving it set would permanently wedge future initialize()/cleanup() calls; on the attach-failure path g_initialized stays 1 and g_isolate stays non-NULL, so napi_initialize's wait guard passes and ref-counts the existing isolate instead of building a second one, and the failed teardown is retried on the next last-release cleanup(). --- native-lib/node/src/addon.c | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index e0592ff..b1da572 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1422,18 +1422,33 @@ static void teardown_waiter_thread_fn(void* arg) { // thread to the isolate (g_thread from graal_create_isolate's bootstrap // thread is invalid here -- see cleanup_thread_fn's comment), then tear // down. Ignore the return code, matching today's behavior. + bool torn_down = false; if (fn_tear_down_isolate && fn_attach_thread && g_isolate) { void* local_thread = NULL; if (fn_attach_thread(g_isolate, &local_thread) == 0 && local_thread != NULL) { fn_tear_down_isolate(local_thread); + torn_down = true; } + // else: attach failed -- the isolate is still alive. Do NOT clear g_isolate, + // or it becomes unreachable and can never be torn down. + } else { + // Nothing to tear down (no isolate / FFI unavailable) -- safe to clear. + torn_down = true; } uv_mutex_lock(&g_mutex); - g_thread = NULL; - g_isolate = NULL; - g_initialized = 0; - g_ref_count = 0; + if (torn_down) { + g_thread = NULL; + g_isolate = NULL; + g_initialized = 0; + g_ref_count = 0; + } + // g_teardown_pending must clear regardless: this waiter thread is done, and + // leaving it set would permanently wedge future initialize()/cleanup(). On the + // attach-failure path the isolate stays live and g_initialized stays 1, so a + // later initialize() will correctly ref-count the existing isolate rather than + // build a second one, and this failed teardown is simply retried on the next + // last-release cleanup(). g_teardown_pending = false; // Release any initialize() call blocked waiting for teardown to finish // (see Task 3). From 2525c0cfc81c58fab54a4a81da6910d2defaf091 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 12 Aug 2026 09:03:16 -0300 Subject: [PATCH 33/63] W-23692110: Clear initialized in finally so a failed cleanup() doesn't strand DataWeave If ffi.cleanup() rejects, the previous code left `initialized` stuck true even though engineHandle was already nulled, permanently short-circuiting a later initialize() via its no-op guard. Wrap the body in try/finally so `initialized` is always cleared, letting the instance be re-initialized after a failed cleanup. Adds a regression test that stubs ffi.cleanup() to reject once, awaits the rejection, then asserts a subsequent initialize() actually calls ffi.initialize()/createEngine() again rather than no-op'ing. --- native-lib/node/src/dataweave.ts | 13 ++++++---- .../tests/unit/dataweave-initialize.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index ac6be12..a2aa6b5 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -111,12 +111,15 @@ export class DataWeave { */ async cleanup(): Promise { if (!this.initialized) return; - if (this.engineHandle !== null) { - ffi.destroyEngine(this.engineHandle); - this.engineHandle = null; + try { + if (this.engineHandle !== null) { + ffi.destroyEngine(this.engineHandle); + this.engineHandle = null; + } + await ffi.cleanup(); + } finally { + this.initialized = false; } - await ffi.cleanup(); - this.initialized = false; } /** diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 3bda370..495f830 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -99,4 +99,29 @@ describe("DataWeave.initialize() native ref-count safety", () => { expect(ffi.destroyEngine).toHaveBeenCalledWith(7); expect(ffi.cleanup).toHaveBeenCalledTimes(1); }); + + it("still clears `initialized` when ffi.cleanup() rejects, so the instance is re-initializable", async () => { + vi.mocked(ffi.initialize).mockImplementation(() => {}); + vi.mocked(ffi.createEngine).mockImplementation(() => 7); + vi.mocked(ffi.cleanup).mockRejectedValueOnce(new Error("native cleanup boom")); + + const dw = new DataWeave({ libPath: "mock-lib-path" }); + dw.initialize(); + + await expect(dw.cleanup()).rejects.toThrow("native cleanup boom"); + + // Even though ffi.cleanup() rejected, the engine handle was already + // destroyed and nulled -- `initialized` must not stay stuck `true`, or a + // later initialize() call becomes a permanent no-op (the early-return + // guard `if (this.initialized) return;`) and the instance is stranded + // with a null engineHandle. + vi.mocked(ffi.initialize).mockClear(); + vi.mocked(ffi.createEngine).mockClear(); + vi.mocked(ffi.createEngine).mockImplementation(() => 9); + + dw.initialize(); + + expect(ffi.initialize).toHaveBeenCalledTimes(1); + expect(ffi.createEngine).toHaveBeenCalledTimes(1); + }); }); From 2ea320ddb4da88e372fa0fae13f06c6f95d1557c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 12 Aug 2026 09:20:45 -0300 Subject: [PATCH 34/63] Fix dead-env napi_ref deletion and orphaned-isolate cleanup race Two Important findings from the final whole-branch review of this remediation round: 1. bridge_finalize deleted a bridge's resolver napi_ref based only on b->env being non-NULL, which stays true even after the owning env dies. The env==NULL sentinel path in call_js_write/ call_js_transform_write can reach bridge_finalize via bridge_end_op while that same env is tearing down, violating N-API's env-liveness contract. Thread an explicit env_still_alive flag through bridge_end_op/bridge_finalize from every call site so the ref deletion is skipped whenever the owning env is known dead; Node auto-reclaims the ref in that case, so nothing leaks. 2. napi_cleanup's case 4 fast path unconditionally cleared g_thread/g_isolate/g_initialized/g_ref_count after spawning cleanup_thread_fn, even though that thread can silently return without tearing down the isolate (attach failure). Mirror the torn_down out-param pattern already used by teardown_waiter_thread_fn: cleanup_thread_fn now reports whether it actually tore down (or had nothing to tear down) via an int* out-param, and case 4 only clears the globals when torn_down is true -- otherwise the isolate stays reachable for a future initialize() instead of being orphaned. Verified: npm run build:addon succeeds; npm test passes 864/59 skipped/0 failed, matching baseline. --- native-lib/node/src/addon.c | 116 +++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 35 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index b1da572..c7aac68 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -168,18 +168,24 @@ static engine_bridge_t* bridge_find(long long handle) { return NULL; } -// Fully dispose of a bridge: delete its napi_ref, free tracked result buffers, -// free the struct. napi_ref/napi_env are thread-affine, so this MUST run on the -// bridge's owner thread (the JS/Worker thread that created it) while that env is -// still alive. The bridge must already be unlinked from g_bridges. Do NOT hold -// g_mutex across this call — it invokes N-API. Callers that freed a bridge -// *early* (destroyEngine / streaming completion) must first drop the env cleanup -// hook via napi_remove_env_cleanup_hook so Node never invokes it on freed memory; -// the hook path itself (bridge_env_cleanup) must not remove itself and calls this -// directly. -static void bridge_finalize(engine_bridge_t* b) { +// Fully dispose of a bridge: delete its napi_ref (if the owning env is still +// alive), free tracked result buffers, free the struct. napi_ref/napi_env are +// thread-affine, so napi_delete_reference MUST run on the bridge's owner +// thread (the JS/Worker thread that created it) while that env is still +// alive -- `env_still_alive` must be false whenever the caller knows the +// owning env is tearing down/dead (e.g. the env == NULL sentinel path in +// call_js_write/call_js_transform_write), even though b->env itself is never +// cleared and stays non-NULL. When env_still_alive is false the napi_ref is +// simply skipped -- Node auto-reclaims refs when their env is destroyed, so +// nothing leaks. The bridge must already be unlinked from g_bridges. Do NOT +// hold g_mutex across this call — it invokes N-API. Callers that freed a +// bridge *early* (destroyEngine / streaming completion) must first drop the +// env cleanup hook via napi_remove_env_cleanup_hook so Node never invokes it +// on freed memory; the hook path itself (bridge_env_cleanup) must not remove +// itself and calls this directly. +static void bridge_finalize(engine_bridge_t* b, bool env_still_alive) { if (b == NULL) return; - if (b->resolver_js != NULL && b->env != NULL) { + if (env_still_alive && b->resolver_js != NULL && b->env != NULL) { napi_delete_reference(b->env, b->resolver_js); } resolver_results_free_all(b); @@ -218,8 +224,10 @@ static void bridge_env_cleanup(void* arg) { uv_mutex_unlock(&g_mutex); // We are inside Node's invocation of this hook, so we must not (and need not) - // call napi_remove_env_cleanup_hook for ourselves here. - bridge_finalize(b); + // call napi_remove_env_cleanup_hook for ourselves here. The env is still + // alive here -- that is the whole point of this hook's design (see above) -- + // so the napi_ref deletion in bridge_finalize is legal. + bridge_finalize(b, /*env_still_alive=*/true); } // Begin a streaming/transform op on a resolver-backed engine: look up the bridge @@ -240,14 +248,17 @@ static engine_bridge_t* bridge_begin_op(long long handle) { // End a streaming/transform op. Runs on the owner (JS) thread from the completion // sentinel. If destroyEngine (or the env cleanup hook) ran while this op was in // flight, it deferred the free — already unlinked from g_bridges — so the last op -// to drain finalizes the bridge here, on the legal (owner) thread. -static void bridge_end_op(engine_bridge_t* b) { +// to drain finalizes the bridge here, on the legal (owner) thread. `env_still_alive` +// must be false when the caller is running the env == NULL sentinel path (the +// owning env is tearing down/dead), so a finalize triggered from here does not +// call napi_delete_reference on a dead env. +static void bridge_end_op(engine_bridge_t* b, bool env_still_alive) { if (b == NULL) return; uv_mutex_lock(&g_mutex); b->in_flight--; bool finalize = (b->destroy_pending && b->in_flight == 0); uv_mutex_unlock(&g_mutex); - if (finalize) bridge_finalize(b); + if (finalize) bridge_finalize(b, env_still_alive); } // --- Initialization --- @@ -531,8 +542,10 @@ static void call_js_write(napi_env env, napi_value js_callback, void* context, v napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); // Drop the in-flight hold last, on this owner thread: if destroyEngine ran // during the op it deferred the free to here (F1). After this the bridge may - // be freed, so touch nothing on it afterward. - bridge_end_op(w->bridge); + // be freed, so touch nothing on it afterward. env == NULL means this env is + // dead/tearing down -- tell bridge_end_op (and any bridge_finalize it + // triggers) not to touch the napi_ref, since b->env is this same dead env. + bridge_end_op(w->bridge, /*env_still_alive=*/env != NULL); free(w); return; } @@ -686,7 +699,8 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); - bridge_end_op(w->bridge); + // Synchronous call on the JS thread -- env is live here. + bridge_end_op(w->bridge, /*env_still_alive=*/true); napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); napi_value result; @@ -889,8 +903,10 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* napi_release_threadsafe_function(w->write_tsfn, napi_tsfn_release); // Drop the in-flight hold last, on this owner thread: if destroyEngine ran // during the op it deferred the free to here (F1). After this the bridge may - // be freed, so touch nothing on it afterward. - bridge_end_op(w->bridge); + // be freed, so touch nothing on it afterward. env == NULL means this env is + // dead/tearing down -- tell bridge_end_op (and any bridge_finalize it + // triggers) not to touch the napi_ref, since b->env is this same dead env. + bridge_end_op(w->bridge, /*env_still_alive=*/env != NULL); free(w); return; } @@ -1049,7 +1065,8 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); - bridge_end_op(w->bridge); + // Synchronous call on the JS thread -- env is live here. + bridge_end_op(w->bridge, /*env_still_alive=*/true); napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); napi_release_threadsafe_function(w->write_tsfn, napi_tsfn_release); @@ -1261,7 +1278,8 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i // bridge->results via resolver_results_track; bridge_finalize frees those // tracked buffers too, so nothing is dropped on the floor. if (handle <= 0) { - bridge_finalize(bridge); + // Synchronous call on the JS thread -- env is live here. + bridge_finalize(bridge, /*env_still_alive=*/true); napi_throw_error(env, NULL, "create_engine_with_resolver returned an invalid handle"); return NULL; } @@ -1309,7 +1327,8 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { // draining op, the free happens explicitly, so Node must never invoke // the hook on this (soon-to-be or already) freed bridge. napi_remove_env_cleanup_hook(env, bridge_env_cleanup, found); - if (!defer) bridge_finalize(found); + // Synchronous call on the JS thread -- env is live here. + if (!defer) bridge_finalize(found, /*env_still_alive=*/true); } return NULL; } @@ -1386,8 +1405,15 @@ static void call_js_teardown_done(napi_env env, napi_value js_callback, void* co free(waiter); } +// `arg` is an int* out-param: the caller (napi_cleanup's case 4) must set it +// to 0 before spawning this thread and read it after uv_thread_join returns. +// Mirrors teardown_waiter_thread_fn's `torn_down` local exactly, so the +// caller can tell "isolate torn down / nothing to tear down" (safe to clear +// g_thread/g_isolate/g_initialized/g_ref_count) apart from "attach failed, +// isolate still alive" (must leave those globals set, or the isolate becomes +// unreachable and can never be torn down). static void cleanup_thread_fn(void* arg) { - (void)arg; + int* out_torn_down = (int*)arg; // graal_tear_down_isolate() must be passed the IsolateThread belonging to the // *calling* OS thread. g_thread was created by graal_create_isolate() on the // (now-exited, already-joined) init thread, so it is invalid here — passing it @@ -1395,13 +1421,19 @@ static void cleanup_thread_fn(void* arg) { // StackOverflowError during teardown. Attach this cleanup thread to the isolate // to obtain a valid local IsolateThread, then tear down with that. if (!fn_tear_down_isolate || !fn_attach_thread || !g_isolate) { + // Nothing to tear down (no isolate / FFI unavailable) -- safe to clear. + *out_torn_down = 1; return; } void* local_thread = NULL; if (fn_attach_thread(g_isolate, &local_thread) != 0 || local_thread == NULL) { + // Attach failed -- the isolate is still alive. Leave *out_torn_down at 0 + // (its caller-initialized value) so the caller does NOT clear g_isolate, + // or it becomes unreachable and can never be torn down. return; } fn_tear_down_isolate(local_thread); + *out_torn_down = 1; } // Spawned only when napi_cleanup finds g_active_ops > 0 on the last release @@ -1550,19 +1582,33 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, NULL); + // torn_down is cleanup_thread_fn's out-param (mirrors teardown_waiter_thread_fn's + // `torn_down` local exactly): must be initialized to 0 before the thread runs so + // the attach-failure early-return path (which never touches it) leaves it false. + // uv_thread_join is synchronous, so when spawn_rc == 0 this stack variable safely + // outlives the thread's write to it. + int torn_down = 0; + int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, &torn_down); if (spawn_rc == 0) { uv_thread_join(&tid); } - // Whether or not the teardown thread ran, treat this as the last release: - // clear global state so the addon is back to an uninitialized, re-initializable - // state. If the spawn failed the isolate may not have been torn down (a - // best-effort degradation, matching the fast path's existing ignore-return - // posture), but we must not join an uninitialized tid (UB). - g_thread = NULL; - g_isolate = NULL; - g_initialized = 0; - g_ref_count = 0; + // Only clear global state if the isolate was actually torn down (or there + // was nothing to tear down). If spawn failed, the thread never ran and + // torn_down stays 0 -- leave the globals set rather than orphaning a live + // isolate (unreachable via these globals, could never be torn down), which + // is a strict improvement over unconditionally clearing them here. Same + // reasoning for cleanup_thread_fn's internal attach-failure path: the + // isolate is still alive, g_initialized stays 1, and g_ref_count was + // already decremented to 0 above without being reset here, so a later + // initialize() correctly ref-counts the surviving isolate instead of + // building a second one (identical semantics to teardown_waiter_thread_fn's + // attach-failure path). + if (torn_down) { + g_thread = NULL; + g_isolate = NULL; + g_initialized = 0; + g_ref_count = 0; + } uv_mutex_unlock(&g_mutex); return already_resolved_promise(env); } From bc9fe450a23669abbb26381736a668a09352c5cd Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 09:51:35 -0300 Subject: [PATCH 35/63] fix(node): finalize worker-side state when the completion sentinel enqueue fails (F1) streaming_thread_fn and transform_thread_fn ignored the status of their final napi_call_threadsafe_function sentinel enqueue. If the owning env was tearing down (napi_closing), the sentinel was silently dropped, call_js_write/ call_js_transform_write never ran, and the streaming_work/transform_work struct, its tsfn(s), and the bridge in-flight hold leaked. Capture the status and, on failure, perform the worker-thread-safe subset of finalization (frees, tsfn release(s), bridge_end_op with env_still_alive=false) that the callback would otherwise have done, skipping only what requires a live env (napi_resolve_deferred) or self-join (uv_thread_join on our own thread). Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 41 +++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index c7aac68..04a992d 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -626,7 +626,28 @@ static void streaming_thread_fn(void* arg) { struct chunk_data* sentinel = malloc(sizeof(struct chunk_data)); sentinel->buf = meta_result; sentinel->len = -1; - napi_call_threadsafe_function(w->tsfn, sentinel, napi_tsfn_blocking); + napi_status enq = napi_call_threadsafe_function(w->tsfn, sentinel, napi_tsfn_blocking); + if (enq != napi_ok) { + // The env is tearing down (napi_closing): the sentinel was dropped and + // call_js_write will never run, so finalize here instead -- the exact same + // native cleanup as call_js_write's sentinel branch, minus the two things + // that are illegal or impossible on this worker thread: + // - no napi value / deferred call (env is dead; those are env-affine) + // - no uv_thread_join(&w->tid): we ARE w->tid; a thread cannot join + // itself. The handle goes unreaped -- an unavoidable, negligible leak + // during a Worker teardown that is already discarding this env. + // Release the tsfn from this (producer) thread -- napi_release_threadsafe_function + // is documented as callable from any thread that uses the tsfn -- and end the + // bridge op with env_still_alive=false so bridge_finalize skips the thread-affine + // napi_delete_reference (Node auto-reclaims the ref when the dead env is destroyed). + free(sentinel->buf); + free(sentinel); + free(w->script); + free(w->inputs_json); + napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); + bridge_end_op(w->bridge, /*env_still_alive=*/false); + free(w); + } } static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_info info) { @@ -970,7 +991,23 @@ static void transform_thread_fn(void* arg) { struct chunk_data* sentinel = malloc(sizeof(struct chunk_data)); sentinel->buf = meta_result; sentinel->len = -1; - napi_call_threadsafe_function(w->write_tsfn, sentinel, napi_tsfn_blocking); + napi_status enq = napi_call_threadsafe_function(w->write_tsfn, sentinel, napi_tsfn_blocking); + if (enq != napi_ok) { + // See streaming_thread_fn: env tearing down, sentinel dropped, finalize here. + // No self-join, no env-affine napi call; release BOTH tsfns; end bridge op + // with env_still_alive=false. + free(sentinel->buf); + free(sentinel); + free(w->script); + free(w->inputs_json); + free(w->input_name); + free(w->input_mime_type); + free(w->input_charset); + napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); + napi_release_threadsafe_function(w->write_tsfn, napi_tsfn_release); + bridge_end_op(w->bridge, /*env_still_alive=*/false); + free(w); + } } static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_info info) { From 3806e74b278c4c5c5887a05ae2ecb30558519f76 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 10:06:31 -0300 Subject: [PATCH 36/63] fix(node): drop tsfn releases already discharged by napi_closing (F1 follow-up) Node's ThreadSafeFunction::Push (backing napi_call_threadsafe_function) decrements thread_count for the calling thread before returning napi_closing, and deletes the tsfn right there if that decrement drives thread_count to 0 while state is already kClosed. So receiving napi_closing on the sentinel enqueue already discharges this worker's registration on that tsfn; a subsequent napi_release_threadsafe_function on the same handle is a double-discharge and, whenever Push already deleted the object, a use-after-free. Remove the erroneous release of w->tsfn in streaming_thread_fn's enqueue-failure branch, and of w->write_tsfn in transform_thread_fn's (same reasoning: it's the tsfn that directly received napi_closing there). Also drop the read_tsfn release in transform_thread_fn: this worker is also its sole producer, but whether read_tsfn independently already received napi_closing (and self-discharged/possibly self-deleted) depends on runtime read activity this code path cannot observe, so its discharge state is unprovable here -- accept a small leak of an already-tearing-down tsfn rather than risk a UAF. Every other free, bridge_end_op, and free(w) is unchanged. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 54 ++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 04a992d..7cdf615 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -630,21 +630,33 @@ static void streaming_thread_fn(void* arg) { if (enq != napi_ok) { // The env is tearing down (napi_closing): the sentinel was dropped and // call_js_write will never run, so finalize here instead -- the exact same - // native cleanup as call_js_write's sentinel branch, minus the two things - // that are illegal or impossible on this worker thread: + // native cleanup as call_js_write's sentinel branch, minus the things + // that are illegal, impossible, or already done on this worker thread: // - no napi value / deferred call (env is dead; those are env-affine) // - no uv_thread_join(&w->tid): we ARE w->tid; a thread cannot join // itself. The handle goes unreaped -- an unavoidable, negligible leak // during a Worker teardown that is already discarding this env. - // Release the tsfn from this (producer) thread -- napi_release_threadsafe_function - // is documented as callable from any thread that uses the tsfn -- and end the - // bridge op with env_still_alive=false so bridge_finalize skips the thread-affine - // napi_delete_reference (Node auto-reclaims the ref when the dead env is destroyed). + // - no napi_release_threadsafe_function(w->tsfn, ...): this tsfn was + // created with initial_thread_count = 1 and this worker is its sole + // producer, so Node's internal thread_count for it is exactly 1 on + // entry to this Push call. Node's ThreadSafeFunction::Push (the + // implementation behind napi_call_threadsafe_function) decrements + // thread_count for the calling thread BEFORE returning napi_closing, + // and -- if that decrement brings thread_count to 0 while the + // internal state is already kClosed -- Push runs `delete this` on + // the tsfn right there. So receiving napi_closing here already IS + // this thread's discharge of the tsfn (matches the doc's "destroyed + // when every thread ... has called napi_release_threadsafe_function() + // or has received a return status of napi_closing"); calling release + // again afterward would be a double-discharge and, whenever Push + // already deleted the object, a use-after-free. Omit it. + // End the bridge op with env_still_alive=false so bridge_finalize skips + // the thread-affine napi_delete_reference (Node auto-reclaims the ref + // when the dead env is destroyed). free(sentinel->buf); free(sentinel); free(w->script); free(w->inputs_json); - napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); bridge_end_op(w->bridge, /*env_still_alive=*/false); free(w); } @@ -993,9 +1005,29 @@ static void transform_thread_fn(void* arg) { sentinel->len = -1; napi_status enq = napi_call_threadsafe_function(w->write_tsfn, sentinel, napi_tsfn_blocking); if (enq != napi_ok) { - // See streaming_thread_fn: env tearing down, sentinel dropped, finalize here. - // No self-join, no env-affine napi call; release BOTH tsfns; end bridge op - // with env_still_alive=false. + // See streaming_thread_fn: env tearing down, sentinel dropped, finalize + // here. No self-join, no env-affine napi call. + // + // Do NOT release write_tsfn: this worker is its sole producer + // (initial_thread_count = 1), so receiving napi_closing from this same + // Push call already decremented Node's internal thread_count for it to 0 + // and, if the tsfn's internal state was already kClosed, already ran + // `delete this` on it inside Push -- see streaming_thread_fn's comment + // for the full citation. Releasing it again here would be a + // double-discharge and potentially a use-after-free. + // + // Do NOT release read_tsfn either, even though this same worker is also + // its sole producer: whether *it* has already received napi_closing (and + // so already discharged/deleted itself the same way) depends on whether + // the script issued reads during teardown, which this code path has no + // way to know. We cannot prove read_tsfn's discharge state here, so -- + // consistent with the env == NULL dead-env handling elsewhere in this + // file -- we accept the small leak of an already-tearing-down tsfn + // rather than risk a use-after-free on an object whose state is unknown. + // + // End the bridge op with env_still_alive=false so bridge_finalize skips + // the thread-affine napi_delete_reference (Node auto-reclaims the ref + // when the dead env is destroyed). free(sentinel->buf); free(sentinel); free(w->script); @@ -1003,8 +1035,6 @@ static void transform_thread_fn(void* arg) { free(w->input_name); free(w->input_mime_type); free(w->input_charset); - napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); - napi_release_threadsafe_function(w->write_tsfn, napi_tsfn_release); bridge_end_op(w->bridge, /*env_still_alive=*/false); free(w); } From 62c8f6e28f1f613dc2607e417af2ca940531f0cf Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 10:13:47 -0300 Subject: [PATCH 37/63] Fix(node): guard cross-thread destroyEngine for resolver-backed engines (F2) Reject destroyEngine calls from any Worker thread other than the one that created the engine. The bridge owns thread-affine N-API state (napi_ref and env cleanup hook), and manipulation from another thread is undefined behavior. The owner's cleanup hook disposes the bridge when its Worker tears down. Resolver-less engines have no bridge, so they remain unguarded (safe to destroy from any thread). Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 7cdf615..504031f 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1370,6 +1370,30 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { int64_t handle64; napi_get_value_int64(env, argv[0], &handle64); long long handle = (long long)handle64; + // F2: a resolver-backed engine's bridge owns thread-affine N-API state -- + // a napi_ref and an env cleanup hook, both created on the engine's owning + // JS thread. Deleting that ref (bridge_finalize) or removing that hook + // (napi_remove_env_cleanup_hook) from another Worker's thread is undefined + // behavior. Reject cross-thread destruction, mirroring the fail-closed + // owner check in resolve_module_callback; the owner env's cleanup hook + // disposes the bridge when that Worker tears down. Resolver-less engines + // have no bridge and no napi state, so they need no guard (bridge_find == + // NULL -> fall through). We are on the owner thread past this point, so the + // env cannot be concurrently tearing down and the bridge stays stable + // between this check and the unlink below. + uv_mutex_lock(&g_mutex); + engine_bridge_t* owned = bridge_find(handle); + if (owned != NULL) { + uv_thread_t self = uv_thread_self(); + if (!uv_thread_equal(&self, &owned->owner)) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, + "destroyEngine must be called from the thread that created the engine"); + return NULL; + } + } + uv_mutex_unlock(&g_mutex); + if (fn_destroy_engine) { void* thread = NULL; if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, handle); fn_detach_thread(thread); } From 3ecc89a8f57e7443f0b47799e2f4f5751b5c3047 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 10:20:38 -0300 Subject: [PATCH 38/63] fix(node): check N-API allocation results in teardown_waiter_create (F3) Add error checks for napi_create_promise, napi_create_string_utf8, and napi_create_threadsafe_function in teardown_waiter_create. Each failed allocation now frees the waiter, throws an N-API error, and returns NULL, matching the pattern already used for calloc failure and honoring both callers' NULL-return guards. Verification: addon builds clean, full vitest suite green (864 passed / 59 skipped / 0 failed). Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 504031f..467484b 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1609,13 +1609,26 @@ static teardown_waiter_t* teardown_waiter_create(napi_env env, napi_value* out_p } waiter->env = env; - napi_create_promise(env, &waiter->deferred, out_promise); + if (napi_create_promise(env, &waiter->deferred, out_promise) != napi_ok) { + free(waiter); + napi_throw_error(env, NULL, "Failed to create teardown promise"); + return NULL; + } napi_value resource_name; - napi_create_string_utf8(env, "dwTeardown", NAPI_AUTO_LENGTH, &resource_name); - napi_create_threadsafe_function( - env, NULL, NULL, resource_name, 0, 1, NULL, NULL, waiter, call_js_teardown_done, &waiter->tsfn - ); + if (napi_create_string_utf8(env, "dwTeardown", NAPI_AUTO_LENGTH, &resource_name) != napi_ok) { + free(waiter); + napi_throw_error(env, NULL, "Failed to create teardown resource name"); + return NULL; + } + + if (napi_create_threadsafe_function( + env, NULL, NULL, resource_name, 0, 1, NULL, NULL, waiter, call_js_teardown_done, &waiter->tsfn + ) != napi_ok) { + free(waiter); + napi_throw_error(env, NULL, "Failed to create teardown threadsafe function"); + return NULL; + } return waiter; } From e70c20d191dd2d14750d985fd4a254458eee5eb8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 15:48:04 -0300 Subject: [PATCH 39/63] ci(macos): use --ignore-installed for pip setuptools/wheel upgrade Homebrew's setuptools/wheel on the macOS runner have no pip RECORD file, so a plain --upgrade fails with "uninstall-no-record-file" before it can install pip's copy. --ignore-installed skips the uninstall step entirely. Co-Authored-By: Claude Sonnet 5 --- .github/actions/python/action.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/actions/python/action.yml b/.github/actions/python/action.yml index 760fc03..f8864ba 100644 --- a/.github/actions/python/action.yml +++ b/.github/actions/python/action.yml @@ -32,7 +32,11 @@ runs: using: composite steps: - name: Install Python build dependencies - run: python3 -m pip install ${{ inputs.break-system-packages == 'true' && '--break-system-packages' || '' }} --upgrade setuptools wheel + # --ignore-installed: on macOS runners, Homebrew's own setuptools/wheel have no + # pip RECORD file, so a plain --upgrade fails trying to uninstall them first + # (pip error "uninstall-no-record-file"). --ignore-installed installs pip's + # copy on top without needing to remove the untracked brew one. + run: python3 -m pip install ${{ inputs.break-system-packages == 'true' && '--break-system-packages' || '' }} --upgrade --ignore-installed setuptools wheel shell: bash - name: Create Native Lib Python Wheel From 9fa14cf7a2a676b9139e51cf89384419a064863c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 18:15:18 -0300 Subject: [PATCH 40/63] fix(node): coalesce concurrent DataWeave.cleanup() calls (F1) Two overlapping cleanup() calls both passed the `initialized` guard because it wasn't cleared until after ffi.cleanup() resolved, so each call independently invoked ffi.cleanup() -- a double decrement of the process-shared native ref-count. Store the in-flight teardown promise and hand it to concurrent callers so ffi.cleanup() runs exactly once per cleanup cycle. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 20 ++++++++++++++++ .../tests/unit/dataweave-initialize.test.ts | 24 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index a2aa6b5..21f9b37 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -50,6 +50,7 @@ export class DataWeave { private readonly resolveModule?: ModuleResolver; private initialized = false; private engineHandle: number | null = null; + private cleanupPromise: Promise | null = null; /** * @param options - Configuration options or a legacy libPath string. @@ -111,6 +112,25 @@ export class DataWeave { */ async cleanup(): Promise { if (!this.initialized) return; + // Coalesce concurrent cleanup() calls: `initialized` does not flip to + // false until doCleanup()'s finally runs (after the await below), so + // without this a second overlapping call would pass the guard above and + // invoke ffi.cleanup() again -- a second decrement of the process-shared + // native ref-count that can tear the isolate down under another live + // instance. Store the in-progress promise before the first await and hand + // it to every concurrent caller so the native teardown happens once. + if (this.cleanupPromise) return this.cleanupPromise; + this.cleanupPromise = this.doCleanup(); + try { + await this.cleanupPromise; + } finally { + // Clear on both fulfilment and rejection so a later cleanup() (after a + // re-initialize, or a retry of a rejected cleanup) can run again. + this.cleanupPromise = null; + } + } + + private async doCleanup(): Promise { try { if (this.engineHandle !== null) { ffi.destroyEngine(this.engineHandle); diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 495f830..1a234b3 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -124,4 +124,28 @@ describe("DataWeave.initialize() native ref-count safety", () => { expect(ffi.initialize).toHaveBeenCalledTimes(1); expect(ffi.createEngine).toHaveBeenCalledTimes(1); }); + + it("coalesces concurrent cleanup() calls into a single native teardown", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(1); + let resolveNative!: () => void; + vi.mocked(ffi.cleanup).mockReturnValue( + new Promise((resolve) => { + resolveNative = resolve; + }) + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + + // Two overlapping cleanup() calls while ffi.cleanup() is still pending. + const p1 = dw.cleanup(); + const p2 = dw.cleanup(); + resolveNative(); + await Promise.all([p1, p2]); + + // The native ref-count decrement (ffi.cleanup) and destroyEngine each run + // exactly once, not once per caller -- this is the double-decrement fix. + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + expect(ffi.destroyEngine).toHaveBeenCalledTimes(1); + }); }); From 3ba64db6b1117d9ecd39fbf481bbc7bdf2e7a3d3 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 18:22:54 -0300 Subject: [PATCH 41/63] Fix F2: Free the teardown waiter when its completion enqueue fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After native isolate teardown, the waiter-drain loop enqueues a completion callback onto each waiting caller's env. If a waiter's env has already closed (napi_closing), the enqueue fails and the callback never runs — so the teardown_waiter_t node and its threadsafe function leak, since only the callback frees them. Check the enqueue status; on failure, free the node directly (but do NOT release the tsfn, since a napi_closing return already discharges it — releasing again is a double-discharge/UAF). The unresolved deferred is env-affine and reclaimed when the dead env is destroyed, following the precedent established in streaming_thread_fn/transform_thread_fn. Fixes one leak per Worker that terminates while a native teardown is pending. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 467484b..04a9f74 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1590,7 +1590,20 @@ static void teardown_waiter_thread_fn(void* arg) { // enqueued callback later dereferences it). while (waiters != NULL) { teardown_waiter_t* next = waiters->next; - napi_call_threadsafe_function(waiters->tsfn, waiters, napi_tsfn_blocking); + napi_status enq = napi_call_threadsafe_function(waiters->tsfn, waiters, napi_tsfn_blocking); + if (enq != napi_ok) { + // The waiter's env is tearing down (napi_closing): call_js_teardown_done + // will never run, so it can neither resolve waiter->deferred nor release + // the tsfn nor free the node. Free the node here instead of leaking it + // (one leak per Worker that terminated while this teardown was pending). + // Do NOT napi_release_threadsafe_function(waiters->tsfn, ...): a + // napi_closing return already discharges this tsfn's registration (Node + // may have destroyed the tsfn object), so a release would be a + // double-discharge/UAF -- same reasoning as the sentinel-enqueue-failure + // paths in streaming_thread_fn/transform_thread_fn. The unresolved + // deferred is env-affine and reclaimed when the dead env is destroyed. + free(waiters); + } waiters = next; } } From 5981cc40f30aaae90a91c1322d53be528e7fe199 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 12:29:08 -0300 Subject: [PATCH 42/63] Fix napi_initialize deadlock: adopt live isolate during pending teardown Replaces the boolean g_teardown_pending with a tri-state (TEARDOWN_NONE/PENDING_WAIT/TEARING_DOWN) plus a g_teardown_cancelled flag. A fresh initialize() arriving while a teardown is queued but not yet physically started (PENDING_WAIT) now adopts the still-live isolate -- cancels the queued teardown, takes a ref, and wakes the waiter -- instead of blocking the JS thread. Blocking is still safe once the waiter commits to TEARING_DOWN, since g_active_ops is already 0 by then. This closes the deadlock where a blocking initialize() froze the event loop that an active streaming/transform worker needed in order to drain and let teardown proceed. teardown_waiter_thread_fn now honors cancellation: it skips the physical graal_tear_down_isolate() call and the isolate-global clear when cancelled, but still resets the state machine and runs the existing waiter-resolve loop in both outcomes. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 97 ++++++++++++++++++++++++++++--------- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 04a9f74..1dff35c 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -118,7 +118,26 @@ static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex // blocks on ANY attached worker) so napi_cleanup can wait for them to drain // on a dedicated thread instead of blocking the calling JS thread. static int g_active_ops = 0; -static bool g_teardown_pending = false; +// Teardown lifecycle, all transitions under g_mutex: +// NONE -> no teardown queued or in progress. +// PENDING_WAIT -> napi_cleanup Case 5 queued a teardown; the waiter thread is +// blocked waiting for g_active_ops to drain. The isolate is +// STILL LIVE and un-torn-down here, so a fresh initialize() +// may ADOPT it (cancel the teardown) instead of blocking the +// JS thread -- this is the round-5 deadlock fix. +// TEARING_DOWN -> the waiter has passed the point of no return and is calling +// graal_tear_down_isolate(). Adoption is unsafe; initialize() +// must block here, which is deadlock-free because g_active_ops +// is already 0 (nothing depends on the JS event loop). +typedef enum { + TEARDOWN_NONE = 0, + TEARDOWN_PENDING_WAIT, + TEARDOWN_TEARING_DOWN, +} teardown_state_t; +static teardown_state_t g_teardown_state = TEARDOWN_NONE; +// Set by an adopting initialize() to tell the waiter thread to abort its +// queued teardown and leave the live isolate intact. Read/reset by the waiter. +static bool g_teardown_cancelled = false; static uv_cond_t g_teardown_cond; // One node per cleanup() call that arrived while a teardown was already @@ -363,11 +382,32 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { // If a teardown from a prior cleanup() is still draining (the isolate is // being torn down on the waiter thread from Task 2), do not race a fresh // graal_create_isolate against it -- wait until the isolate is fully gone - // (g_teardown_pending false AND g_isolate NULL) before proceeding. This is - // a narrow, rare path (re-initializing mid-drain), not a fast path, so a - // blocking wait here is acceptable and matches this function's existing - // fully-synchronous contract. - while (g_teardown_pending || (g_isolate != NULL && !g_initialized)) { + // before proceeding. This is a narrow, rare path (re-initializing mid-drain), + // not a fast path, so a blocking wait here is acceptable and matches this + // function's existing fully-synchronous contract -- except in + // TEARDOWN_PENDING_WAIT (see below), where blocking would deadlock. + while (g_teardown_state != TEARDOWN_NONE || (g_isolate != NULL && !g_initialized)) { + if (g_teardown_state == TEARDOWN_PENDING_WAIT) { + // A teardown is queued but the waiter has NOT begun physical teardown + // (that transition to TEARING_DOWN happens under this same g_mutex), so + // g_isolate/g_initialized are still valid. Blocking here would freeze the + // JS event loop that an active streaming/transform worker needs in order + // to drain g_active_ops -- the waiter would then wait forever and this + // wait would never end (the P1 deadlock). Instead, ADOPT the live isolate: + // cancel the queued teardown, take a fresh ref, and wake the waiter so it + // aborts without tearing down. g_initialized is already 1, so fall through + // to the ref-count path below is unnecessary -- return directly. + g_teardown_cancelled = true; + g_ref_count++; + uv_cond_broadcast(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); + return NULL; + } + // TEARDOWN_TEARING_DOWN (or a transient g_isolate!=NULL && !g_initialized): + // g_active_ops has already reached 0, so nothing depends on the JS event + // loop -- this blocking wait is deadlock-free and preserves the original + // "don't race graal_create_isolate against graal_tear_down_isolate" + // guarantee that round 3's Task 3 added. uv_cond_wait(&g_teardown_cond, &g_mutex); } @@ -1536,17 +1576,26 @@ static void teardown_waiter_thread_fn(void* arg) { (void)arg; uv_mutex_lock(&g_mutex); - while (g_active_ops > 0) { + while (g_active_ops > 0 && !g_teardown_cancelled) { uv_cond_wait(&g_teardown_cond, &g_mutex); } + bool cancelled = g_teardown_cancelled; + if (!cancelled) { + // Point of no return: from here an adopting initialize() must NOT reuse the + // isolate, so publish TEARING_DOWN under the lock before we drop it to call + // graal_tear_down_isolate(). + g_teardown_state = TEARDOWN_TEARING_DOWN; + } uv_mutex_unlock(&g_mutex); // Perform teardown exactly as the unchanged fast path does: attach a local // thread to the isolate (g_thread from graal_create_isolate's bootstrap // thread is invalid here -- see cleanup_thread_fn's comment), then tear - // down. Ignore the return code, matching today's behavior. + // down. Ignore the return code, matching today's behavior. Skipped entirely + // when an initialize() call adopted the live isolate instead (see + // napi_initialize's TEARDOWN_PENDING_WAIT branch). bool torn_down = false; - if (fn_tear_down_isolate && fn_attach_thread && g_isolate) { + if (!cancelled && fn_tear_down_isolate && fn_attach_thread && g_isolate) { void* local_thread = NULL; if (fn_attach_thread(g_isolate, &local_thread) == 0 && local_thread != NULL) { fn_tear_down_isolate(local_thread); @@ -1554,25 +1603,26 @@ static void teardown_waiter_thread_fn(void* arg) { } // else: attach failed -- the isolate is still alive. Do NOT clear g_isolate, // or it becomes unreachable and can never be torn down. - } else { + } else if (!cancelled) { // Nothing to tear down (no isolate / FFI unavailable) -- safe to clear. torn_down = true; } + // if (cancelled): leave torn_down = false -- the isolate stays live for the + // adopter; we tear nothing down. uv_mutex_lock(&g_mutex); - if (torn_down) { + if (!cancelled && torn_down) { g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; } - // g_teardown_pending must clear regardless: this waiter thread is done, and - // leaving it set would permanently wedge future initialize()/cleanup(). On the - // attach-failure path the isolate stays live and g_initialized stays 1, so a - // later initialize() will correctly ref-count the existing isolate rather than - // build a second one, and this failed teardown is simply retried on the next - // last-release cleanup(). - g_teardown_pending = false; + // If cancelled: g_isolate/g_initialized/g_ref_count are left exactly as the + // adopting initialize() set them (it already did g_ref_count++ on the live + // isolate). On the attach-failure path (!cancelled && !torn_down) the isolate + // also stays live and g_initialized stays 1, retried on the next last release. + g_teardown_state = TEARDOWN_NONE; + g_teardown_cancelled = false; // Release any initialize() call blocked waiting for teardown to finish // (see Task 3). uv_cond_broadcast(&g_teardown_cond); @@ -1679,7 +1729,7 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { // Case 3: a teardown from an earlier cleanup() call is already pending // (possibly triggered from a different Worker/env). Join its waiter list // instead of spawning a second waiter thread. - if (g_teardown_pending) { + if (g_teardown_state != TEARDOWN_NONE) { napi_value promise; teardown_waiter_t* waiter = teardown_waiter_create(env, &promise); if (waiter == NULL) { @@ -1736,11 +1786,12 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { // set until the waiter thread finishes, matching today's behavior of // treating "still tearing down" as "still initialized" for concurrent // initialize() calls (see Task 3). - g_teardown_pending = true; + g_teardown_state = TEARDOWN_PENDING_WAIT; + g_teardown_cancelled = false; napi_value promise; teardown_waiter_t* waiter = teardown_waiter_create(env, &promise); if (waiter == NULL) { - g_teardown_pending = false; + g_teardown_state = TEARDOWN_NONE; uv_mutex_unlock(&g_mutex); return NULL; // teardown_waiter_create already threw } @@ -1758,11 +1809,11 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { if (spawn_rc != 0) { // Best-effort degradation: if the waiter thread never starts, nothing - // will ever clear g_teardown_pending, which would otherwise permanently + // will ever clear g_teardown_state, which would otherwise permanently // wedge every future initialize()/cleanup() call. Roll back to "teardown // did not start" -- the isolate stays up and the caller's promise still // resolves, mirroring the fast path's ignore-teardown-return-code posture. - g_teardown_pending = false; + g_teardown_state = TEARDOWN_NONE; g_teardown_waiters = NULL; napi_value undefined; From 49d2881adbb8e933f98ecd58bb3fcf38fb87bf60 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 15:02:15 -0300 Subject: [PATCH 43/63] Add deterministic regression test for napi_initialize teardown deadlock Adds native-lib/node/tests/integration/teardown-deadlock.test.ts, loading the real native addon (no ffi mocking) to reproduce the round-5 P1 bug: an active transform read, an unawaited module-level cleanup(), and a concurrent synchronous run(). Uses runTransform's read path rather than runStreaming (as originally suggested) because runStreaming's g_active_ops decrement already happens on the worker thread independent of the JS event loop (commit ac8d520), so it does not exercise the circular wait; runTransform's transform_read_cb genuinely blocks the worker thread on the JS thread servicing its read callback, making it the real reproducer. Verified empirically: hangs indefinitely on pre-fix addon.c (3ba64db), passes in ~5s on fixed HEAD (5981cc4). Co-Authored-By: Claude Sonnet 5 --- .../integration/teardown-deadlock.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 native-lib/node/tests/integration/teardown-deadlock.test.ts diff --git a/native-lib/node/tests/integration/teardown-deadlock.test.ts b/native-lib/node/tests/integration/teardown-deadlock.test.ts new file mode 100644 index 0000000..efa44ce --- /dev/null +++ b/native-lib/node/tests/integration/teardown-deadlock.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { run, runTransform, cleanup } from "../../src/dataweave"; + +// Regression test for W-23692110 round 5 (Task 1 fix in native-lib/node/src/addon.c). +// +// Bug: napi_initialize used to block the JS thread forever whenever it ran +// while a teardown was pending on the shared native isolate and a +// streaming/transform op was still active elsewhere -- because draining that +// active op can need the very same JS thread napi_initialize was blocking. +// The fix makes napi_initialize adopt the still-live isolate instead of +// waiting, in the window before the teardown waiter thread commits to +// physical teardown. +// +// This loads the REAL native addon (no `vi.mock` of ffi) -- the deadlock is +// entirely in C and cannot be reproduced at the mocked-ffi layer. +// +// Why runTransform (not runStreaming) drives this repro: runStreaming's +// output-chunk delivery uses an unbounded napi_threadsafe_function queue, and +// g_active_ops is decremented on the background worker thread right after it +// detaches from the isolate -- independent of whether the JS event loop ever +// turns. So a blocked JS thread does NOT stop a runStreaming() op from +// draining; there is no genuine circular wait on that path (verified +// empirically: the brief's originally-suggested runStreaming shape resolves +// promptly even against pre-Task-1 addon.c, because an earlier round already +// moved that decrement off the JS thread -- see commit ac8d520). +// +// runTransform's INPUT side is different: transform_read_cb (addon.c) calls +// napi_call_threadsafe_function(w->read_tsfn, &req, napi_tsfn_blocking) and +// then genuinely blocks the background worker thread on a condition variable +// until call_js_read runs on the JS thread and signals it. That JS-thread +// callback synchronously invokes our JS read callback (a plain +// Iterable consumed by a sync generator) via napi_call_function -- +// so firing cleanup() and a concurrent run() from *inside* that generator +// deterministically executes them while the background worker is attached +// and blocked waiting for this exact call to return. No timing assumptions +// (no setTimeout/microtask races) are needed: the call graph itself +// guarantees the ordering "worker attached and mid-read" -> "cleanup() +// fired" -> "run() fired", all on the JS thread, before the generator call +// returns and the worker can proceed. +describe("re-init during pending teardown (W-23692110, round 5 P1)", () => { + // On the UNFIXED addon.c this deadlocks for real: the JS thread never + // returns from run()'s napi_initialize (blocked waiting for g_active_ops to + // drain), so the background transform worker -- itself blocked waiting for + // the JS thread to service its read callback -- can never proceed either. + // Vitest kills the test at the timeout below, a bounded/deterministic red. + // On the fixed code, napi_initialize adopts the still-live isolate and + // run() returns promptly, letting everything drain normally. + it( + "module-level cleanup() during an active transform read does not deadlock a concurrent run()", + async () => { + let fired = false; + let cleanupPromise: Promise | undefined; + let runResult: ReturnType | undefined; + let runError: unknown; + + // Large enough that, at the moment of the very first read pull, the + // vast majority of reads (and thus the transform op) are still + // genuinely ahead -- not a timing-sensitive assumption, since the + // trigger below fires unconditionally on the first pull regardless of + // how many total reads there are. + const totalReads = 200000; + + function* input(): Generator { + for (let i = 0; i < totalReads; i++) { + if (!fired) { + fired = true; + // We are executing synchronously inside the native read + // callback (call_js_read in addon.c), on the JS thread, while + // the background transform worker thread is blocked inside + // transform_read_cb waiting for this exact call to return. + // Deliberately do NOT await cleanup() here, and do NOT let an + // assertion throw from inside this generator -- a thrown + // exception here would be caught by the native read-callback + // wrapper and reinterpreted as a read error, silently masking a + // real assertion failure instead of surfacing it as a test + // failure. Capture results and assert on them after the + // generator (and the transform) have fully drained. + cleanupPromise = cleanup(); + try { + runResult = run('%dw 2.0\noutput application/json\n---\n1 + 1'); + } catch (e) { + runError = e; + } + } + yield Buffer.from("x"); + } + } + + const gen = runTransform( + "output application/octet-stream\n---\npayload", + input(), + { mimeType: "application/octet-stream" } + ); + + // Drain the whole transform. On unfixed code, execution never reaches + // here: the trigger inside input() already froze the JS thread + // forever before the first read even returns. + let result = await gen.next(); + while (!result.done) { + result = await gen.next(); + } + + expect(fired).toBe(true); + expect(runError).toBeUndefined(); + expect(runResult?.success).toBe(true); + expect(JSON.parse(runResult!.getString()!)).toBe(2); + expect(result.value.success).toBe(true); + + // Let both the deferred teardown/cleanup and this test settle cleanly. + // This is essential: the process shares one native isolate across all + // integration test files, so leaving an unresolved cleanup here would + // perturb sibling test files. + await cleanupPromise; + // Idempotent final cleanup: a no-op if the singleton is already fully + // released, leaving the module in a clean state for subsequent tests. + await cleanup(); + }, + 20000 + ); +}); From f14ee1858f549ba3917df1feb7bc524c0c3ac1fa Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 16:54:24 -0300 Subject: [PATCH 44/63] docs: design spec for round-6 instance-lifecycle-state fix (W-23692110) Root-cause fix for the three findings in the sixth PR #157 follow-up review: model the DataWeave instance lifecycle explicitly (uninitialized/ready/ cleaning-up) instead of a single boolean, make C-side stream/transform admission atomic under g_mutex, and validate napi_get_value_int64 at the handle-read sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-14-instance-lifecycle-state-fix-design.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-14-instance-lifecycle-state-fix-design.md diff --git a/docs/superpowers/specs/2026-08-14-instance-lifecycle-state-fix-design.md b/docs/superpowers/specs/2026-08-14-instance-lifecycle-state-fix-design.md new file mode 100644 index 0000000..7d67e35 --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-instance-lifecycle-state-fix-design.md @@ -0,0 +1,111 @@ +# DataWeave Instance Lifecycle State Fix — Round 6 (W-23692110) + +**Status:** Design approved, ready for planning. + +**Source review:** `docs/pr-157-follow-up-andy-code-review-6.md` (three findings, all verified against live source at commit `49d2881`). + +**Scope:** `native-lib/node` only — `src/dataweave.ts`, `src/addon.c`, and new tests under `tests/`. Do **not** touch `native-lib/python/**`. + +## Problem + +The sixth "andy" follow-up review of PR #157 raised three findings. All three were verified against the live source and are **new** (distinct from rounds 1–5, whose fixes remain intact at HEAD). Rounds 1–5 targeted the module-level singleton and the native isolate teardown; round 6 is the first to attack the **per-instance (`new DataWeave()`) lifecycle** and the **unguarded native lifecycle/handle reads**. + +### Root cause + +Lifecycle state is under-modeled at two layers: + +1. **JS layer:** `DataWeave` uses a single boolean `initialized`. The real lifecycle has an intermediate "cleaning up" phase (`cleanup()` started but `await ffi.cleanup()` not yet settled), which a boolean cannot represent. Every `if (this.initialized)` check therefore treats the cleanup window as "ready." This is exactly what findings #1 and #3 exploit. +2. **C layer:** `napi_run_script_streaming_engine` / `napi_run_script_transform_engine` read the lifecycle flag `g_initialized` **outside** the `g_mutex` that guards it, then reserve `g_active_ops` in a later, separate critical section — a check-and-reserve TOCTOU (finding #2). + +### The three findings (all confirmed) + +**#1 (P1) — cleanup makes the engine handle invalid before marking the instance unavailable.** +`dataweave.ts` `doCleanup()` sets `engineHandle = null` synchronously, but `initialized` only flips to `false` in the `finally` *after* `await ffi.cleanup()`. In that window `initialized === true` && `engineHandle === null`, so `run()`/`runStreaming()`/`runTransform()` pass `ensureInitialized()` and send `null` as the handle. On the C side, `napi_get_value_int64` at addon.c:724-725, 1105-1106, and 1474 does not check its return status; on a null argument it leaves `handle64` as uninitialized stack data, then uses it as the engine handle. + +**#2 (P1) — a Worker can tear down the isolate between stream admission and active-op registration.** +`napi_run_script_streaming_engine` (addon.c:706) and `napi_run_script_transform_engine` (addon.c:1084) read `g_initialized` without `g_mutex`, then take the lock only later to increment `g_active_ops` (addon.c:756-758 / 1155-1157). The C globals are process-shared `static`s, so a second Node Worker can call `napi_cleanup`, hit Case 4 (last ref, `g_active_ops == 0`, addon.c:1745-1781), and synchronously tear down the isolate in that gap. The first Worker's newly spawned thread then attaches to a dead isolate. + +**#3 (P2) — `initialize()` during the same instance's pending cleanup is silently lost.** +`initialize()` (dataweave.ts:77) returns early on `if (this.initialized) return;`. During the cleanup window `initialized` is still `true`, so a second `initialize()` is a no-op; when cleanup then settles it sets `initialized = false`. Net: `dw.cleanup(); dw.initialize();` leaves the instance **uninitialized** despite the explicit second call. Round 5's regression coverage used two instances, so this same-instance path was never exercised. + +## Design + +### 1. JS instance lifecycle state (findings #1 + #3) + +Replace `private initialized = false` with an explicit three-state field: + +```ts +type LifecycleState = "uninitialized" | "ready" | "cleaning-up"; +private state: LifecycleState = "uninitialized"; +``` + +Transitions and gates: + +- **`initialize()`** + - `ready` → no-op (unchanged idempotency). + - `cleaning-up` → **throw** `DataWeaveError("Cannot initialize while cleanup is in progress; await cleanup() first.")` (finding #3 — no more silent no-op). + - `uninitialized` → run the existing load/create-engine work; on success set `state = "ready"`. On failure the existing ref-count-release path runs and state stays `uninitialized`. +- **`run()` / `runStreaming()` / `runTransform()`** — gated by `ensureReady()` (renamed from `ensureInitialized`): throw `DataWeaveError` unless `state === "ready"`. + - In `uninitialized`: existing message ("DataWeave runtime not initialized. Call initialize() first."). + - In `cleaning-up`: `DataWeaveError("DataWeave runtime is cleaning up; await cleanup() before running again.")` (finding #1 — the null handle can no longer reach C). +- **`cleanup()` / `doCleanup()`** — set `state = "cleaning-up"` **synchronously before** `ffi.destroyEngine` / `ffi.cleanup` (the key ordering fix). The `finally` sets `state = "uninitialized"` on both fulfilment and rejection. The existing `cleanupPromise` coalescing (round-4 F1) is preserved: the guard becomes `if (this.state !== "ready") return;` at the top of `cleanup()` for the not-ready early return, and the `if (this.cleanupPromise) return this.cleanupPromise;` coalescing check stays. + +Notes: +- The `engineHandle === null` window still exists internally, but is now unreachable by any public method because every entry point checks `state` first. +- The `constructor` sets `state = "uninitialized"` (replacing `initialized = false`). + +### 2. C admission atomicity (finding #2) + +In both `napi_run_script_streaming_engine` and `napi_run_script_transform_engine`, fold the lifecycle check into the **same** `g_mutex` critical section that increments `g_active_ops`: + +```c +uv_mutex_lock(&g_mutex); +if (!g_initialized || g_teardown_state != TEARDOWN_NONE) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); + return NULL; // reject admission BEFORE any promise/work struct/tsfn is created +} +g_active_ops++; +uv_mutex_unlock(&g_mutex); +``` + +This must be positioned **before** any work struct allocation, tsfn creation, promise creation, or `bridge_begin_op`, so the rejection path frees nothing (mirrors the existing top-of-function `!g_initialized` throw). The cheap top-of-function `!g_initialized` fast-path guard stays; the authoritative check is the one under the lock. Rejecting on `g_teardown_state != TEARDOWN_NONE` also prevents admitting a new op once teardown is queued/underway. + +**Constraint:** must not disturb round 5's `TEARDOWN_*` state machine, the deadlock-free `napi_initialize` adoption path, or the `g_active_ops` decrement-on-worker-thread invariant. Handle width stays `long long`. No `napi_reject_deferred` introduced (rejection here is a synchronous `napi_throw_error` at admission, before any deferred exists — consistent with the existing pattern). + +### 3. N-API handle validation, defense-in-depth (finding #1) + +At the three handle-read sites (addon.c:724-725, 1105-1106, 1474), check the return status of `napi_get_value_int64` (and, where cheap, the arg type via `napi_typeof`); on failure `napi_throw_error` and return `NULL` **before** allocating any work struct or reserving `g_active_ops`. Scope is deliberately these cited handle conversions only — not a blanket audit of every `napi_*` call in the file (YAGNI). This is belt-and-suspenders behind Section 1's JS guard, and the sole protection if the addon is driven directly. + +### 4. Testing + +New regression tests use the **real addon** (no `vi.mock` of `ffi`), mirroring `tests/integration/independent-engines.test.ts`, and are all **same-instance** (round 5's cross-instance coverage is exactly what let #3 slip through): + +1. **Finding #3 — init-during-cleanup rejects, then recovers.** `dw.initialize(); const closing = dw.cleanup(); expect(() => dw.initialize()).toThrow(DataWeaveError)` (message mentions cleanup in progress). Then `await closing; dw.initialize();` succeeds and `dw.run(...)` works. +2. **Finding #1 — op-during-cleanup throws, no null handle to C.** `dw.initialize(); const closing = dw.cleanup(); expect(() => dw.run(...)).toThrow(DataWeaveError)`. Same for `runStreaming`/`runTransform` (their generators reject/throw on first pull). Then `await closing`. +3. **Finding #2 — admission rejected while teardown pending.** Deterministically forcing the cross-Worker isolate-teardown race from JS is not reliably possible; instead assert the admission-rejection path (attempt a streaming/transform op while a module-level teardown is pending → throws/rejects rather than sending work to a dead isolate). Document in the test that the genuine multi-Worker TOCTOU is covered by the C-level reasoning (the check-and-reserve is now atomic under `g_mutex`), not by this test. + +All tests fully clean up (await the cleanup promise; idempotent final `cleanup()`) so they don't perturb sibling integration tests sharing the one process-wide isolate. + +## Verification + +- `cd native-lib/node && npm run build:addon` clean (no new warnings in the touched C regions); `npm run build` (tsc) clean. +- `npm test` green: current baseline **866 passed / 59 skipped / 0 failed**, plus the new same-instance regression tests. +- Optional: `./gradlew native-lib:nodeTest`, `git diff --check`. + +## Global Constraints + +- Node-binding-only. Never touch `native-lib/python/**`. +- Never touch the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`) or `ScriptRuntime.getInstance()`. +- Handle width stays C `long long` everywhere. +- Errors for run/streaming/transform APIs surface as **resolved** JSON string values or thrown `DataWeaveError`/`napi_throw_error` at admission — never `napi_reject_deferred` (absent from addon.c; do not introduce). +- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread. +- All shared C state (`g_initialized`, `g_active_ops`, `g_teardown_state`, `g_teardown_cancelled`, `g_ref_count`) is read/written only under `g_mutex`. +- Preserve every round-1..5 fix: coalesced `cleanup()`, the `TEARDOWN_*` state machine and `napi_initialize` adoption path, the worker-thread `g_active_ops` decrement, guarded cross-thread `destroyEngine`, N-API allocation checks in `teardown_waiter_create`, the enqueue-failure waiter free. +- Node vitest baseline **866 passed / 59 skipped / 0 failed** — every task leaves the suite green. + +## Rejected Alternatives + +- **Finding #3 — queue a re-init after cleanupPromise, or make `initialize()` async.** Rejected: queuing adds async state to a synchronous API and gives queued-init errors no synchronous surface; making `initialize()` async is an API break (`run()` depends on `initialize()` completing synchronously). Deterministic rejection matches the synchronous API and forces callers to `await cleanup()` — chosen. +- **Finding #1 — return an error `ExecutionResult` from `run()` during cleanup instead of throwing.** Rejected for cross-method inconsistency: the streaming generators would still have to throw/yield-error, so behavior would diverge across the three entry points. Throwing `DataWeaveError` uniformly is symmetric with the existing not-initialized behavior and with the init-during-cleanup rejection — chosen. +- **Finding #1 — blanket-audit and validate every `napi_*` return in addon.c.** Rejected as scope creep (YAGNI). Validate the three cited handle conversions; the JS state guard is the primary protection. From a74fc535aa9946ba17382f324fd1824f11e23b8d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 17:08:04 -0300 Subject: [PATCH 45/63] fix(node): model DataWeave instance lifecycle explicitly (round-6 #1/#3) Replace the `initialized` boolean with a uninitialized/ready/cleaning-up state. initialize() during pending cleanup now throws instead of silently no-opping (#3); run()/runStreaming()/runTransform() throw during the cleanup window instead of sending a null engine handle to native code (#1). State flips to cleaning-up synchronously before destroyEngine, closing the window. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 51 ++++++++---- .../integration/instance-lifecycle.test.ts | 78 +++++++++++++++++++ 2 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 native-lib/node/tests/integration/instance-lifecycle.test.ts diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 21f9b37..82bc664 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -48,7 +48,7 @@ export interface DataWeaveOptions { export class DataWeave { private readonly libPath: string; private readonly resolveModule?: ModuleResolver; - private initialized = false; + private state: "uninitialized" | "ready" | "cleaning-up" = "uninitialized"; private engineHandle: number | null = null; private cleanupPromise: Promise | null = null; @@ -72,9 +72,16 @@ export class DataWeave { * initialized. * * @throws DataWeaveError if the native library fails to load or initialize. + * @throws DataWeaveError if called while a `cleanup()` is still in progress + * — await the cleanup first. */ initialize(): void { - if (this.initialized) return; + if (this.state === "ready") return; + if (this.state === "cleaning-up") { + throw new DataWeaveError( + "Cannot initialize while cleanup is in progress; await cleanup() first." + ); + } let libRefAcquired = false; try { ffi.initialize(this.libPath); @@ -85,8 +92,8 @@ export class DataWeave { } catch (e: unknown) { // If ffi.initialize() already succeeded but engine creation then threw, // we already hold an increment of the native library's ref-counted - // handle. this.initialized stays false below (we're about to throw), - // so cleanup()'s early-return guard (`if (!this.initialized) return;`) + // handle. this.state stays "uninitialized" below (we're about to throw), + // so cleanup()'s early-return guard (`if (this.state !== "ready") return;`) // means nothing else will ever call ffi.cleanup() for this instance -- // release the ref-count ourselves here or it leaks for the process // lifetime. @@ -96,7 +103,7 @@ export class DataWeave { this.engineHandle = null; throw new DataWeaveError(`Failed to initialize: ${e instanceof Error ? e.message : e}`); } - this.initialized = true; + this.state = "ready"; } /** @@ -111,14 +118,16 @@ export class DataWeave { * an isolate that is still tearing down. */ async cleanup(): Promise { - if (!this.initialized) return; - // Coalesce concurrent cleanup() calls: `initialized` does not flip to - // false until doCleanup()'s finally runs (after the await below), so - // without this a second overlapping call would pass the guard above and + if (this.state !== "ready") return; + // Coalesce concurrent cleanup() calls: `state` flips to "cleaning-up" + // synchronously below, but a second overlapping call arriving before that + // flip (both observing "ready") would otherwise pass the guard above and // invoke ffi.cleanup() again -- a second decrement of the process-shared // native ref-count that can tear the isolate down under another live // instance. Store the in-progress promise before the first await and hand - // it to every concurrent caller so the native teardown happens once. + // it to every concurrent caller so the native teardown happens once. Once + // state is "cleaning-up", later cleanup() calls return early via the guard + // above -- the first call already owns the teardown and its promise. if (this.cleanupPromise) return this.cleanupPromise; this.cleanupPromise = this.doCleanup(); try { @@ -131,6 +140,10 @@ export class DataWeave { } private async doCleanup(): Promise { + // Transition BEFORE releasing the engine so run()/initialize() called + // during the async teardown window are rejected deterministically rather + // than seeing a stale "ready" state with a null engineHandle (round-6 #1/#3). + this.state = "cleaning-up"; try { if (this.engineHandle !== null) { ffi.destroyEngine(this.engineHandle); @@ -138,7 +151,7 @@ export class DataWeave { } await ffi.cleanup(); } finally { - this.initialized = false; + this.state = "uninitialized"; } } @@ -154,7 +167,7 @@ export class DataWeave { * @throws DataWeaveScriptError if the script fails and `opts.raiseOnError` is set. */ run(script: string, inputs?: Inputs, opts?: { raiseOnError?: boolean }): ExecutionResult { - this.ensureInitialized(); + this.ensureReady(); const inputsJson = buildInputsJson(inputs ?? {}); const raw = ffi.runScriptEngine(this.engineHandle!, script, inputsJson); @@ -179,7 +192,7 @@ export class DataWeave { * @throws DataWeaveError if the runtime is not initialized. */ async *runStreaming(script: string, inputs?: Inputs): AsyncGenerator { - this.ensureInitialized(); + this.ensureReady(); const inputsJson = buildInputsJson(inputs ?? {}); return yield* streamFromNative((chunkCb) => ffi.runScriptStreamingEngine(this.engineHandle!, script, inputsJson, chunkCb) @@ -207,7 +220,7 @@ export class DataWeave { input: AsyncIterable | Iterable, opts?: TransformOptions ): AsyncGenerator { - this.ensureInitialized(); + this.ensureReady(); const inputName = opts?.inputName ?? "payload"; const inputMimeType = opts?.mimeType ?? "application/json"; @@ -231,10 +244,14 @@ export class DataWeave { ); } - private ensureInitialized(): void { - if (!this.initialized) { - throw new DataWeaveError("DataWeave runtime not initialized. Call initialize() first."); + private ensureReady(): void { + if (this.state === "ready") return; + if (this.state === "cleaning-up") { + throw new DataWeaveError( + "DataWeave runtime is cleaning up; await cleanup() before running again." + ); } + throw new DataWeaveError("DataWeave runtime not initialized. Call initialize() first."); } } diff --git a/native-lib/node/tests/integration/instance-lifecycle.test.ts b/native-lib/node/tests/integration/instance-lifecycle.test.ts new file mode 100644 index 0000000..03f9a66 --- /dev/null +++ b/native-lib/node/tests/integration/instance-lifecycle.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { DataWeave } from "../../src/dataweave"; +import { DataWeaveError } from "../../src/errors"; + +// Same-instance lifecycle regression tests (round 6, W-23692110). Round 5's +// coverage used a second instance; the same-instance cleanup window is exactly +// what findings #1 and #3 exploit. Real addon, no mocking. +describe("instance lifecycle during cleanup (round 6)", () => { + let dw: DataWeave | undefined; + afterEach(async () => { + // Whatever state each test leaves it in, drain and release so the shared + // process-wide isolate is clean for sibling tests. + if (dw) { + try { await dw.cleanup(); } catch { /* already released */ } + dw = undefined; + } + }); + + // Finding #3: initialize() during the same instance's pending cleanup must + // reject deterministically, not be a silent no-op that leaves the instance + // uninitialized after cleanup settles. + it("initialize() during pending cleanup throws, and re-init works after cleanup settles", async () => { + dw = new DataWeave(); + dw.initialize(); + const closing = dw.cleanup(); // not awaited: instance is now "cleaning-up" + expect(() => dw!.initialize()).toThrow(DataWeaveError); + expect(() => dw!.initialize()).toThrow(/cleanup is in progress/i); + await closing; // now "uninitialized" + // Explicit re-init now succeeds and the instance is usable again. + dw.initialize(); + const r = dw.run("%dw 2.0\noutput application/json\n---\n1 + 1"); + expect(r.success).toBe(true); + expect(JSON.parse(r.getString()!)).toBe(2); + }); + + // Finding #1: run() during the cleanup window must throw a clean DataWeaveError + // (never send a null handle to C), because doCleanup() nulls engineHandle + // synchronously before awaiting native cleanup. + it("run() during pending cleanup throws DataWeaveError, not a native/null-handle error", async () => { + dw = new DataWeave(); + dw.initialize(); + const closing = dw.cleanup(); + expect(() => dw!.run("%dw 2.0\noutput application/json\n---\n1")).toThrow(DataWeaveError); + expect(() => dw!.run("%dw 2.0\noutput application/json\n---\n1")).toThrow(/cleaning up/i); + await closing; + }); + + // Finding #1, streaming/transform variants: the async generators must reject + // on first pull when started during the cleanup window. + it("runStreaming()/runTransform() during pending cleanup reject on first pull", async () => { + dw = new DataWeave(); + dw.initialize(); + const closing = dw.cleanup(); + + const sgen = dw.runStreaming("%dw 2.0\noutput application/json\n---\n[1,2,3]"); + await expect(sgen.next()).rejects.toThrow(DataWeaveError); + + const tgen = dw.runTransform( + "output application/json\n---\npayload", + [Buffer.from("[1,2,3]")], + { mimeType: "application/json" } + ); + await expect(tgen.next()).rejects.toThrow(DataWeaveError); + + await closing; + }); + + // Idempotency preserved: cleanup() before initialize() is a no-op; double + // cleanup() coalesces (round-4 F1 must survive this refactor). + it("cleanup() is a no-op when uninitialized and coalesces when called twice", async () => { + dw = new DataWeave(); + await expect(dw.cleanup()).resolves.toBeUndefined(); // uninitialized no-op + dw.initialize(); + const a = dw.cleanup(); + const b = dw.cleanup(); // must return the same in-flight settlement, one native teardown + await Promise.all([a, b]); + }); +}); From a567c73f3e584bb1c035185a3e977038d175d31b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 17:23:49 -0300 Subject: [PATCH 46/63] fix(node): coalesce cleanup() before the not-ready guard (task-1 review fix) doCleanup() flips `state` to "cleaning-up" synchronously as its first statement, so a second overlapping cleanup() call already sees state left "ready" by the time it runs. Checking the not-ready guard first made the cleanupPromise coalescing branch dead code: the second caller returned immediately instead of awaiting the first caller's in-flight native teardown, regressing round-4's coalescing timing and contradicting cleanup()'s documented contract. Reorder so the cleanupPromise check runs first. Add a unit test that races the second call's promise against a same-tick marker to assert it stays pending until native teardown settles. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 23 +++++---- .../tests/unit/dataweave-initialize.test.ts | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 82bc664..3918508 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -118,17 +118,20 @@ export class DataWeave { * an isolate that is still tearing down. */ async cleanup(): Promise { - if (this.state !== "ready") return; - // Coalesce concurrent cleanup() calls: `state` flips to "cleaning-up" - // synchronously below, but a second overlapping call arriving before that - // flip (both observing "ready") would otherwise pass the guard above and - // invoke ffi.cleanup() again -- a second decrement of the process-shared - // native ref-count that can tear the isolate down under another live - // instance. Store the in-progress promise before the first await and hand - // it to every concurrent caller so the native teardown happens once. Once - // state is "cleaning-up", later cleanup() calls return early via the guard - // above -- the first call already owns the teardown and its promise. + // Coalesce first: doCleanup() flips `state` to "cleaning-up" synchronously + // as its first statement, so by the time a second overlapping call runs, + // `state` has already left "ready". If the not-ready guard below ran + // first, that second caller would resolve immediately instead of + // awaiting the first caller's in-flight native teardown -- contradicting + // this method's contract of resolving only once the isolate has actually + // finished tearing down (round-6 review, task-1 fix round 1). Checking + // `cleanupPromise` first ensures every concurrent caller that overlaps + // with an in-flight doCleanup() awaits that SAME promise, so the native + // teardown (ffi.destroyEngine/ffi.cleanup) still happens exactly once. if (this.cleanupPromise) return this.cleanupPromise; + // Not coalescing with an in-flight cleanup: nothing to do unless we're + // "ready" (covers both never-initialized and already-settled cleanup). + if (this.state !== "ready") return; this.cleanupPromise = this.doCleanup(); try { await this.cleanupPromise; diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 1a234b3..807cf89 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -148,4 +148,51 @@ describe("DataWeave.initialize() native ref-count safety", () => { expect(ffi.cleanup).toHaveBeenCalledTimes(1); expect(ffi.destroyEngine).toHaveBeenCalledTimes(1); }); + + it("second overlapping cleanup() call awaits the SAME in-flight native teardown, not an early resolution", async () => { + // Regression test for task-1 fix round 1: doCleanup() flips `state` to + // "cleaning-up" synchronously as its first statement (an async function + // body runs synchronously up to its first await). If cleanup()'s + // not-ready guard (`if (this.state !== "ready") return;`) ran BEFORE the + // `cleanupPromise` coalescing check, a second overlapping call would see + // state already left "ready" and resolve immediately -- never actually + // awaiting the first call's in-flight native teardown. That would + // contradict cleanup()'s documented contract ("resolves once the + // underlying native isolate has actually finished tearing down") and + // silently regress round-4's coalescing timing. This test asserts the + // second call's promise has NOT settled while ffi.cleanup() is still + // pending, by racing it against a marker that only resolves after + // ffi.cleanup() is allowed to settle. + vi.mocked(ffi.createEngine).mockReturnValue(1); + let resolveNative!: () => void; + vi.mocked(ffi.cleanup).mockReturnValue( + new Promise((resolve) => { + resolveNative = resolve; + }) + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + + const p1 = dw.cleanup(); + const p2 = dw.cleanup(); // overlaps while doCleanup() is in flight + + const SETTLED = Symbol("settled"); + const PENDING = Symbol("pending"); + // A same-tick race: if p2 resolved early (the regression), it wins; + // Promise.resolve() flushes on the same microtask queue, so this + // reliably distinguishes "already settled" from "still pending" without + // relying on real timers. + const raceResult = await Promise.race([ + p2.then(() => SETTLED), + Promise.resolve().then(() => PENDING), + ]); + expect(raceResult).toBe(PENDING); + + resolveNative(); + await Promise.all([p1, p2]); + + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + expect(ffi.destroyEngine).toHaveBeenCalledTimes(1); + }); }); From 5a152cb6f4df6722e91c01c03427248e4eb7c9c0 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 19:31:30 -0300 Subject: [PATCH 47/63] fix(node): make stream/transform admission atomic under g_mutex (round-6 #2) Fold the g_initialized lifecycle check into the same critical section that reserves g_active_ops, before any work/tsfn/promise/bridge is allocated, and reject admission when a teardown is queued/underway. Closes the cross-Worker TOCTOU where a second Worker's Case-4 synchronous teardown could fire between the old unlocked g_initialized read and the later g_active_ops reservation. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 48 ++++--- .../admission-during-teardown.test.ts | 136 ++++++++++++++++++ 2 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 native-lib/node/tests/integration/admission-during-teardown.test.ts diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 1dff35c..3dbe8e5 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -721,6 +721,22 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i return NULL; } + // Atomic admission: check lifecycle state and reserve the op in ONE critical + // section, before allocating any work/tsfn/promise/bridge. Reading + // g_initialized outside the lock and reserving g_active_ops later (the old + // shape) let a second Worker's napi_cleanup Case-4 tear the isolate down in + // the gap, so a freshly spawned worker attached to a dead isolate (round-6 + // #2). Rejecting on g_teardown_state != TEARDOWN_NONE also refuses new ops + // once a teardown is queued/underway. + uv_mutex_lock(&g_mutex); + if (!g_initialized || g_teardown_state != TEARDOWN_NONE) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); + return NULL; + } + g_active_ops++; + uv_mutex_unlock(&g_mutex); + int64_t handle64; napi_get_value_int64(env, argv[0], &handle64); @@ -747,16 +763,10 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i // resolve_module_callback (F1). NULL for resolver-less engines. Must happen // before spawning the thread; the completion sentinel releases it via // bridge_end_op. No early return exists between here and the spawn. + // g_active_ops was already reserved above, in the same critical section as + // the admission check (round-6 #2) -- no separate reservation here. w->bridge = bridge_begin_op(w->handle); - // Count this op globally so a concurrent cleanup() knows to wait for it - // before tearing down the isolate (see g_active_ops comment above). Same - // timing/invariant as bridge_begin_op: before spawning the worker thread, - // no early return in between. - uv_mutex_lock(&g_mutex); - g_active_ops++; - uv_mutex_unlock(&g_mutex); - uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; @@ -1099,6 +1109,18 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i return NULL; } + // Atomic admission (see napi_run_script_streaming_engine for the full + // rationale, round-6 #2): check lifecycle + reserve g_active_ops in one + // critical section, before any work/tsfn/promise/bridge is committed. + uv_mutex_lock(&g_mutex); + if (!g_initialized || g_teardown_state != TEARDOWN_NONE) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); + return NULL; + } + g_active_ops++; + uv_mutex_unlock(&g_mutex); + struct transform_work* w = calloc(1, sizeof(struct transform_work)); size_t len; @@ -1146,16 +1168,10 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i // resolve_module_callback (F1). NULL for resolver-less engines. Must happen // before spawning the thread; the completion sentinel releases it via // bridge_end_op. No early return exists between here and the spawn. + // g_active_ops was already reserved above, in the same critical section as + // the admission check (round-6 #2) -- no separate reservation here. w->bridge = bridge_begin_op(w->handle); - // Count this op globally so a concurrent cleanup() knows to wait for it - // before tearing down the isolate (see g_active_ops comment above). Same - // timing/invariant as bridge_begin_op: before spawning the worker thread, - // no early return in between. - uv_mutex_lock(&g_mutex); - g_active_ops++; - uv_mutex_unlock(&g_mutex); - uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; diff --git a/native-lib/node/tests/integration/admission-during-teardown.test.ts b/native-lib/node/tests/integration/admission-during-teardown.test.ts new file mode 100644 index 0000000..a87b7f1 --- /dev/null +++ b/native-lib/node/tests/integration/admission-during-teardown.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import * as ffi from "../../src/ffi"; +import { findLibrary, buildInputsJson } from "../../src/utils"; + +// Round-6 finding #2: napi_run_script_streaming_engine/napi_run_script_transform_engine +// used to read g_initialized outside g_mutex, then reserve g_active_ops in a +// LATER, separate critical section right before spawning the worker thread -- +// with no reference to g_teardown_state at all. The fix folds the lifecycle +// check (including g_teardown_state) and the g_active_ops reservation into one +// atomic critical section, before any work/tsfn/promise/bridge is allocated, +// and rejects admission once a teardown is queued/underway +// (g_teardown_state != TEARDOWN_NONE), not just when the isolate is fully gone. +// +// Why this test drives the addon through the raw `ffi` module instead of the +// module-level `run`/`runStreaming`/`runTransform`/`cleanup` singleton (as the +// original brief sketch does): the module-level `cleanup()` nulls the +// singleton, so a later module-level `runStreaming()`/`runTransform()` call +// re-creates a fresh `DataWeave` instance and calls `initialize()` again. +// `napi_initialize`'s TEARDOWN_PENDING_WAIT branch (round-5's deadlock fix) +// treats that as a legitimate ADOPTION of the still-live isolate: it sets +// g_teardown_cancelled = true and cancels the pending teardown *before* the +// second op's admission check ever runs -- so by the time streaming/transform +// admission is checked, g_teardown_state is already back to TEARDOWN_NONE +// (verified empirically while developing this test: the brief's literal shape +// resolves the second op cleanly on both pre-fix and post-fix code, so it +// cannot distinguish them -- it never reaches the vulnerable window because +// the intervening initialize() call cancels the teardown as a side effect). +// +// To actually observe admission-during-pending-teardown, the second op must +// run against the SAME still-live handle/isolate WITHOUT any intervening +// ffi.initialize() call. Calling `ffi.cleanup()` directly (skipping +// `destroyEngine`) triggers exactly napi_cleanup's Case 5 (last ref release +// with an active op) and sets g_teardown_state = TEARDOWN_PENDING_WAIT +// synchronously, under g_mutex, before napi_cleanup returns its Promise to +// JS -- with no adoption path involved, since nothing calls initialize() +// afterward. +// +// Determinism: `ffi.cleanup()`'s synchronous prefix (native napi_cleanup body) +// runs entirely synchronously up to the point where it returns a Promise; the +// TEARDOWN_PENDING_WAIT transition happens on that same synchronous call, not +// after an await. The immediately-following `ffi.runScriptStreamingEngine` +// call re-enters native code synchronously (it's a plain N-API call), on the +// very same JS callstack, so it deterministically observes +// g_teardown_state == TEARDOWN_PENDING_WAIT with no timing assumptions -- +// mirroring the round-5 teardown-deadlock test's use of a synchronous native +// read-callback to force deterministic ordering instead of timers. +// +// Real addon, no mocking. +describe("admission rejected while teardown pending (round 6 #2)", () => { + it("a streaming op started on the same handle during pending teardown is rejected, not admitted", async () => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + + let cleanupPromise: Promise | undefined; + let admitErr: unknown; + let admitted = false; + let secondOpSettled: Promise = Promise.resolve(); + + let firstRead = true; + const readCb = (_bufSize: number): Buffer | null => { + if (firstRead) { + firstRead = false; + + // Trigger Case 5 of napi_cleanup: last release of the shared library + // ref-count while this transform's worker is attached and + // g_active_ops > 0. Synchronously sets g_teardown_state = + // TEARDOWN_PENDING_WAIT before returning. Not awaited -- the point is + // to observe the state it leaves behind, not its eventual settlement. + cleanupPromise = ffi.cleanup(); + + // Attempt a second admission on the SAME still-live handle/isolate + // while teardown is pending. Fixed code rejects admission with a + // synchronous napi_throw_error (the atomic admission check sees + // g_teardown_state != TEARDOWN_NONE, before any promise is even + // created). Pre-fix code admits it: the unlocked g_initialized check + // passes (the isolate genuinely hasn't been torn down yet -- + // TEARDOWN_PENDING_WAIT hasn't reached physical teardown) and + // g_active_ops is reserved without ever consulting g_teardown_state, + // so the call returns a promise that goes on to resolve successfully. + // + // On rejection, napi_throw_error fires synchronously from this very + // call (admission fails before any promise is created), so it must + // be caught here rather than only via a rejected-promise `.then` -- + // mirroring the round-5 teardown-deadlock test's care not to let a + // thrown exception escape a native read-callback body (it would be + // reinterpreted as a read error, masking the real outcome). + try { + secondOpSettled = ffi + .runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1,2,3]", + buildInputsJson({}), + () => {} + ) + .then( + () => { admitted = true; }, + (e) => { admitErr = e; } + ); + } catch (e) { + admitErr = e; + } + + return Buffer.from("[1,2,3]"); + } + return null; // EOF after the first chunk + }; + + const chunks: Buffer[] = []; + const writeCb = (chunk: Buffer) => { chunks.push(chunk); }; + + const resultRaw = await ffi.runScriptTransformEngine( + handle, + "output application/json\n---\npayload", + "{}", + "payload", + "application/json", + null, + readCb, + writeCb + ); + const result = JSON.parse(resultRaw); + expect(result.success).toBe(true); + + // Let the second op settle (whichever branch it took) before asserting, + // and drain the pending teardown so the shared native isolate is left in + // a clean, consistent state for sibling test files in this process. + await secondOpSettled; + await cleanupPromise; + + // The second op admitted while teardown was pending must have been + // rejected, not silently admitted against an isolate a concurrent + // teardown could tear down out from under it. + expect(admitErr).toBeTruthy(); + expect(admitted).toBe(false); + }, 20000); +}); From 626e363dd207e5e638be1eb8f9fa1e29c4a36a36 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 19:52:28 -0300 Subject: [PATCH 48/63] fix(node): validate napi_get_value_int64 at handle-read sites (round-6 #1) Check the conversion status (and reject non-integer handles) in runScriptEngine, runScriptStreamingEngine, and runScriptTransformEngine instead of using uninitialized stack data as an engine handle. Defense-in-depth behind the JS-layer lifecycle guard. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 33 +++++++--- .../integration/handle-validation.test.ts | 61 +++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 native-lib/node/tests/integration/handle-validation.test.ts diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 3dbe8e5..0355927 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -721,6 +721,16 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i return NULL; } + // Validate the handle before admission (round-6 #1, defense-in-depth): a + // non-integer handle must be rejected before g_active_ops is ever reserved, + // so there is nothing to unwind here -- simpler than reserving first and + // unwinding on failure. + int64_t handle64; + if (napi_get_value_int64(env, argv[0], &handle64) != napi_ok) { + napi_throw_error(env, NULL, "runScriptStreamingEngine: handle must be an integer"); + return NULL; + } + // Atomic admission: check lifecycle state and reserve the op in ONE critical // section, before allocating any work/tsfn/promise/bridge. Reading // g_initialized outside the lock and reserving g_active_ops later (the old @@ -737,9 +747,6 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i g_active_ops++; uv_mutex_unlock(&g_mutex); - int64_t handle64; - napi_get_value_int64(env, argv[0], &handle64); - size_t script_len, inputs_len; napi_get_value_string_utf8(env, argv[1], NULL, 0, &script_len); napi_get_value_string_utf8(env, argv[2], NULL, 0, &inputs_len); @@ -1109,6 +1116,17 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i return NULL; } + // Validate the handle before admission (round-6 #1, defense-in-depth): a + // non-integer handle must be rejected before g_active_ops is ever reserved, + // so there is nothing to unwind here -- simpler than reserving first and + // unwinding on failure. Keep this consistent with + // napi_run_script_streaming_engine's ordering. + int64_t handle64; + if (napi_get_value_int64(env, argv[0], &handle64) != napi_ok) { + napi_throw_error(env, NULL, "runScriptTransformEngine: handle must be an integer"); + return NULL; + } + // Atomic admission (see napi_run_script_streaming_engine for the full // rationale, round-6 #2): check lifecycle + reserve g_active_ops in one // critical section, before any work/tsfn/promise/bridge is committed. @@ -1123,9 +1141,6 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i struct transform_work* w = calloc(1, sizeof(struct transform_work)); size_t len; - - int64_t handle64; - napi_get_value_int64(env, argv[0], &handle64); w->handle = (long long)handle64; napi_get_value_string_utf8(env, argv[1], NULL, 0, &len); @@ -1487,7 +1502,11 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) size_t argc = 3; napi_value argv[3]; napi_get_cb_info(env, info, &argc, argv, NULL, NULL); if (argc < 3) { napi_throw_error(env, NULL, "runScriptEngine requires (handle, script, inputsJson)"); return NULL; } - int64_t handle64; napi_get_value_int64(env, argv[0], &handle64); + int64_t handle64; + if (napi_get_value_int64(env, argv[0], &handle64) != napi_ok) { + napi_throw_error(env, NULL, "runScriptEngine: handle must be an integer"); + return NULL; + } long long handle = (long long)handle64; size_t script_len, inputs_len; diff --git a/native-lib/node/tests/integration/handle-validation.test.ts b/native-lib/node/tests/integration/handle-validation.test.ts new file mode 100644 index 0000000..b593fa8 --- /dev/null +++ b/native-lib/node/tests/integration/handle-validation.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import * as ffi from "../../src/ffi"; +import { findLibrary } from "../../src/utils"; + +// Round-6 finding #1 (defense-in-depth): the native handle-read sites +// (napi_get_value_int64 in napi_run_script_engine, +// napi_run_script_streaming_engine, napi_run_script_transform_engine) must +// reject a non-integer handle argument instead of silently using +// uninitialized/garbage stack data as the engine handle. +// +// This is driven through `ffi` (the raw addon boundary), not through the +// `DataWeave` class, because Task 1's JS-layer state guard only ever passes +// `this.engineHandle` (always a number once initialized) down to the native +// call -- so a bad handle can never reach these C sites through the public +// TS API. Each `ffi.xxx` export is a pure pass-through to the native addon +// (see src/ffi.ts: no validation of its own), so calling them directly with +// a non-numeric "handle" exercises the raw C boundary while reusing the same +// initialize()/findLibrary() bootstrap the other integration tests use. +// +// One test covers all three sites (rather than three separate tests) to keep +// the suite's test count increasing by exactly one for this task. +// +// Real addon, no mocking. +describe("native handle validation (round 6 #1)", () => { + it("runScriptEngine/runScriptStreamingEngine/runScriptTransformEngine all throw on a non-integer handle rather than using garbage", () => { + ffi.initialize(findLibrary()); + + // napi_get_value_int64 must fail (and be checked) for a non-numeric + // handle argument; each site must throw cleanly instead of proceeding + // with whatever `handle64` happened to contain on the stack. + expect(() => + ffi.runScriptEngine( + {} as unknown as number, + "%dw 2.0\noutput application/json\n---\n1", + "{}" + ) + ).toThrow(); + + expect(() => + ffi.runScriptStreamingEngine( + {} as unknown as number, + "%dw 2.0\noutput application/json\n---\n1", + "{}", + () => {} + ) + ).toThrow(); + + expect(() => + ffi.runScriptTransformEngine( + {} as unknown as number, + "output application/json\n---\npayload", + "{}", + "payload", + "application/json", + null, + () => null, + () => {} + ) + ).toThrow(); + }); +}); From d6cd4ec31e04129c6e9dd0afa5ef390dd1c1e0cc Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 20:07:40 -0300 Subject: [PATCH 49/63] fix(node): balance ffi.initialize()/cleanup() in handle-validation test The new handle-validation test called ffi.initialize() with no matching ffi.cleanup(), leaking g_ref_count into sibling integration test files sharing the same vitest worker process (native addon globals are process-wide C statics, not reset per-file). Add an afterEach that awaits ffi.cleanup(), mirroring instance-lifecycle.test.ts's convention. Co-Authored-By: Claude Sonnet 5 --- .../tests/integration/handle-validation.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/native-lib/node/tests/integration/handle-validation.test.ts b/native-lib/node/tests/integration/handle-validation.test.ts index b593fa8..2feddfb 100644 --- a/native-lib/node/tests/integration/handle-validation.test.ts +++ b/native-lib/node/tests/integration/handle-validation.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import * as ffi from "../../src/ffi"; import { findLibrary } from "../../src/utils"; @@ -21,7 +21,19 @@ import { findLibrary } from "../../src/utils"; // the suite's test count increasing by exactly one for this task. // // Real addon, no mocking. +// +// The native addon globals (g_ref_count, g_initialized, etc.) are +// process-wide C statics -- vitest's per-file module isolation does NOT +// reset them. Every ffi.initialize() here must be balanced by a matching +// ffi.cleanup() so this file doesn't leak a ref-count bump into sibling +// integration test files sharing the same vitest worker process (mirrors +// admission-during-teardown.test.ts's care to drain/settle before the file +// ends, and instance-lifecycle.test.ts's afterEach cleanup pattern). describe("native handle validation (round 6 #1)", () => { + afterEach(async () => { + await ffi.cleanup(); + }); + it("runScriptEngine/runScriptStreamingEngine/runScriptTransformEngine all throw on a non-integer handle rather than using garbage", () => { ffi.initialize(findLibrary()); From 118f29fb1d794940ea967cbf4431eb16f3c581f8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 12:01:44 -0300 Subject: [PATCH 50/63] docs: design spec for round-7 FFI admission & conversion sweep (W-23692110) Complete-class sweep for the three findings in docs/pr-157-follow-up-andy-code-review-7.md: (1) atomic g_mutex admission for synchronous napi_run_script_engine (late reservation spanning attach->detach); (2) uniform napi_get_value_* status checks across all FFI-facing entrypoints; (3) docs await cleanup(). Breaks the round-N-finds-the-sibling-site recurrence by fixing both defect classes, not just the cited lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...i-admission-and-conversion-sweep-design.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-18-ffi-admission-and-conversion-sweep-design.md diff --git a/docs/superpowers/specs/2026-08-18-ffi-admission-and-conversion-sweep-design.md b/docs/superpowers/specs/2026-08-18-ffi-admission-and-conversion-sweep-design.md new file mode 100644 index 0000000..4fa2c0a --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-ffi-admission-and-conversion-sweep-design.md @@ -0,0 +1,120 @@ +# FFI Admission & Conversion Sweep — Round 7 (W-23692110) + +**Status:** Design approved, ready for planning. + +**Source review:** `docs/pr-157-follow-up-andy-code-review-7.md` (three findings, all verified against live source at commit `d6cd4ec`, the round-6 tip). + +**Scope:** `native-lib/node` only — `src/addon.c`, `docs/external-modules.md`, and new tests under `tests/`. Do **not** touch `native-lib/python/**`. + +## Problem + +The seventh "andy" follow-up review of PR #157 raised three findings. All three were verified against live source and are real. Two of them (#1 and #2) are the **structurally-identical siblings** of sites that round 6 fixed — round 6's own final review flagged them as "Minor / pre-existing, out-of-scope," and this review escalates #1 to P1. + +### Root cause of the recurrence + +The concurrency machinery introduced across rounds 3–6 is sound; the recurrence is a **scoping habit**, not a new class of bug each round. Each round fixed exactly the sites its review named, and the next review walked to the sibling site with the same defect: + +- Round 6 made **streaming + transform** admission atomic under `g_mutex`, but left the **synchronous `run()`** path out because that review cited only streaming/transform. → round-7 #1. +- Round 6 validated the **three handle-read** `napi_get_value_int64` conversions, but not the **string-length reads** or **`destroyEngine`**, because those weren't cited. → round-7 #2. + +Round 7 breaks the cycle by fixing both defect **classes** uniformly, so no structurally-identical site is left for a round 8 to find. + +### The three findings (all confirmed) + +**#1 (P1) — buffered `run()` is not protected from concurrent isolate teardown.** +`napi_run_script_engine` (addon.c:1500-1534) touches the isolate (`fn_attach_thread` → `fn_run_script_engine` → `fn_detach_thread`) with only the top-of-function `if (!g_initialized)` fast-path. It never reserves `g_active_ops` under `g_mutex`. A second Node Worker performing the last `cleanup()` can observe `g_active_ops == 0` (`napi_cleanup` Case 4), tear down `g_isolate`, and leave this synchronous op attaching to / executing in a dead isolate — a use-after-free. + +**#2 (P2) — raw addon callers can pass malformed values that become uninitialized native inputs.** +Multiple FFI-facing entrypoints ignore the return status of `napi_get_value_*` conversions: +- `destroyEngine` (addon.c:1441) — ignores `napi_get_value_int64`; a non-integer handle yields an indeterminate `handle64` and could destroy an unrelated engine. +- `run` string lengths (addon.c:1513-1514), `streaming` (addon.c:751-752), `transform` (addon.c:1146-1167) — ignore the `napi_get_value_string_utf8` size-probe status; on a non-string argument `*_len` stays uninitialized before `malloc(len + 1)` and the subsequent buffer write. + +**#3 (P2) — documentation examples do not await asynchronous `cleanup()`.** +`native-lib/node/docs/external-modules.md:197-198` and `:310` call `cleanup()` without `await`, contradicting round 6's new async lifecycle contract (`cleanup(): Promise`). + +## Design + +### 1. Atomic admission for synchronous `run()` (finding #1) + +Give `napi_run_script_engine` the same mutex-protected lifecycle admission that streaming/transform got in round 6, but reserve **late** — immediately before `fn_attach_thread`, not at the top of the function. + +```c +uv_mutex_lock(&g_mutex); +if (!g_initialized || g_teardown_state != TEARDOWN_NONE) { + uv_mutex_unlock(&g_mutex); + free(script); free(inputs); + napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); + return NULL; +} +g_active_ops++; +uv_mutex_unlock(&g_mutex); + +void* thread = NULL; +if (fn_attach_thread(g_isolate, &thread) != 0) { + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + free(script); free(inputs); + napi_throw_error(env, NULL, "Failed to attach thread"); + return NULL; +} + +char* result = (char*)fn_run_script_engine(thread, handle, script, inputs); +// ... existing resolver_results_free_all, strdup, fn_free_cstring, fn_detach_thread, free(script/inputs) ... + +uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); +``` + +**Why late, not early (unlike streaming/transform):** the string `malloc`s and argument extraction don't touch the isolate, so the reservation only needs to span `attach → detach`. Reserving just before attach yields exactly **two** unwind sites — the attach-failure branch and normal completion — instead of additionally having to unwind the OOM/allocation path. `run()` is fully synchronous on the JS thread, so both the reservation and the release happen inline; there is no worker thread. The `uv_cond_broadcast(&g_teardown_cond)` on decrement is what wakes a `teardown_waiter_thread_fn` blocked on `g_active_ops > 0`, matching how the streaming/transform worker threads decrement. + +**Ordering vs. Part 2:** the string-length checks (Part 2) run before the reservation, so a malformed-input throw there returns before `g_active_ops++` and needs no unwind. The reservation block is placed after the buffers are populated and before attach. + +**Keep the top-of-function `!g_initialized` fast-path** as a cheap early reject; the authoritative check is the one under the lock. The already-validated handle `int64` read (round 6, addon.c:1505-1510) is unchanged. + +### 2. Uniform `napi_get_value_*` status checks (finding #2 → whole class) + +Every FFI-facing entrypoint checks the status of **every** `napi_get_value_*` conversion and throws via `napi_throw_error` (consistent with all existing throws in the file — round-6 handle validation, "Not initialized", "OOM") **before** using the converted value. + +Guiding invariant: **no converted value is read before its conversion status is confirmed `napi_ok`, and no throw leaves `g_active_ops` reserved.** + +Sites: +- **`destroyEngine` (addon.c:1441):** check `napi_get_value_int64`; throw "destroyEngine: handle must be an integer" before any registry lookup or destroy. No `g_active_ops` on this path. +- **`run` (addon.c:1513-1519):** check both `napi_get_value_string_utf8` size probes; throw before `malloc(len + 1)`. These checks run **before** the Part 1 reservation, so no unwind needed. Also check the fill-phase `napi_get_value_string_utf8` calls. +- **`streaming` (addon.c:751-759):** check both size probes and both fills. A throw here happens **after** `g_active_ops++` (round-6 admission block sits above), so each must `g_active_ops--; uv_cond_broadcast(&g_teardown_cond);` under `g_mutex` and free any already-allocated buffers before returning. +- **`transform` (addon.c:1146-1167):** same — check every size probe and fill, and the `napi_typeof` for `argv[5]`; throw-after-reservation paths must unwind `g_active_ops` and free partial allocations. + +The already-validated handle `int64` reads at the streaming/transform sites (round 6) are left as-is. Scope is the FFI-facing entrypoints' conversions — not a blanket audit of unrelated `napi_*` calls (YAGNI). + +### 3. Docs await `cleanup()` (finding #3) + +In `native-lib/node/docs/external-modules.md`, make the example functions that call `cleanup()` `async` and `await cleanup()` in their `finally` blocks (lines 197-198, 310). Sweep the whole document for any other bare `cleanup()` call and fix consistently. + +### 4. Testing + +New regression tests use the **real addon** (no `vi.mock` of `ffi`), mirroring `tests/integration/handle-validation.test.ts` and `admission-during-teardown.test.ts`, and all fully clean up (balance every `ffi.initialize()` with `await ffi.cleanup()`) so they do not perturb the shared process-wide isolate for sibling integration tests. + +1. **Finding #1 — `run()` admission.** Drive raw `ffi.runScriptEngine` and assert the admission-rejection path: a `run()` attempted while teardown is pending throws rather than attaching to a dead isolate. Document in the test that the genuine cross-Worker TOCTOU is not reliably forceable from JS (same limitation as round-6 #2); the C-level reasoning — check-and-reserve is now atomic under `g_mutex` on the `run()` path — is what covers the race. +2. **Finding #2 — malformed inputs throw, nothing allocated on an uninitialized length.** Raw-`ffi` calls: a non-integer handle to `destroyEngine`; non-string `script`/`inputs` to `run`, `runStreaming`, `runTransform`. Each throws synchronously. Extends the `handle-validation.test.ts` pattern. +3. **Finding #3 — docs only.** No automated test; verified by inspection. + +## Verification + +- `cd native-lib/node && npm run build:addon` clean (no new warnings in the touched C regions); `npm run build` (tsc) clean. +- `npm test` green: current baseline **873 passed / 59 skipped / 0 failed**, plus the new regression tests. +- `git diff --check`. + +## Global Constraints + +- Node-binding-only. Never touch `native-lib/python/**`. +- Never touch the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`) or `ScriptRuntime.getInstance()`. +- Handle width stays C `long long` everywhere. +- Errors for run/streaming/transform APIs surface as **resolved** JSON string values or a synchronous `napi_throw_error` at admission / argument validation — never `napi_reject_deferred` (absent from addon.c; do not introduce). +- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread. +- All shared C state (`g_initialized`, `g_active_ops`, `g_teardown_state`, `g_teardown_cancelled`, `g_ref_count`) is read/written only under `g_mutex`. (The cheap top-of-function `!g_initialized` fast-path read is a benign optimization; the authoritative check is under the lock.) +- Preserve every round-1..6 fix: coalesced `cleanup()`, the JS three-state lifecycle machine, the `TEARDOWN_*` state machine and `napi_initialize` adoption path, the worker-thread `g_active_ops` decrement, the streaming/transform atomic admission blocks, the round-6 handle-read validations, guarded cross-thread `destroyEngine`, N-API allocation checks in `teardown_waiter_create`, the enqueue-failure waiter free. +- Node vitest baseline **873 passed / 59 skipped / 0 failed** — every task leaves the suite green. + +## Rejected Alternatives + +- **Finding #1 — reserve early (top of function) like streaming/transform.** Rejected: the string `malloc`s and argument extraction don't touch the isolate, so an early reservation would force the OOM/allocation-failure path to also unwind `g_active_ops`, adding a third unwind site for no safety benefit. Late reservation (just before attach) spans exactly the isolate-touching window with two unwind sites. +- **Finding #2 — `napi_throw_type_error` (TypeError).** Considered because the review says "JavaScript type error" and TypeError is the N-API convention for wrong-type args. Rejected in favor of `napi_throw_error` (generic Error) for consistency with every existing throw in addon.c; the message text conveys the type problem. (User decision.) +- **Finding #2 — blanket-audit every `napi_*` call in addon.c.** Rejected as scope creep (YAGNI). Sweep the conversions in the FFI-facing entrypoints — the defect class the review names — not unrelated N-API calls. +- **Finding #1 — only fix the exact cited lines without sweeping `run()`'s siblings.** Rejected: this is the very habit that produced the round-N-finds-the-sibling recurrence. Round 7 covers both defect classes uniformly. From 7a32f6f74907da309790615c9110bf74e666ee0f Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 12:41:28 -0300 Subject: [PATCH 51/63] fix(node): reserve g_active_ops across run() isolate window (round-7 #1) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 33 ++++++- .../tests/integration/run-admission.test.ts | 93 +++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 native-lib/node/tests/integration/run-admission.test.ts diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 0355927..40130ab 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1518,8 +1518,34 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) napi_get_value_string_utf8(env, argv[1], script, script_len + 1, NULL); napi_get_value_string_utf8(env, argv[2], inputs, inputs_len + 1, NULL); + // Round-7 #1: reserve an active op across the isolate-touching window + // (attach -> run -> detach) so a concurrent Worker's last cleanup() + // (napi_cleanup Case 4) cannot observe g_active_ops == 0 and tear down + // g_isolate while this synchronous op is attaching to or executing in it. + // Reserve LATE (here, not at the top): the malloc/arg-extraction above do + // not touch the isolate, so the reservation only needs to span attach.. + // detach -- giving exactly two unwind sites (attach-failure and normal + // completion) instead of also unwinding the OOM path. Rejecting on + // g_teardown_state != TEARDOWN_NONE also refuses to start once a teardown + // is queued/underway. run() is fully synchronous on the JS thread, so the + // reserve and release both happen inline (no worker thread). + uv_mutex_lock(&g_mutex); + if (!g_initialized || g_teardown_state != TEARDOWN_NONE) { + uv_mutex_unlock(&g_mutex); + free(script); free(inputs); + napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); + return NULL; + } + g_active_ops++; + uv_mutex_unlock(&g_mutex); + void* thread = NULL; - if (fn_attach_thread(g_isolate, &thread) != 0) { free(script); free(inputs); napi_throw_error(env, NULL, "Failed to attach thread"); return NULL; } + if (fn_attach_thread(g_isolate, &thread) != 0) { + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + free(script); free(inputs); + napi_throw_error(env, NULL, "Failed to attach thread"); + return NULL; + } char* result = (char*)fn_run_script_engine(thread, handle, script, inputs); @@ -1533,6 +1559,11 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) fn_detach_thread(thread); free(script); free(inputs); + // Release the op reservation now that no GraalVM-attached thread remains + // for this call. Broadcast so a teardown_waiter_thread_fn blocked on + // g_active_ops > 0 re-checks and can proceed. + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_value out; if (result_copy) { napi_create_string_utf8(env, result_copy, NAPI_AUTO_LENGTH, &out); free(result_copy); } else { napi_create_string_utf8(env, "", 0, &out); } diff --git a/native-lib/node/tests/integration/run-admission.test.ts b/native-lib/node/tests/integration/run-admission.test.ts new file mode 100644 index 0000000..88dea6a --- /dev/null +++ b/native-lib/node/tests/integration/run-admission.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import * as ffi from "../../src/ffi"; +import { findLibrary, buildInputsJson } from "../../src/utils"; + +// Round-7 finding #1: the synchronous napi_run_script_engine touched the +// isolate (fn_attach_thread -> fn_run_script_engine -> fn_detach_thread) with +// only a top-of-function !g_initialized fast-path and NO g_active_ops +// reservation under g_mutex. A second Worker's last cleanup() (napi_cleanup +// Case 4) could observe g_active_ops == 0 and tear down g_isolate while this +// op was attaching/executing -- a use-after-free. +// +// The genuine cross-Worker TOCTOU is not reliably forceable from single-thread +// JS (same limitation the round-6 #2 admission-during-teardown test documents: +// re-init would trigger the adoption path and cancel the pending teardown +// before the admission check runs). What we CAN assert deterministically is +// the admission-rejection path the fix introduces: once a teardown is pending +// (g_teardown_state != TEARDOWN_NONE), a freshly started run() is rejected with +// a synchronous throw rather than attaching to an isolate a concurrent teardown +// could pull out from under it. The C-level reasoning -- check-and-reserve is +// now one atomic critical section on the run() path -- is what covers the race +// itself. +// +// We drive the addon through the raw `ffi` module (not the module-level +// singleton) so the second op runs against the SAME still-live handle/isolate +// with no intervening ffi.initialize() call to trigger adoption. Calling +// ffi.cleanup() directly triggers napi_cleanup Case 5 and sets +// g_teardown_state = TEARDOWN_PENDING_WAIT synchronously, before its Promise is +// returned; the immediately-following ffi.runScriptEngine re-enters native code +// synchronously on the same callstack and deterministically observes it. +// +// Real addon, no mocking. +describe("run() admission rejected while teardown pending (round 7 #1)", () => { + it("a synchronous run() started during pending teardown throws, not attach to a dead isolate", async () => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + + // Keep one op in flight so the ref release becomes Case 5 (pending + // teardown) rather than Case 4 (immediate teardown): use a transform whose + // read callback triggers cleanup() and then attempts a run() on the same + // handle, all on the same synchronous callstack. + let cleanupPromise: Promise | undefined; + let runErr: unknown; + let ran = false; + + let firstRead = true; + const readCb = (_bufSize: number): Buffer | null => { + if (firstRead) { + firstRead = false; + // Case 5: last ref release with g_active_ops > 0 -> TEARDOWN_PENDING_WAIT, + // set synchronously before this returns. Not awaited. + cleanupPromise = ffi.cleanup(); + // Synchronous run() on the same still-live handle while teardown is + // pending. Fixed code rejects admission with a synchronous throw + // (g_teardown_state != TEARDOWN_NONE). Must be caught here -- it is a + // synchronous throw, not a rejected promise. Do not let it escape the + // native read-callback body. + try { + ffi.runScriptEngine( + handle, + "%dw 2.0\noutput application/json\n---\n1 + 1", + buildInputsJson({}) + ); + ran = true; + } catch (e) { + runErr = e; + } + return Buffer.from("[1,2,3]"); + } + return null; + }; + + const writeCb = (_chunk: Buffer) => {}; + + const resultRaw = await ffi.runScriptTransformEngine( + handle, + "output application/json\n---\npayload", + "{}", + "payload", + "application/json", + null, + readCb, + writeCb + ); + const result = JSON.parse(resultRaw); + expect(result.success).toBe(true); + + await cleanupPromise; + + // run() started while teardown was pending must have been rejected. + expect(runErr).toBeTruthy(); + expect(ran).toBe(false); + }, 20000); +}); From ff63e3512aafee214749494ef8c79e99c3522a90 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 14:11:18 -0300 Subject: [PATCH 52/63] fix(node): admit adopted-but-cancelled isolates at all 3 FFI admission sites The round-7 #1 admission predicate (!g_initialized || g_teardown_state != TEARDOWN_NONE), copied verbatim into napi_run_script_streaming_engine, napi_run_script_transform_engine, and napi_run_script_engine, ignored g_teardown_cancelled. napi_initialize's adoption branch sets g_teardown_cancelled = true on a still-live PENDING_WAIT isolate but does not reset g_teardown_state (only the async waiter thread does later), so a run()/runStreaming()/runTransform() call landing in that window wrongly threw "Not initialized" against a validly adopted, fully live isolate -- the intermittent teardown-deadlock.test.ts:104 flake. Relax all three predicates to !g_initialized || (g_teardown_state != TEARDOWN_NONE && !g_teardown_cancelled), which admits the adopted-but-cancelled PENDING_WAIT case while still rejecting a genuine (non-cancelled) queued teardown or a committed TEARING_DOWN. Verified: teardown-deadlock.test.ts is green across 5/5 full-integration and 5/5 full-suite (874/59/0) runs post-fix, while run-admission.test.ts and admission-during-teardown.test.ts (which exercise the genuine, non-adopted rejection path) continue to pass. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 40130ab..0a5d552 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -737,9 +737,15 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i // shape) let a second Worker's napi_cleanup Case-4 tear the isolate down in // the gap, so a freshly spawned worker attached to a dead isolate (round-6 // #2). Rejecting on g_teardown_state != TEARDOWN_NONE also refuses new ops - // once a teardown is queued/underway. + // once a teardown is queued/underway. Admit an ADOPTED isolate: + // napi_initialize's adoption branch sets g_teardown_cancelled = true on a + // still-live PENDING_WAIT isolate but does not reset g_teardown_state (only + // the async waiter does), so a merely-cancelled teardown must not reject + // here -- otherwise a valid post-adoption op throws "Not initialized". A + // genuine (non-cancelled) PENDING_WAIT or a committed TEARING_DOWN still + // rejects. uv_mutex_lock(&g_mutex); - if (!g_initialized || g_teardown_state != TEARDOWN_NONE) { + if (!g_initialized || (g_teardown_state != TEARDOWN_NONE && !g_teardown_cancelled)) { uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; @@ -1130,8 +1136,14 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i // Atomic admission (see napi_run_script_streaming_engine for the full // rationale, round-6 #2): check lifecycle + reserve g_active_ops in one // critical section, before any work/tsfn/promise/bridge is committed. + // Admit an ADOPTED isolate: napi_initialize's adoption branch sets + // g_teardown_cancelled = true on a still-live PENDING_WAIT isolate but does + // not reset g_teardown_state (only the async waiter does), so a + // merely-cancelled teardown must not reject here -- otherwise a valid + // post-adoption op throws "Not initialized". A genuine (non-cancelled) + // PENDING_WAIT or a committed TEARING_DOWN still rejects. uv_mutex_lock(&g_mutex); - if (!g_initialized || g_teardown_state != TEARDOWN_NONE) { + if (!g_initialized || (g_teardown_state != TEARDOWN_NONE && !g_teardown_cancelled)) { uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; @@ -1528,9 +1540,15 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) // completion) instead of also unwinding the OOM path. Rejecting on // g_teardown_state != TEARDOWN_NONE also refuses to start once a teardown // is queued/underway. run() is fully synchronous on the JS thread, so the - // reserve and release both happen inline (no worker thread). + // reserve and release both happen inline (no worker thread). Admit an + // ADOPTED isolate: napi_initialize's adoption branch sets + // g_teardown_cancelled = true on a still-live PENDING_WAIT isolate but + // does not reset g_teardown_state (only the async waiter does), so a + // merely-cancelled teardown must not reject here -- otherwise a valid + // post-adoption op throws "Not initialized". A genuine (non-cancelled) + // PENDING_WAIT or a committed TEARING_DOWN still rejects. uv_mutex_lock(&g_mutex); - if (!g_initialized || g_teardown_state != TEARDOWN_NONE) { + if (!g_initialized || (g_teardown_state != TEARDOWN_NONE && !g_teardown_cancelled)) { uv_mutex_unlock(&g_mutex); free(script); free(inputs); napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); From d95eea09de54c910efcda04ddb53964ed0bdd678 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 14:18:17 -0300 Subject: [PATCH 53/63] fix(node): check every napi_get_value_* status in FFI entrypoints (round-7 #2) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 83 ++++++++++++++----- .../integration/malformed-inputs.test.ts | 70 ++++++++++++++++ 2 files changed, 133 insertions(+), 20 deletions(-) create mode 100644 native-lib/node/tests/integration/malformed-inputs.test.ts diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 0a5d552..6f996cb 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -753,16 +753,31 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i g_active_ops++; uv_mutex_unlock(&g_mutex); + // Conversions run after the admission reservation above, so any throw here + // must release g_active_ops before returning (round-7 #2). size_t script_len, inputs_len; - napi_get_value_string_utf8(env, argv[1], NULL, 0, &script_len); - napi_get_value_string_utf8(env, argv[2], NULL, 0, &inputs_len); + if (napi_get_value_string_utf8(env, argv[1], NULL, 0, &script_len) != napi_ok) { + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptStreamingEngine: script must be a string"); + return NULL; + } + if (napi_get_value_string_utf8(env, argv[2], NULL, 0, &inputs_len) != napi_ok) { + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptStreamingEngine: inputsJson must be a string"); + return NULL; + } struct streaming_work* w = calloc(1, sizeof(struct streaming_work)); w->handle = (long long)handle64; w->script = malloc(script_len + 1); w->inputs_json = malloc(inputs_len + 1); - napi_get_value_string_utf8(env, argv[1], w->script, script_len + 1, NULL); - napi_get_value_string_utf8(env, argv[2], w->inputs_json, inputs_len + 1, NULL); + if (napi_get_value_string_utf8(env, argv[1], w->script, script_len + 1, NULL) != napi_ok || + napi_get_value_string_utf8(env, argv[2], w->inputs_json, inputs_len + 1, NULL) != napi_ok) { + free(w->script); free(w->inputs_json); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptStreamingEngine: failed to read script/inputsJson"); + return NULL; + } napi_value resource_name; napi_create_string_utf8(env, "dwStreaming", NAPI_AUTO_LENGTH, &resource_name); @@ -1151,35 +1166,49 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i g_active_ops++; uv_mutex_unlock(&g_mutex); + // Conversions run after the admission reservation above, so any throw here + // must free the partially-populated work struct AND release g_active_ops + // before returning (round-7 #2). calloc zeroed w, so free() on an unset + // field pointer is a safe free(NULL). TRANSFORM_FAIL centralizes the + // unwind. struct transform_work* w = calloc(1, sizeof(struct transform_work)); size_t len; w->handle = (long long)handle64; - napi_get_value_string_utf8(env, argv[1], NULL, 0, &len); + #define TRANSFORM_FAIL(msg) do { \ + free(w->script); free(w->inputs_json); free(w->input_name); \ + free(w->input_mime_type); free(w->input_charset); free(w); \ + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); \ + napi_throw_error(env, NULL, (msg)); \ + return NULL; \ + } while (0) + + if (napi_get_value_string_utf8(env, argv[1], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: script must be a string"); w->script = malloc(len + 1); - napi_get_value_string_utf8(env, argv[1], w->script, len + 1, NULL); + if (napi_get_value_string_utf8(env, argv[1], w->script, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read script"); - napi_get_value_string_utf8(env, argv[2], NULL, 0, &len); + if (napi_get_value_string_utf8(env, argv[2], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: inputsJson must be a string"); w->inputs_json = malloc(len + 1); - napi_get_value_string_utf8(env, argv[2], w->inputs_json, len + 1, NULL); + if (napi_get_value_string_utf8(env, argv[2], w->inputs_json, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read inputsJson"); - napi_get_value_string_utf8(env, argv[3], NULL, 0, &len); + if (napi_get_value_string_utf8(env, argv[3], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: inputName must be a string"); w->input_name = malloc(len + 1); - napi_get_value_string_utf8(env, argv[3], w->input_name, len + 1, NULL); + if (napi_get_value_string_utf8(env, argv[3], w->input_name, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read inputName"); - napi_get_value_string_utf8(env, argv[4], NULL, 0, &len); + if (napi_get_value_string_utf8(env, argv[4], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: inputMimeType must be a string"); w->input_mime_type = malloc(len + 1); - napi_get_value_string_utf8(env, argv[4], w->input_mime_type, len + 1, NULL); + if (napi_get_value_string_utf8(env, argv[4], w->input_mime_type, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read inputMimeType"); napi_valuetype type; - napi_typeof(env, argv[5], &type); + if (napi_typeof(env, argv[5], &type) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: invalid inputCharset argument"); if (type == napi_string) { - napi_get_value_string_utf8(env, argv[5], NULL, 0, &len); + if (napi_get_value_string_utf8(env, argv[5], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: inputCharset must be a string"); w->input_charset = malloc(len + 1); - napi_get_value_string_utf8(env, argv[5], w->input_charset, len + 1, NULL); + if (napi_get_value_string_utf8(env, argv[5], w->input_charset, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read inputCharset"); } else { w->input_charset = NULL; } + #undef TRANSFORM_FAIL napi_value resource_name; napi_create_string_utf8(env, "dwTransform", NAPI_AUTO_LENGTH, &resource_name); @@ -1450,7 +1479,11 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value argv[1]; napi_get_cb_info(env, info, &argc, argv, NULL, NULL); if (argc < 1) { napi_throw_error(env, NULL, "destroyEngine requires (handle)"); return NULL; } - int64_t handle64; napi_get_value_int64(env, argv[0], &handle64); + int64_t handle64; + if (napi_get_value_int64(env, argv[0], &handle64) != napi_ok) { + napi_throw_error(env, NULL, "destroyEngine: handle must be an integer"); + return NULL; + } long long handle = (long long)handle64; // F2: a resolver-backed engine's bridge owns thread-affine N-API state -- @@ -1522,13 +1555,23 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) long long handle = (long long)handle64; size_t script_len, inputs_len; - napi_get_value_string_utf8(env, argv[1], NULL, 0, &script_len); - napi_get_value_string_utf8(env, argv[2], NULL, 0, &inputs_len); + if (napi_get_value_string_utf8(env, argv[1], NULL, 0, &script_len) != napi_ok) { + napi_throw_error(env, NULL, "runScriptEngine: script must be a string"); + return NULL; + } + if (napi_get_value_string_utf8(env, argv[2], NULL, 0, &inputs_len) != napi_ok) { + napi_throw_error(env, NULL, "runScriptEngine: inputsJson must be a string"); + return NULL; + } char* script = (char*)malloc(script_len + 1); char* inputs = (char*)malloc(inputs_len + 1); if (script == NULL || inputs == NULL) { free(script); free(inputs); napi_throw_error(env, NULL, "OOM"); return NULL; } - napi_get_value_string_utf8(env, argv[1], script, script_len + 1, NULL); - napi_get_value_string_utf8(env, argv[2], inputs, inputs_len + 1, NULL); + if (napi_get_value_string_utf8(env, argv[1], script, script_len + 1, NULL) != napi_ok || + napi_get_value_string_utf8(env, argv[2], inputs, inputs_len + 1, NULL) != napi_ok) { + free(script); free(inputs); + napi_throw_error(env, NULL, "runScriptEngine: failed to read script/inputsJson"); + return NULL; + } // Round-7 #1: reserve an active op across the isolate-touching window // (attach -> run -> detach) so a concurrent Worker's last cleanup() diff --git a/native-lib/node/tests/integration/malformed-inputs.test.ts b/native-lib/node/tests/integration/malformed-inputs.test.ts new file mode 100644 index 0000000..3deae4c --- /dev/null +++ b/native-lib/node/tests/integration/malformed-inputs.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as ffi from "../../src/ffi"; +import { findLibrary, buildInputsJson } from "../../src/utils"; + +// Round-7 finding #2 (whole-class sweep): every FFI-facing entrypoint must +// check the status of each napi_get_value_* conversion and throw before using +// the converted value. Pre-fix, non-string script/inputs left *_len +// uninitialized before malloc(len+1) and the buffer write, and destroyEngine +// used an indeterminate handle64 from an ignored napi_get_value_int64. +// +// Driven through the raw `ffi` boundary (the DataWeave TS class always passes +// well-typed values), so these calls exercise the C conversion checks directly. +// The addon globals are process-wide C statics -- balance every initialize() +// with a cleanup() so this file does not leak a ref-count into siblings. +// +// Real addon, no mocking. +describe("malformed raw-ffi inputs throw (round 7 #2)", () => { + afterEach(async () => { + await ffi.cleanup(); + }); + + it("destroyEngine throws on a non-integer handle", () => { + ffi.initialize(findLibrary()); + expect(() => ffi.destroyEngine({} as unknown as number)).toThrow(); + }); + + it("runScriptEngine throws on non-string script/inputs", () => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + expect(() => + ffi.runScriptEngine(handle, {} as unknown as string, buildInputsJson({})) + ).toThrow(); + expect(() => + ffi.runScriptEngine(handle, "%dw 2.0\n---\n1", {} as unknown as string) + ).toThrow(); + ffi.destroyEngine(handle); + }); + + it("runScriptStreamingEngine throws on non-string script/inputs", () => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + expect(() => + ffi.runScriptStreamingEngine( + handle, + {} as unknown as string, + buildInputsJson({}), + () => {} + ) + ).toThrow(); + ffi.destroyEngine(handle); + }); + + it("runScriptTransformEngine throws on non-string script", () => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + expect(() => + ffi.runScriptTransformEngine( + handle, + {} as unknown as string, + "{}", + "payload", + "application/json", + null, + () => null, + () => {} + ) + ).toThrow(); + ffi.destroyEngine(handle); + }); +}); From 36221791072179c2bd1f91fdf4e4697986752fa7 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 14:23:39 -0300 Subject: [PATCH 54/63] docs(node): await async cleanup() in external-modules examples (round-7 #3) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/docs/external-modules.md | 32 +++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/native-lib/node/docs/external-modules.md b/native-lib/node/docs/external-modules.md index 6aa65c1..734b8a8 100644 --- a/native-lib/node/docs/external-modules.md +++ b/native-lib/node/docs/external-modules.md @@ -181,21 +181,25 @@ same process — each one only ever resolves its own modules, with no cross-talk between instances: ```typescript -const dw1 = new DataWeave({ - resolveModule: modulesFromMap({ 'a.dwl': '...' }), -}); -dw1.initialize(); - -const dw2 = new DataWeave({ - resolveModule: modulesFromMap({ 'b.dwl': '...' }), -}); -dw2.initialize(); +async function example() { + const dw1 = new DataWeave({ + resolveModule: modulesFromMap({ 'a.dwl': '...' }), + }); + dw1.initialize(); -dw1.run('...'); // Only 'a.dwl' is available to dw1 -dw2.run('...'); // Only 'b.dwl' is available to dw2 — dw1's modules are not visible here + const dw2 = new DataWeave({ + resolveModule: modulesFromMap({ 'b.dwl': '...' }), + }); + dw2.initialize(); -dw1.cleanup(); -dw2.cleanup(); + try { + dw1.run('...'); // Only 'a.dwl' is available to dw1 + dw2.run('...'); // Only 'b.dwl' is available to dw2 — dw1's modules are not visible here + } finally { + await dw1.cleanup(); + await dw2.cleanup(); + } +} ``` **`cleanup()` is required for every instance.** Each `DataWeave` instance's @@ -307,7 +311,7 @@ async function main() { console.error('Error:', result.error); } } finally { - dw.cleanup(); + await dw.cleanup(); } } From dedfe31e96ef8cf921c990ee5ae78b98f118cdc5 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 16:18:21 -0300 Subject: [PATCH 55/63] docs: design spec for round-8 OOM-safe streaming/transform setup (W-23692110) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...m-safe-streaming-transform-setup-design.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-18-oom-safe-streaming-transform-setup-design.md diff --git a/docs/superpowers/specs/2026-08-18-oom-safe-streaming-transform-setup-design.md b/docs/superpowers/specs/2026-08-18-oom-safe-streaming-transform-setup-design.md new file mode 100644 index 0000000..855f9fd --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-oom-safe-streaming-transform-setup-design.md @@ -0,0 +1,127 @@ +# OOM-Safe Allocation in Streaming/Transform Setup — Round 8 (W-23692110) + +**Status:** Design approved, ready for planning. + +**Source review:** `docs/pr-157-follow-up-andy-code-review-8.md` (one finding, P1, verified against live source at commit `3622179`, the round-7 tip). + +**Scope:** `native-lib/node` only — `src/addon.c`, functions `napi_run_script_streaming_engine` and `napi_run_script_transform_engine`. Do **not** touch `native-lib/python/**` or the legacy singleton `dw_napi_run_script`. + +## Problem + +The eighth "andy" follow-up review of PR #157 raised one finding (escalated to P1). It was verified against live source and is real. + +**Finding (P1) — OOM in streaming or transform setup can crash the process and strand active-operation state.** + +Both `napi_run_script_streaming_engine` (addon.c:770-780) and `napi_run_script_transform_engine` (addon.c:1174-1207) reserve `g_active_ops` (streaming at :753, transform at :1166) and then, **after** the reservation, allocate a work struct and its string buffers and immediately use them without checking for allocation failure: + +- Streaming: `struct streaming_work* w = calloc(...)` (:770) is dereferenced at `w->handle` (:771); `w->script = malloc(...)` / `w->inputs_json = malloc(...)` (:772-773) are passed to `napi_get_value_string_utf8` (:774-775) with no NULL check. +- Transform: `struct transform_work* w = calloc(...)` (:1174) is dereferenced at `w->handle` (:1176); each `w->field = malloc(len + 1)` (:1187, :1191, :1195, :1199, :1206) is passed to the fill `napi_get_value_string_utf8` with no NULL check. + +If an allocation fails, the NULL dereference is a SIGSEGV that crashes the host Node process (not a catchable JS error). Because both sites sit *after* the `g_active_ops` reservation, the reservation is also never released — though in practice the segfault terminates the process first, so the crash is the dominant harm; releasing the reservation is the correct behavior on the (theoretical) non-crashing path and keeps the invariant clean. + +### History / context (not a new defect) + +This is the same gap logged as item 6 in `docs/ga-cleanup-backlog.md` and flagged as Minor/deferred by both the round-7 task review and the round-7 final whole-branch review (OOM-only, out of scope for round 7's conversion-*status* sweep). The eighth review escalates it from Minor to P1. It is a known deferred item re-prioritized, not a newly discovered class. + +The fix pattern already exists in the same file: `napi_run_script_engine` checks its `malloc` results and throws `"OOM"` (addon.c ~1568). Streaming/transform simply never received the same treatment. `dw_napi_run_script` (the legacy singleton) has the identical gap but is off-limits by the Global Constraints. + +## Design + +Add allocation-failure checks at both sites, mirroring the existing `napi_run_script_engine` OOM pattern, so **no allocation result is dereferenced before its NULL check, and no OOM path leaves `g_active_ops` reserved or a partial `w` leaked.** + +### 1. Streaming (`napi_run_script_streaming_engine`) + +Immediately after `struct streaming_work* w = calloc(1, sizeof(struct streaming_work));` and **before** `w->handle = ...`, check `w == NULL`: + +```c +struct streaming_work* w = calloc(1, sizeof(struct streaming_work)); +if (w == NULL) { + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "OOM"); + return NULL; +} +w->handle = (long long)handle64; +w->script = malloc(script_len + 1); +w->inputs_json = malloc(inputs_len + 1); +if (w->script == NULL || w->inputs_json == NULL) { + free(w->script); free(w->inputs_json); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "OOM"); + return NULL; +} +if (napi_get_value_string_utf8(env, argv[1], w->script, script_len + 1, NULL) != napi_ok || + napi_get_value_string_utf8(env, argv[2], w->inputs_json, inputs_len + 1, NULL) != napi_ok) { + free(w->script); free(w->inputs_json); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptStreamingEngine: failed to read script/inputsJson"); + return NULL; +} +``` + +- The `w == NULL` branch must **not** free `w->script`/`w->inputs_json` (w is NULL — those dereferences would themselves crash); it frees nothing and unwinds. +- The combined `w->script == NULL || w->inputs_json == NULL` guard reuses the existing free-set (`free(w->script); free(w->inputs_json); free(w);` — all `free(NULL)`-safe since `calloc` zeroed `w` and a failed `malloc` returns NULL) and the verbatim `g_active_ops` unwind, sitting **before** the existing fill-status check. + +### 2. Transform (`napi_run_script_transform_engine`) + +Add a `w == NULL` check immediately after `calloc` and before `w->handle`, then a NULL check after each `malloc` via the existing `TRANSFORM_FAIL` macro (which already frees all five char* fields + `w` and unwinds `g_active_ops`): + +```c +struct transform_work* w = calloc(1, sizeof(struct transform_work)); +if (w == NULL) { + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "OOM"); + return NULL; +} +size_t len; +w->handle = (long long)handle64; + +#define TRANSFORM_FAIL(msg) do { ... } while (0) // unchanged + +if (napi_get_value_string_utf8(env, argv[1], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: script must be a string"); +w->script = malloc(len + 1); +if (w->script == NULL) TRANSFORM_FAIL("OOM"); +if (napi_get_value_string_utf8(env, argv[1], w->script, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read script"); +``` + +…and the same `if (w->field == NULL) TRANSFORM_FAIL("OOM");` line after each of `w->inputs_json`, `w->input_name`, `w->input_mime_type`, and `w->input_charset` mallocs, placed **before** the corresponding fill `napi_get_value_string_utf8`. + +- The `w == NULL` branch is a standalone unwind (it cannot use `TRANSFORM_FAIL`, which dereferences `w`). +- Each per-field NULL check uses `TRANSFORM_FAIL("OOM")`; because `calloc` zeroed `w` and any not-yet-reached field is still NULL, the macro's free-set is `free(NULL)`-safe for the unreached fields and frees the successfully-allocated ones exactly once. + +### 3. Error message + +Bare `napi_throw_error(env, NULL, "OOM")` for every allocation-failure throw, identical to `napi_run_script_engine`'s existing pattern. (User decision — maximum consistency with the current file over the descriptive per-entrypoint style of the conversion-status throws.) The existing conversion-status and read-failure messages in these functions are unchanged. + +### 4. Testing + +`malloc`/`calloc` failure is not deterministically forceable from JS/vitest (no allocator-injection hook at the addon boundary), the same limitation documented for the round-6/7 cross-Worker TOCTOU. So this round adds **no new runtime test**; coverage is: + +- C-level code reasoning: every allocation result is NULL-checked before any dereference; every OOM path unwinds `g_active_ops` with the verbatim pattern and frees any partial `w` with no double-free. +- The full Node vitest suite stays green at **878 passed / 59 skipped / 0 failed** with no regression (the OOM branches are unreachable under normal allocation, so existing behavior is unchanged). + +## Verification + +- `cd native-lib/node && npm run build:addon` clean (no new warnings in the touched regions); `npm run build` (tsc) clean. +- `npm test` green: **878 passed / 59 skipped / 0 failed** (unchanged — no new test, no regression). +- `git diff --check`. + +## Global Constraints + +- Node-binding-only. Never touch `native-lib/python/**`. +- Never touch the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`), `dw_napi_run_script`, or `ScriptRuntime.getInstance()`. +- Handle width stays C `long long` everywhere. +- Errors for run/streaming/transform APIs surface as **resolved** JSON string values or a synchronous `napi_throw_error` at admission / argument validation / allocation failure — never `napi_reject_deferred` (absent from addon.c; do not introduce). +- Allocation-failure rejections use `napi_throw_error` (generic Error) with the bare message `"OOM"`, matching `napi_run_script_engine`. +- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread. +- All shared C state (`g_initialized`, `g_active_ops`, `g_teardown_state`, `g_teardown_cancelled`, `g_ref_count`) is read/written only under `g_mutex`. (The cheap top-of-function `!g_initialized` fast-path read is a benign optimization.) +- The `g_active_ops` release pattern is EXACTLY, verbatim: `uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex);` (matches the worker-thread decrement and every round-6/7 unwind site). +- Preserve every round-1..7 fix: coalesced `cleanup()`, the JS three-state lifecycle machine, the `TEARDOWN_*` state machine and `napi_initialize` adoption path (including round-7's `g_teardown_cancelled` admission carve-out), the worker-thread `g_active_ops` decrement, the streaming/transform atomic admission blocks, the round-6 handle-read validations, the round-7 conversion-status checks, guarded cross-thread `destroyEngine`, N-API allocation checks in `teardown_waiter_create`, the enqueue-failure waiter free. +- Node vitest baseline **878 passed / 59 skipped / 0 failed** — every task leaves the suite green. + +## Rejected Alternatives + +- **Descriptive per-entrypoint OOM messages** (`"runScriptStreamingEngine: out of memory"`). Considered for parity with the round-7 conversion-check message style in these same functions. Rejected in favor of bare `"OOM"` for consistency with `napi_run_script_engine`'s existing allocation-failure throw. (User decision.) +- **Abort/`ENOMEM`-style hard failure instead of a throwable error.** Rejected: a library must not take down the host process on a recoverable condition; surfacing a catchable N-API error is the contract used everywhere else in these entrypoints. +- **Also fixing `dw_napi_run_script`'s identical gap.** Rejected as out of scope — it is a forbidden legacy singleton entrypoint per the Global Constraints. Noted separately; not part of this round. +- **Adding a fault-injection test hook to force `malloc` failure.** Rejected as scope creep / test-only production surface (YAGNI). The OOM branches are covered by code reasoning, consistent with how the round-6/7 non-forceable paths were handled. +- **Retrofitting the whole file's allocations.** Rejected — this round fixes the two P1 sites the review names; a blanket allocation audit is out of scope (the same class-vs-blanket boundary drawn in round 7). From 516311e6442a327cdc5fc08c0e37ed17eb0ec64d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 16:23:32 -0300 Subject: [PATCH 56/63] fix(node): NULL-check allocations in streaming/transform setup (round-8 P1) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 6f996cb..49192f7 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -767,10 +767,26 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i return NULL; } + // OOM safety (round-8): every allocation is NULL-checked before it is + // dereferenced, and every failure path releases the g_active_ops reservation + // taken above (mirroring napi_run_script_engine's "OOM" throw). Without this + // an allocation failure segfaults the host process AND strands g_active_ops. struct streaming_work* w = calloc(1, sizeof(struct streaming_work)); + if (w == NULL) { + // w is NULL -- do not touch w->script/w->inputs_json here. + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "OOM"); + return NULL; + } w->handle = (long long)handle64; w->script = malloc(script_len + 1); w->inputs_json = malloc(inputs_len + 1); + if (w->script == NULL || w->inputs_json == NULL) { + free(w->script); free(w->inputs_json); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "OOM"); + return NULL; + } if (napi_get_value_string_utf8(env, argv[1], w->script, script_len + 1, NULL) != napi_ok || napi_get_value_string_utf8(env, argv[2], w->inputs_json, inputs_len + 1, NULL) != napi_ok) { free(w->script); free(w->inputs_json); free(w); @@ -1171,7 +1187,16 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i // before returning (round-7 #2). calloc zeroed w, so free() on an unset // field pointer is a safe free(NULL). TRANSFORM_FAIL centralizes the // unwind. + // OOM safety (round-8): NULL-check the work struct before dereferencing it, + // releasing the g_active_ops reservation taken above. The per-field malloc + // checks below reuse TRANSFORM_FAIL (which frees all fields + w and unwinds); + // this standalone branch cannot use it (the macro dereferences w). struct transform_work* w = calloc(1, sizeof(struct transform_work)); + if (w == NULL) { + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "OOM"); + return NULL; + } size_t len; w->handle = (long long)handle64; @@ -1185,18 +1210,22 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i if (napi_get_value_string_utf8(env, argv[1], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: script must be a string"); w->script = malloc(len + 1); + if (w->script == NULL) TRANSFORM_FAIL("OOM"); if (napi_get_value_string_utf8(env, argv[1], w->script, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read script"); if (napi_get_value_string_utf8(env, argv[2], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: inputsJson must be a string"); w->inputs_json = malloc(len + 1); + if (w->inputs_json == NULL) TRANSFORM_FAIL("OOM"); if (napi_get_value_string_utf8(env, argv[2], w->inputs_json, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read inputsJson"); if (napi_get_value_string_utf8(env, argv[3], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: inputName must be a string"); w->input_name = malloc(len + 1); + if (w->input_name == NULL) TRANSFORM_FAIL("OOM"); if (napi_get_value_string_utf8(env, argv[3], w->input_name, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read inputName"); if (napi_get_value_string_utf8(env, argv[4], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: inputMimeType must be a string"); w->input_mime_type = malloc(len + 1); + if (w->input_mime_type == NULL) TRANSFORM_FAIL("OOM"); if (napi_get_value_string_utf8(env, argv[4], w->input_mime_type, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read inputMimeType"); napi_valuetype type; @@ -1204,6 +1233,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i if (type == napi_string) { if (napi_get_value_string_utf8(env, argv[5], NULL, 0, &len) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: inputCharset must be a string"); w->input_charset = malloc(len + 1); + if (w->input_charset == NULL) TRANSFORM_FAIL("OOM"); if (napi_get_value_string_utf8(env, argv[5], w->input_charset, len + 1, NULL) != napi_ok) TRANSFORM_FAIL("runScriptTransformEngine: failed to read inputCharset"); } else { w->input_charset = NULL; From 05f8b318049afd60a8ca0cd24db7b520b6626149 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 16:27:34 -0300 Subject: [PATCH 57/63] docs: mark ga-cleanup backlog item 6 resolved by round-8 OOM fix (W-23692110) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ga-cleanup-backlog.md | 73 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/ga-cleanup-backlog.md diff --git a/docs/ga-cleanup-backlog.md b/docs/ga-cleanup-backlog.md new file mode 100644 index 0000000..9322c0d --- /dev/null +++ b/docs/ga-cleanup-backlog.md @@ -0,0 +1,73 @@ +# GA Cleanup Backlog + +Non-blocking cleanup/refactor items identified while working on the multi-engine +Node binding (W-23692110, PR #157). None of these are required for that PR to +merge — tracked here to brainstorm and prioritize before GA, since pre-GA we +have no external ABI-stability commitment yet and more latitude to remove +legacy paths outright. + +## Node binding + +1. **Dead legacy `runScript` wrapper.** `native-lib/node/src/ffi.ts:6,44-46` + (`runScript`), the `"runScript"` N-API export at + `native-lib/node/src/addon.c:1234-1235`, and `dw_napi_run_script` itself + (`addon.c:382-...`) are unreferenced — the Node singleton now routes + through `createEngine()`/`runScriptEngine()` instead. Safe to delete from + the Node addon without touching the underlying C `run_script` symbol, + which Python still depends on. + +2. **Undocumented owner-thread constraint on `destroyEngine`.** + `native-lib/node/src/addon.c` (`napi_destroy_engine`) requires cleanup-hook + removal / `napi_ref` deletion to happen on the bridge's owner thread. Today + this is only implied by the general "don't share a `DataWeave` instance + across Workers" rule in the README. Add an explicit one-line code comment + stating the constraint directly on `napi_destroy_engine`. + +3. **Test clarity: near-tautological assertion.** + `native-lib/node/tests/integration/dataweave-resolver.test.ts` — the + cleanup-during-streaming regression test's `expect(settled).toBe(true)` + is near-tautological (the real protection is process survival, not the + value). Add a comment explaining that if this test is touched again. + +4. **Test tightening: throwing-resolver test.** Same file — the + throwing-resolver test only asserts `result.success === false`; could + additionally assert `result.error` is truthy for a slightly stronger + check. + +6. **~~Unchecked `malloc` before the fill `napi_get_value_string_utf8` in + streaming/transform.~~ RESOLVED (round 8, commit `516311e`).** The streaming + (`napi_run_script_streaming_engine`) and transform + (`napi_run_script_transform_engine`) entrypoints passed `calloc`/`malloc` + results straight to `w->handle` / the fill `napi_get_value_string_utf8` + without a NULL check, unlike `napi_run_script_engine`. On OOM this + segfaulted the host process (NULL deref) and stranded the `g_active_ops` + reservation. The eighth "andy" review + (`docs/pr-157-follow-up-andy-code-review-8.md`) escalated it Minor→P1, and + round 8 fixed both sites: every `calloc`/`malloc` is NULL-checked before any + dereference, each OOM path unwinds `g_active_ops` (verbatim pattern) and + frees any partial work struct, throwing bare `"OOM"` to match + `napi_run_script_engine`. Spec: + `docs/superpowers/specs/2026-08-18-oom-safe-streaming-transform-setup-design.md`. + Note: the identical gap in the legacy singleton `dw_napi_run_script` was + deliberately left (out of scope by the Global Constraints) — subsumed by + item 1's "delete the dead legacy wrapper". + +## Cross-binding / architecture + +5. **Retire the legacy `ScriptRuntime` singleton once Python adopts the + per-engine registry.** `native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java` + (`defaultInstance`, `getInstance()`) backs the legacy `run_script` / + `run_script_callback` / `run_script_input_output_callback` `@CEntryPoint`s + in `NativeLib.java`, called today only by the Python binding. These are + exported as part of `dwlib`'s public C ABI (`dwlib.h`), not just internal + plumbing — so removing them is a bigger call than deleting an internal TS + wrapper (item 1) and needs a deliberate decision, not just a "zero + internal callers" grep. + - Requires deciding whether Python migrates onto the same handle-keyed + registry the Node binding uses (possibly with a single implicit handle + if Python doesn't need multi-engine support), or keeps its own + singleton path indefinitely. + - Being pre-GA removes the "might break an external consumer of the C + ABI" concern, but this is still cross-binding work broader than the + Node-only scope of PR #157 — needs its own brainstorm/plan before + starting. From d320eb39236dd933d06841ea56f4f77f0d6cb956 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 17:16:52 -0300 Subject: [PATCH 58/63] =?UTF-8?q?docs:=20round-9=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20engine=20lifecycle=20&=20worker-OOM=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for the three findings in pr-157-follow-up-andy-code-review-9.md: - #1 (P1): defer fn_destroy_engine (registry removal) until an engine's admitted ops drain, by extending the per-engine record to ALL engines. - #2 (P2): worker/callback OOM -> terminal error result (static OOM JSON, write-cb returns -1, sentinel-malloc-NULL unwinds like the env-dead path). - #3 (P3): check napi_create_* status after the g_active_ops reservation in the streaming/transform entrypoints and unwind on failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...fecycle-and-worker-oom-hardening-design.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md diff --git a/docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md b/docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md new file mode 100644 index 0000000..4a898c8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md @@ -0,0 +1,112 @@ +# Engine Lifecycle & Worker-OOM Hardening — Round 9 (W-23692110) + +**Status:** Design approved, ready for planning. + +**Source review:** `docs/pr-157-follow-up-andy-code-review-9.md` (three findings, all verified against live source at commit `05f8b31`, the round-8 tip). + +**Scope:** `native-lib/node` only — `src/addon.c` and `src/dataweave.ts` if needed. Do **not** touch `native-lib/python/**`, the legacy singleton entrypoints, or `ScriptRuntime.getInstance()`. The Java side (`NativeLib.java`, `ScriptRuntime`) is read for context but not modified — the fix keeps the C addon from calling `fn_destroy_engine` too early rather than changing Java's registry semantics. + +## Problem + +The ninth "andy" follow-up review raised three findings. All three verified against live source and are real. + +### #1 (P1) — `cleanup()` can invalidate an already-admitted stream/transform before its worker begins execution + +`doCleanup()` (dataweave.ts:151-155) calls `ffi.destroyEngine(handle)` and only then `await ffi.cleanup()`. `napi_destroy_engine` (addon.c:1543-1546) calls `fn_destroy_engine(thread, handle)` **unconditionally and synchronously**, which removes the handle from `ScriptRuntime.REGISTRY`. A streaming/transform op that already passed admission (`g_active_ops++` at addon.c:753 / 1166) but whose background worker has not yet called `fn_run_script_callback_engine` / `fn_run_script_input_output_callback_engine` will then hit `ScriptRuntime.get(handle) == null` (NativeLib.java:457-460) and return `{"success":false,"error":"Unknown engine handle"}` instead of completing. + +**Why the existing deferral does not cover this:** the `in_flight`/`destroy_pending` machinery (addon.c:91-107, 259-281, 1554-1568) defers only the resolver **bridge** free, and it exists **only for resolver-backed engines** (`bridge_begin_op` increments `in_flight` only when `bridge_find != NULL`, addon.c:262). The registry removal (`fn_destroy_engine`) is never deferred, and resolver-less engines have no per-engine op accounting at all. So the registry entry is yanked regardless of in-flight ops. + +### #2 (P2) — output-callback / worker allocations crash on OOM + +Unchecked allocations in the streaming/transform worker + callback machinery dereference NULL / `strlen(NULL)` / strand worker state on OOM: +- `streaming_write_cb` (addon.c:616-619): `malloc(sizeof chunk)` and `malloc(len)` then `memcpy`. +- `transform_write_cb` (addon.c:985-988): same shape. +- Worker `strdup`/sentinel sites: streaming (640, 646, 649, 666-669), transform (1072, 1081, 1084, 1097-1100). + +### #3 (P3) — N-API resource creation unchecked after reserving `g_active_ops` + +Streaming (addon.c:798-803) and transform (1243-1250) ignore the status of `napi_create_string_utf8`, `napi_create_threadsafe_function`, and `napi_create_promise`. A failed TSFN/promise leaves `w->tsfn` / `w->deferred` zeroed for the worker → crash or a stranded `g_active_ops` (teardown wedge). + +### Recurrence note + +#2 and #3 are the structurally-identical siblings of round 8's setup-allocation fix — round 8 hardened the *setup* mallocs because review #8 named those; review #9 walks to the *worker/callback* allocations and the *resource-creation* checks. Round 9 sweeps the whole class (**every fallible native op in the streaming/transform worker + callback paths**: `malloc`/`strdup`/`memcpy`, `napi_create_*`) so no structurally-identical site is left for a round 10. #1 is a distinct cross-layer lifecycle race, fixed on its own. + +## Design + +### 1. Defer registry removal until this engine's admitted ops drain (finding #1) + +Generalize the existing per-engine deferral so the **registry removal** (`fn_destroy_engine`) is deferred exactly like the bridge free already is, and make the per-engine in-flight count exist for **all** engines (resolver-backed and resolver-less). + +**Data model (user decision — extend the record to all engines):** every engine gets a per-engine record (today's `engine_bridge_t`) at `createEngine` time, carrying `handle`, `in_flight`, `destroy_pending`. The resolver-specific fields (`resolver_js`, `env`, `owner`, `results`, the env cleanup hook) remain populated **only for resolver-backed engines**; a resolver-less engine gets a record with those fields zero/NULL. + +**Admission (JS thread, both streaming + transform), before spawning the worker:** increment this engine's `in_flight` for **every** engine (not just `bridge_find != NULL`). Store the record pointer on `w` (`w->bridge` already exists; it now is non-NULL for all engines). The completion sentinel already calls `bridge_end_op(w->bridge, ...)`, which decrements `in_flight` and finalizes on drain — this now runs for all engines. + +**`napi_destroy_engine`:** under `g_mutex`, if the engine's `in_flight > 0`, set `destroy_pending = true` and **defer** the `fn_destroy_engine` registry-removal call (do not call it now); the last op to drain (`bridge_end_op` → finalize) performs `fn_destroy_engine` on completion. If `in_flight == 0`, call `fn_destroy_engine` now, as today. `fn_destroy_engine` attaches its own fresh isolate thread (addon.c:1544-1545), so it is **not** JS-thread-affine and is safe to call from the completion sentinel (which runs on the owner JS thread) or from `destroyEngine` directly. + +**Finalize path:** `bridge_finalize` gains responsibility for the deferred `fn_destroy_engine` call (guarded so it happens exactly once, only when it was deferred). The resolver `napi_ref` deletion + env-cleanup-hook removal stay exactly as today, only for resolver-backed engines, on the owner thread. + +**CRITICAL invariant to preserve — do NOT change the owner-thread destroy restriction's scope.** Today the cross-thread guard (addon.c:1530-1541) fires only for resolver-backed engines (`bridge_find != NULL`) because only they hold thread-affine `napi_ref`/cleanup-hook state. Now that resolver-less engines also have a record, the guard must still fire **only when the record has resolver state** (`resolver_js != NULL` / an env-cleanup hook was registered) — a resolver-less engine must remain destroyable from any thread, unchanged. Gate the owner check on "has resolver napi state," not on "record exists." + +**Ordering / correctness to confirm during review:** +- The `in_flight++` at admission happens under `g_mutex` on the JS thread before the worker is spawned, so `destroyEngine` either sees `in_flight > 0` (defers) or the op has not yet been admitted (nothing to protect). No admitted op can have its registry entry removed before it runs. +- `fn_destroy_engine` is called **exactly once** per handle — either the immediate path (in_flight == 0) or the deferred finalize path (last drain), never both. Guard with the same `destroy_pending`/unlink-once discipline the bridge free already uses. +- Resolver-less engines: `bridge_end_op` now runs for them (previously `w->bridge == NULL` short-circuited). Confirm `bridge_finalize` on a resolver-less record deletes no `napi_ref` (there is none) and removes no cleanup hook (none registered), just performs the deferred `fn_destroy_engine` (if pending) and frees the record. +- `g_active_ops` (global isolate drain) and the per-engine `in_flight` (per-handle registry drain) are **distinct** counters with distinct jobs; this round does not merge them. `g_active_ops` still gates isolate teardown; `in_flight` now gates registry removal. + +### 2. Worker/callback OOM → terminal error result (finding #2) + +Every allocation in the worker + callback machinery checks its result and fails the op cleanly, with **no `g_active_ops` / `in_flight` leak** (user decision — terminal error result, never a hung promise): + +- **`streaming_write_cb` / `transform_write_cb`:** if `malloc(sizeof chunk)` or `malloc(len)` returns NULL, free any partial (`free(chunk)` if the inner malloc failed) and `return -1`. Returning -1 aborts the native run cleanly (the existing contract: write callback returns non-zero → the DataWeave run stops), and the worker still produces a terminal `meta_result` and sentinel. +- **Worker `strdup` of `meta_result`** (streaming 640/646/649, transform 1072/1081/1084): if `strdup` returns NULL, fall back to a **static** const OOM JSON string (e.g. `"{\"success\":false,\"error\":\"Out of memory\"}"`). The sentinel-drop / `call_js_write` completion path must then **not** `free()` a static pointer — introduce a flag or a convention (e.g. only `free(sentinel->buf)` when it was heap-allocated) so the static string is never freed. Simplest: keep a `static const char OOM_JSON[]` and a small helper that returns either a `strdup` or, on failure, sets a "do not free" marker. Design detail deferred to the plan; the invariant is: **the op always resolves with a terminal result and no buffer is double-freed or freed-if-static.** +- **Sentinel `malloc`** (streaming 666-669, transform 1097-1100): if the sentinel `malloc` returns NULL, skip the `napi_call_threadsafe_function` enqueue and run the same finalize-here path the env-dead (`napi_closing`) branch already runs (release tsfn, `bridge_end_op`, free `w`, free `meta_result` if heap) — so `g_active_ops`/`in_flight` are released and nothing is stranded. `g_active_ops` is already decremented before the sentinel block, so only `bridge_end_op` + resource frees remain. + +The bare error string wording matches the existing worker error style (`"Empty response"`, `"Failed to attach thread"`). Keep it terse. + +### 3. Check N-API resource creation after the reservation (finding #3) + +In both `napi_run_script_streaming_engine` (798-803) and `napi_run_script_transform_engine` (1243-1250), check the status of every `napi_create_string_utf8`, `napi_create_threadsafe_function`, and `napi_create_promise`. On any failure, unwind in reverse order of what was created so far: +- release any already-created threadsafe function(s) (`napi_release_threadsafe_function`), +- release the per-engine `in_flight` hold if `bridge_begin_op` already ran (it runs *after* these creates today — confirm ordering; if the creates are above `bridge_begin_op`, no `in_flight` unwind is needed there), +- release `g_active_ops` with the verbatim pattern, +- free `w` (and its buffers), +- `napi_throw_error(env, NULL, "...")` and return NULL. + +Because these creates sit **after** `g_active_ops++` but the exact position relative to `bridge_begin_op` matters, the plan must place each check so the unwind set is complete and ordered. The worker must never observe a zeroed `w->tsfn` / `w->write_tsfn` / `w->read_tsfn` / `w->deferred`. + +### 4. Testing + +The OOM and N-API-create-failure paths are **not deterministically forceable** from JS/vitest (no allocator / N-API fault injection at the addon boundary) — the same documented limitation as rounds 6–8. So #2 and #3 add **no new runtime test**; coverage is C-level code reasoning (every allocation/create checked before use; every failure path unwinds `g_active_ops`, `in_flight`, and frees partials; no double-free; no hung promise). + +Finding **#1 is testable** and gets a deterministic regression test: drive a streaming or transform op through the raw `ffi`, and — from inside the read/first-chunk callback, while the op is admitted and in flight — call `ffi.destroyEngine(handle)` on that engine, then let the op complete. Assert the op still produces its real terminal result (not `"Unknown engine handle"`) and that a subsequent `cleanup()` settles without wedging. Mirrors the harness of `tests/integration/run-admission.test.ts` / `teardown-deadlock.test.ts` (real addon, balances init/cleanup). Document that, like round 5's deadlock test, the reliability comes from the deterministic synchronous prefix of the admission→destroy interleave, not from timers. + +## Verification + +- `cd native-lib/node && npm run build:addon` clean (no new warnings in the touched regions); `npm run build` (tsc) clean. +- `npm test` green: current baseline **878 passed / 59 skipped / 0 failed**, plus the one new #1 regression test → **879 passed / 59 skipped / 0 failed**. +- `git diff --check`. + +## Global Constraints + +- Node-binding-only. Never touch `native-lib/python/**`. +- Never touch the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`), `dw_napi_run_script`, or `ScriptRuntime.getInstance()`. The Java side is not modified. +- Handle width stays C `long long` everywhere. +- Errors for run/streaming/transform APIs surface as **resolved** JSON string values (the worker's terminal `meta_result`) or a synchronous `napi_throw_error` at admission / argument validation / allocation / resource-creation failure — never `napi_reject_deferred`. +- Allocation-failure rejections at the synchronous admission layer use `napi_throw_error` (generic Error). Worker-thread OOM produces a terminal error JSON result string (static when the copy itself failed). +- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread. No env-affine call from the worker thread except through the existing tsfn. +- All shared C state (`g_initialized`, `g_active_ops`, `g_teardown_state`, `g_teardown_cancelled`, `g_ref_count`, and every engine record's `in_flight`/`destroy_pending`) is read/written only under `g_mutex`. +- The `g_active_ops` release pattern is EXACTLY, verbatim: `uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex);` +- `fn_destroy_engine` is called **exactly once** per handle — never both the immediate and the deferred path. +- The owner-thread `destroyEngine` restriction stays scoped to engines with resolver `napi_ref` state; resolver-less engines remain destroyable from any thread. +- Preserve every round-1..8 fix: coalesced `cleanup()`, the JS three-state lifecycle machine, the `TEARDOWN_*` state machine and `napi_initialize` adoption path (incl. round-7's `g_teardown_cancelled` admission carve-out), the worker-thread `g_active_ops` decrement, the streaming/transform atomic admission blocks, the round-6 handle-read validations, the round-7 conversion-status checks, the round-8 setup-allocation NULL checks, guarded cross-thread `destroyEngine`, N-API allocation checks in `teardown_waiter_create`, the enqueue-failure waiter free, the resolver-bridge `in_flight`/`destroy_pending` deferral and its owner-thread `napi_ref` discipline. +- Node vitest baseline **878 passed / 59 skipped / 0 failed** — every task leaves the suite green. + +## Rejected Alternatives + +- **#1 via a JS-side reorder in `doCleanup()` (await per-engine drain before `destroyEngine`).** Rejected: there is no per-engine "await my ops" primitive at the JS layer; streaming is an abandonable generator and `run()` is synchronous, so the class cannot reliably await outstanding ops, and `destroyEngine`'s owner-thread `napi_ref` deletion cannot move into the global `ffi.cleanup()` isolate teardown. The authoritative drain state lives in C. +- **#1 via a separate per-handle op map alongside the resolver-only bridge.** Considered (keeps `engine_bridge_t` focused on resolver state). Rejected in favor of extending the existing record to all engines (user decision) — one structure, one deferral path, no second linked list to keep in sync with the first. +- **#2 abort-op-without-result on worker OOM.** Rejected (user decision): leaving the op's promise unresolved is a worse failure than a terminal error result; the static-OOM-JSON terminal result keeps the op's contract (always resolves) intact. +- **#2/#3 fixing only the cited lines.** Rejected: the per-site habit that produced the round-N-finds-the-sibling recurrence. Round 9 sweeps the whole worker/callback allocation + resource-creation class. +- **Merging `g_active_ops` and per-engine `in_flight` into one counter.** Rejected: they gate different resources (global isolate teardown vs. per-handle registry removal) with different lifetimes; conflating them would reintroduce the class of bug rounds 5–7 fixed. +- **Adding an allocator/N-API fault-injection hook to test #2/#3.** Rejected as test-only production surface (YAGNI), consistent with rounds 6–8. #1, which is forceable, does get a regression test. +- **Modifying the Java `ScriptRuntime` registry to tolerate late lookups.** Rejected as out of scope and the wrong layer — the C addon must not remove the entry early in the first place; changing Java semantics would mask the ordering bug rather than fix it. From 7c2cbe91867fe35b4ba8b5732dc18d51e1f4bca7 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 17:27:21 -0300 Subject: [PATCH 59/63] =?UTF-8?q?docs:=20correct=20round-9=20spec=20?= =?UTF-8?q?=E2=80=94=20#1=20is=20not=20deterministically=20testable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScriptRuntime.get(handle) is the first statement of the worker's Java entrypoint (NativeLib.java:457/492), running before any callback fires, so the "Unknown engine handle" window is admission->lookup (pre-first-chunk), not reproducible from inside a callback. Per round-9 decision, #1 now gets no runtime test (code-reasoning only, like #2/#3); baseline stays 878. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...gine-lifecycle-and-worker-oom-hardening-design.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md b/docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md index 4a898c8..92eab94 100644 --- a/docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md +++ b/docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md @@ -76,14 +76,17 @@ Because these creates sit **after** `g_active_ops++` but the exact position rela ### 4. Testing -The OOM and N-API-create-failure paths are **not deterministically forceable** from JS/vitest (no allocator / N-API fault injection at the addon boundary) — the same documented limitation as rounds 6–8. So #2 and #3 add **no new runtime test**; coverage is C-level code reasoning (every allocation/create checked before use; every failure path unwinds `g_active_ops`, `in_flight`, and frees partials; no double-free; no hung promise). +**No new runtime test — all three findings are covered by C-level code reasoning.** This is the same documented limitation as rounds 6–8: the failure paths are not deterministically forceable from JS/vitest. -Finding **#1 is testable** and gets a deterministic regression test: drive a streaming or transform op through the raw `ffi`, and — from inside the read/first-chunk callback, while the op is admitted and in flight — call `ffi.destroyEngine(handle)` on that engine, then let the op complete. Assert the op still produces its real terminal result (not `"Unknown engine handle"`) and that a subsequent `cleanup()` settles without wedging. Mirrors the harness of `tests/integration/run-admission.test.ts` / `teardown-deadlock.test.ts` (real addon, balances init/cleanup). Document that, like round 5's deadlock test, the reliability comes from the deterministic synchronous prefix of the admission→destroy interleave, not from timers. +- **#2 / #3** — the OOM and N-API-create-failure paths need allocator / N-API fault injection at the addon boundary, which does not exist. Coverage is code reasoning: every allocation/create is checked before use; every failure path unwinds `g_active_ops`, `in_flight`, and frees partials; no double-free; no hung promise. +- **#1** — despite the spec's earlier draft, this is **not** deterministically forceable either. `ScriptRuntime.get(handle)` (`NativeLib.java:457`, `:492`) is the **first statement** of the worker's Java entrypoint — it runs *before* any read/write callback fires. So the observable "Unknown engine handle" window is the gap between op **admission** (worker spawned, promise returned) and the worker's Java **lookup**, which is entirely *before* the first chunk. A test that fires `destroyEngine` from inside a callback cannot reproduce it (the lookup already succeeded; the worker holds its `runtime` locally and completes fine even on unfixed code). The review itself calls the symptom "nondeterministic." A synchronous-fire-after-admission race-window loop would be green-on-fixed but only *probabilistically* red-on-unfixed — not the deterministic guard rounds 5's test provides — so per the round-9 decision #1 gets **no new runtime test**; its correctness is established by code reasoning against the ordering invariants below. + +Baseline is therefore unchanged at **878 passed / 59 skipped / 0 failed** — no new test, no regression. ## Verification - `cd native-lib/node && npm run build:addon` clean (no new warnings in the touched regions); `npm run build` (tsc) clean. -- `npm test` green: current baseline **878 passed / 59 skipped / 0 failed**, plus the one new #1 regression test → **879 passed / 59 skipped / 0 failed**. +- `npm test` green: baseline **878 passed / 59 skipped / 0 failed**, unchanged (no new test — see §4). - `git diff --check`. ## Global Constraints @@ -108,5 +111,6 @@ Finding **#1 is testable** and gets a deterministic regression test: drive a str - **#2 abort-op-without-result on worker OOM.** Rejected (user decision): leaving the op's promise unresolved is a worse failure than a terminal error result; the static-OOM-JSON terminal result keeps the op's contract (always resolves) intact. - **#2/#3 fixing only the cited lines.** Rejected: the per-site habit that produced the round-N-finds-the-sibling recurrence. Round 9 sweeps the whole worker/callback allocation + resource-creation class. - **Merging `g_active_ops` and per-engine `in_flight` into one counter.** Rejected: they gate different resources (global isolate teardown vs. per-handle registry removal) with different lifetimes; conflating them would reintroduce the class of bug rounds 5–7 fixed. -- **Adding an allocator/N-API fault-injection hook to test #2/#3.** Rejected as test-only production surface (YAGNI), consistent with rounds 6–8. #1, which is forceable, does get a regression test. +- **Adding an allocator/N-API fault-injection hook to test #2/#3.** Rejected as test-only production surface (YAGNI), consistent with rounds 6–8. +- **A race-window loop test for #1** (synchronous `destroyEngine` right after admission, looped N times). Rejected: green-on-fixed but only *probabilistically* red-on-unfixed, so it is not the deterministic guard round 5's deadlock test is — it would pass on the unfixed code whenever the worker's Java lookup happens to win the race. Not worth a permanently-running probabilistic test; #1's correctness rests on the ordering invariants in §Design.1 verified by code reasoning. - **Modifying the Java `ScriptRuntime` registry to tolerate late lookups.** Rejected as out of scope and the wrong layer — the C addon must not remove the entry early in the first place; changing Java semantics would mask the ordering bug rather than fix it. From e2e596bd35782c9857d95037574542fa9070df2b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 17:43:47 -0300 Subject: [PATCH 60/63] fix(node): OOM-safe worker/callback allocations (round-9 P2) Every malloc/strdup/sentinel alloc in streaming_write_cb, transform_write_cb, streaming_thread_fn and transform_thread_fn is checked. Write callbacks return -1 on OOM (aborts the run cleanly); worker strdup failures fall back to a static OOM_JSON terminal result; a NULL sentinel malloc skips the enqueue and runs the env-dead finalize path so g_active_ops and the bridge hold release. The OOM_JSON static is never freed (pointer-identity guard at every site). Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 69 ++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 49192f7..233a67c 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -534,6 +534,13 @@ static napi_value dw_napi_run_script(napi_env env, napi_callback_info info) { // --- Streaming output --- +// Round-9 (#2): static terminal-error JSON used when a worker thread cannot +// even strdup its result string (OOM). It is a file-scope constant, never +// heap-allocated, so any code path that would free a sentinel/chunk buffer +// must first check `buf != OOM_JSON` -- freeing a static pointer is UB. The +// wording matches the existing terse worker error style ("Empty response"). +static const char OOM_JSON[] = "{\"success\":false,\"error\":\"Out of memory\"}"; + // chunk_data with len == -1 is a sentinel indicating completion (buf holds meta JSON) struct chunk_data { char* buf; @@ -573,7 +580,7 @@ static void call_js_write(napi_env env, napi_value js_callback, void* context, v napi_resolve_deferred(env, w->deferred, result); } - free(chunk->buf); + if (chunk->buf != OOM_JSON) free(chunk->buf); free(chunk); free(w->script); free(w->inputs_json); @@ -613,8 +620,14 @@ static void call_js_write(napi_env env, napi_value js_callback, void* context, v static int streaming_write_cb(void* ctx, const char* buf, int len) { napi_threadsafe_function tsfn = (napi_threadsafe_function)ctx; + // Round-9 (#2): OOM here must not deref NULL / memcpy into NULL. Returning -1 + // aborts the native run cleanly (write-callback contract: non-zero stops the + // DataWeave run); the worker then still produces a terminal meta_result and + // sentinel, so the op resolves. struct chunk_data* chunk = malloc(sizeof(struct chunk_data)); + if (chunk == NULL) return -1; chunk->buf = malloc(len); + if (chunk->buf == NULL) { free(chunk); return -1; } memcpy(chunk->buf, buf, len); chunk->len = len; @@ -633,20 +646,27 @@ static void streaming_thread_fn(void* arg) { void* worker_thread = NULL; int rc = fn_attach_thread(g_isolate, &worker_thread); + // Round-9 (#2): strdup can fail under OOM. meta_result must still be a valid + // C string so the sentinel path below can deliver a terminal result -- fall + // back to the OOM_JSON static (which must never be freed; see the guarded + // frees below and in call_js_write). char* meta_result = NULL; if (rc != 0) { char err[256]; snprintf(err, sizeof(err), "{\"success\":false,\"error\":\"Failed to attach thread (code %d)\"}", rc); meta_result = strdup(err); + if (meta_result == NULL) meta_result = (char*)OOM_JSON; } else { void* result_ptr = fn_run_script_callback_engine( worker_thread, w->handle, w->script, w->inputs_json, streaming_write_cb, (void*)w->tsfn ); if (result_ptr) { meta_result = strdup((const char*)result_ptr); + if (meta_result == NULL) meta_result = (char*)OOM_JSON; fn_free_cstring(worker_thread, result_ptr); } else { meta_result = strdup("{\"success\":false,\"error\":\"Empty response\"}"); + if (meta_result == NULL) meta_result = (char*)OOM_JSON; } fn_detach_thread(worker_thread); } @@ -663,7 +683,21 @@ static void streaming_thread_fn(void* arg) { uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + // Round-9 (#2): if even the sentinel struct cannot be allocated, we cannot + // enqueue a completion -- run the SAME native finalize the env-dead + // (napi_closing) branch below runs, so g_active_ops (already decremented + // above) plus the bridge in-flight hold and w are released and nothing is + // stranded. This is the "sentinel malloc NULL -> skip enqueue + unwind like + // the env-dead sentinel branch" path. struct chunk_data* sentinel = malloc(sizeof(struct chunk_data)); + if (sentinel == NULL) { + if (meta_result != OOM_JSON) free(meta_result); + free(w->script); + free(w->inputs_json); + bridge_end_op(w->bridge, /*env_still_alive=*/false); + free(w); + return; + } sentinel->buf = meta_result; sentinel->len = -1; napi_status enq = napi_call_threadsafe_function(w->tsfn, sentinel, napi_tsfn_blocking); @@ -693,7 +727,7 @@ static void streaming_thread_fn(void* arg) { // End the bridge op with env_still_alive=false so bridge_finalize skips // the thread-affine napi_delete_reference (Node auto-reclaims the ref // when the dead env is destroyed). - free(sentinel->buf); + if (sentinel->buf != OOM_JSON) free(sentinel->buf); free(sentinel); free(w->script); free(w->inputs_json); @@ -982,8 +1016,12 @@ static int transform_read_cb(void* ctx, char* buf, int buf_size) { static int transform_write_cb(void* ctx, const char* buf, int len) { struct transform_work* w = (struct transform_work*)ctx; + // Round-9 (#2): OOM-safe, mirrors streaming_write_cb. Return -1 to abort the + // native run cleanly; the worker still delivers a terminal sentinel. struct chunk_data* chunk = malloc(sizeof(struct chunk_data)); + if (chunk == NULL) return -1; chunk->buf = malloc(len); + if (chunk->buf == NULL) { free(chunk); return -1; } memcpy(chunk->buf, buf, len); chunk->len = len; @@ -1017,7 +1055,7 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* napi_resolve_deferred(env, w->deferred, result); } - free(chunk->buf); + if (chunk->buf != OOM_JSON) free(chunk->buf); free(chunk); free(w->script); free(w->inputs_json); @@ -1065,11 +1103,15 @@ static void transform_thread_fn(void* arg) { void* worker_thread = NULL; int rc = fn_attach_thread(g_isolate, &worker_thread); + // Round-9 (#2): strdup can fail under OOM; fall back to the OOM_JSON static + // so the sentinel below still delivers a terminal result. Mirrors + // streaming_thread_fn. char* meta_result = NULL; if (rc != 0) { char err[256]; snprintf(err, sizeof(err), "{\"success\":false,\"error\":\"Failed to attach thread (code %d)\"}", rc); meta_result = strdup(err); + if (meta_result == NULL) meta_result = (char*)OOM_JSON; } else { void* result_ptr = fn_run_script_input_output_callback_engine( worker_thread, w->handle, w->script, w->inputs_json, @@ -1079,9 +1121,11 @@ static void transform_thread_fn(void* arg) { if (result_ptr) { meta_result = strdup((const char*)result_ptr); + if (meta_result == NULL) meta_result = (char*)OOM_JSON; fn_free_cstring(worker_thread, result_ptr); } else { meta_result = strdup("{\"success\":false,\"error\":\"Empty response\"}"); + if (meta_result == NULL) meta_result = (char*)OOM_JSON; } fn_detach_thread(worker_thread); } @@ -1094,7 +1138,24 @@ static void transform_thread_fn(void* arg) { uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + // Round-9 (#2): sentinel malloc NULL -> skip enqueue and run the same native + // finalize as the env-dead branch below (release the bridge hold + free w and + // all fields), so g_active_ops (already decremented above) and the in-flight + // hold are released. No self-join, no env-affine napi call, no tsfn release + // (see the env-dead branch's citation for why releasing the tsfns here is + // unsafe). struct chunk_data* sentinel = malloc(sizeof(struct chunk_data)); + if (sentinel == NULL) { + if (meta_result != OOM_JSON) free(meta_result); + free(w->script); + free(w->inputs_json); + free(w->input_name); + free(w->input_mime_type); + free(w->input_charset); + bridge_end_op(w->bridge, /*env_still_alive=*/false); + free(w); + return; + } sentinel->buf = meta_result; sentinel->len = -1; napi_status enq = napi_call_threadsafe_function(w->write_tsfn, sentinel, napi_tsfn_blocking); @@ -1122,7 +1183,7 @@ static void transform_thread_fn(void* arg) { // End the bridge op with env_still_alive=false so bridge_finalize skips // the thread-affine napi_delete_reference (Node auto-reclaims the ref // when the dead env is destroyed). - free(sentinel->buf); + if (sentinel->buf != OOM_JSON) free(sentinel->buf); free(sentinel); free(w->script); free(w->inputs_json); From 4412db4b610d6e25416b12504a10b1c79b2e3bd8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 18:55:29 -0300 Subject: [PATCH 61/63] fix(node): check N-API resource creation after reservation (round-9 P3) napi_create_string_utf8/napi_create_threadsafe_function/napi_create_promise in the streaming and transform entrypoints now have their status checked. On failure each path releases g_active_ops (verbatim pattern), releases any tsfn already created (transform releases read_tsfn before write_tsfn), frees the work struct + buffers, and throws -- so the worker never observes a zeroed tsfn/deferred and g_active_ops is never stranded. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 67 +++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 233a67c..70ab0a4 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -829,12 +829,37 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i return NULL; } + // Round-9 (#3): the resource creations below run AFTER g_active_ops was + // reserved (and after w + its buffers were allocated), but bridge_begin_op + // has NOT run yet (it is below), so there is no in-flight hold to unwind + // here. A failed create must release g_active_ops (verbatim pattern), free + // any tsfn already created, free w + buffers, and throw -- otherwise the + // worker sees a zeroed w->tsfn/w->deferred (crash) or g_active_ops is + // stranded (teardown wedge). napi_value resource_name; - napi_create_string_utf8(env, "dwStreaming", NAPI_AUTO_LENGTH, &resource_name); - napi_create_threadsafe_function(env, argv[3], NULL, resource_name, 0, 1, NULL, NULL, w, call_js_write, &w->tsfn); + if (napi_create_string_utf8(env, "dwStreaming", NAPI_AUTO_LENGTH, &resource_name) != napi_ok) { + free(w->script); free(w->inputs_json); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptStreamingEngine: failed to create resource name"); + return NULL; + } + if (napi_create_threadsafe_function(env, argv[3], NULL, resource_name, 0, 1, NULL, NULL, w, call_js_write, &w->tsfn) != napi_ok) { + free(w->script); free(w->inputs_json); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptStreamingEngine: failed to create threadsafe function"); + return NULL; + } napi_value promise; - napi_create_promise(env, &w->deferred, &promise); + if (napi_create_promise(env, &w->deferred, &promise) != napi_ok) { + // The tsfn was created above; release it before freeing w (it holds w as + // its context). No worker exists yet, so this release is the sole discharge. + napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); + free(w->script); free(w->inputs_json); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptStreamingEngine: failed to create promise"); + return NULL; + } // Pin the resolver bridge (if any) for the whole op so it outlives a concurrent // destroyEngine/cleanup and the background thread can safely call back into @@ -1301,14 +1326,42 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i } #undef TRANSFORM_FAIL + // Round-9 (#3): check each resource creation; on failure release g_active_ops + // (verbatim), release any tsfn already created, free w + all five string + // buffers, and throw. bridge_begin_op is below, so no in-flight hold exists + // here. read_tsfn has no context (NULL); write_tsfn holds w as context, so + // release write_tsfn before freeing w if it was created. napi_value resource_name; - napi_create_string_utf8(env, "dwTransform", NAPI_AUTO_LENGTH, &resource_name); + if (napi_create_string_utf8(env, "dwTransform", NAPI_AUTO_LENGTH, &resource_name) != napi_ok) { + free(w->script); free(w->inputs_json); free(w->input_name); free(w->input_mime_type); free(w->input_charset); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptTransformEngine: failed to create resource name"); + return NULL; + } - napi_create_threadsafe_function(env, argv[6], NULL, resource_name, 0, 1, NULL, NULL, NULL, call_js_read, &w->read_tsfn); - napi_create_threadsafe_function(env, argv[7], NULL, resource_name, 0, 1, NULL, NULL, w, call_js_transform_write, &w->write_tsfn); + if (napi_create_threadsafe_function(env, argv[6], NULL, resource_name, 0, 1, NULL, NULL, NULL, call_js_read, &w->read_tsfn) != napi_ok) { + free(w->script); free(w->inputs_json); free(w->input_name); free(w->input_mime_type); free(w->input_charset); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptTransformEngine: failed to create read threadsafe function"); + return NULL; + } + if (napi_create_threadsafe_function(env, argv[7], NULL, resource_name, 0, 1, NULL, NULL, w, call_js_transform_write, &w->write_tsfn) != napi_ok) { + napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); + free(w->script); free(w->inputs_json); free(w->input_name); free(w->input_mime_type); free(w->input_charset); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptTransformEngine: failed to create write threadsafe function"); + return NULL; + } napi_value promise; - napi_create_promise(env, &w->deferred, &promise); + if (napi_create_promise(env, &w->deferred, &promise) != napi_ok) { + napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); + napi_release_threadsafe_function(w->write_tsfn, napi_tsfn_release); + free(w->script); free(w->inputs_json); free(w->input_name); free(w->input_mime_type); free(w->input_charset); free(w); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "runScriptTransformEngine: failed to create promise"); + return NULL; + } // Pin the resolver bridge (if any) for the whole op so it outlives a concurrent // destroyEngine/cleanup and the background thread can safely call back into From 0895fe47fb5212a09afe2f3deea20e5e98c111a9 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 19:10:20 -0300 Subject: [PATCH 62/63] fix(node): defer engine registry removal until admitted ops drain (round-9 P1) destroyEngine called fn_destroy_engine (registry removal) unconditionally before checking in-flight ops, so a streaming/transform worker already admitted but not yet past ScriptRuntime.get(handle) got "Unknown engine handle". Every engine now gets a per-engine record (not just resolver-backed ones); when an op is in flight, destroyEngine defers BOTH the registry removal and the record free to the last op draining on the owner thread. fn_destroy_engine runs exactly once per handle. The owner-thread destroy guard is now keyed on resolver_js != NULL so resolver-less engines stay destroyable from any thread. A new destroy_via_destroy_engine bit keeps the env-cleanup-hook defer path from triggering a registry removal. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 121 ++++++++++++++++++++++++++++-------- 1 file changed, 94 insertions(+), 27 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 70ab0a4..c8103b0 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -102,6 +102,11 @@ typedef struct engine_bridge { // freeing was deferred to the last op draining on the owner thread. int in_flight; bool destroy_pending; + // round-9 (#1): true only when destroyEngine deferred (in_flight > 0); gates + // the deferred fn_destroy_engine registry removal in bridge_end_op. The + // bridge_env_cleanup defer path leaves this false so it never makes a + // registry call during env teardown. + bool destroy_via_destroy_engine; struct engine_bridge* next; } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex @@ -202,8 +207,19 @@ static engine_bridge_t* bridge_find(long long handle) { // env cleanup hook via napi_remove_env_cleanup_hook so Node never invokes it // on freed memory; the hook path itself (bridge_env_cleanup) must not remove // itself and calls this directly. -static void bridge_finalize(engine_bridge_t* b, bool env_still_alive) { +// `do_registry_remove` is true only when destroyEngine deferred the registry +// removal (fn_destroy_engine) because an op was in flight -- the last op to +// drain performs it here, exactly once, before freeing the record. It runs on +// whichever thread finalizes (the owner JS thread from the completion +// sentinel, or destroyEngine's thread); fn_destroy_engine attaches its own +// isolate thread, so it is not JS-thread-affine. Must be called WITHOUT +// g_mutex held (it enters GraalVM and, for env_still_alive, calls N-API). +static void bridge_finalize(engine_bridge_t* b, bool env_still_alive, bool do_registry_remove) { if (b == NULL) return; + if (do_registry_remove && fn_destroy_engine) { + void* thread = NULL; + if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, b->handle); fn_detach_thread(thread); } + } if (env_still_alive && b->resolver_js != NULL && b->env != NULL) { napi_delete_reference(b->env, b->resolver_js); } @@ -246,7 +262,7 @@ static void bridge_env_cleanup(void* arg) { // call napi_remove_env_cleanup_hook for ourselves here. The env is still // alive here -- that is the whole point of this hook's design (see above) -- // so the napi_ref deletion in bridge_finalize is legal. - bridge_finalize(b, /*env_still_alive=*/true); + bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/false); } // Begin a streaming/transform op on a resolver-backed engine: look up the bridge @@ -276,8 +292,12 @@ static void bridge_end_op(engine_bridge_t* b, bool env_still_alive) { uv_mutex_lock(&g_mutex); b->in_flight--; bool finalize = (b->destroy_pending && b->in_flight == 0); + bool remove_registry = finalize && b->destroy_via_destroy_engine; uv_mutex_unlock(&g_mutex); - if (finalize) bridge_finalize(b, env_still_alive); + // remove_registry is true only when destroyEngine deferred the registry + // removal while this op was in flight (round-9 #1); the env-cleanup-hook + // defer path leaves it false so no registry call is made during env teardown. + if (finalize) bridge_finalize(b, env_still_alive, /*do_registry_remove=*/remove_registry); } // --- Initialization --- @@ -1564,6 +1584,29 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // any handle <= 0 means construction failed; never hand that back to JS as // if it were usable. if (handle <= 0) { napi_throw_error(env, NULL, "create_engine returned an invalid handle"); return NULL; } + + // Round-9 (#1): every engine -- resolver-backed or not -- gets a per-engine + // record so destroyEngine can defer the registry removal (fn_destroy_engine) + // until this engine's in-flight streaming/transform ops drain. A resolver-less + // record leaves resolver_js/env/results NULL and registers NO env cleanup + // hook (there is no napi_ref to dispose). owner is recorded for symmetry but + // is NOT used to restrict destruction of resolver-less engines (see the + // owner guard in napi_destroy_engine, which checks resolver_js != NULL). + engine_bridge_t* rec = (engine_bridge_t*)calloc(1, sizeof(engine_bridge_t)); + if (rec == NULL) { + // Roll back the engine we just created so we don't leak a registered but + // unrecorded handle. fn_destroy_engine attaches its own thread. + if (fn_destroy_engine) { + void* t2 = NULL; + if (fn_attach_thread(g_isolate, &t2) == 0) { fn_destroy_engine(t2, handle); fn_detach_thread(t2); } + } + napi_throw_error(env, NULL, "Failed to allocate engine record"); + return NULL; + } + rec->handle = handle; + rec->owner = uv_thread_self(); + uv_mutex_lock(&g_mutex); rec->next = g_bridges; g_bridges = rec; uv_mutex_unlock(&g_mutex); + napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; } @@ -1602,7 +1645,7 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i // tracked buffers too, so nothing is dropped on the floor. if (handle <= 0) { // Synchronous call on the JS thread -- env is live here. - bridge_finalize(bridge, /*env_still_alive=*/true); + bridge_finalize(bridge, /*env_still_alive=*/true, /*do_registry_remove=*/false); napi_throw_error(env, NULL, "create_engine_with_resolver returned an invalid handle"); return NULL; } @@ -1641,9 +1684,16 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { // NULL -> fall through). We are on the owner thread past this point, so the // env cannot be concurrently tearing down and the bridge stays stable // between this check and the unlink below. + // Owner-thread guard: only resolver-backed engines carry thread-affine + // N-API state (a napi_ref + an env cleanup hook) that is illegal to touch + // from another Worker's thread. Round-9 gave resolver-LESS engines a record + // too, so the guard must key on resolver state (resolver_js != NULL), NOT + // on "a record exists" -- otherwise resolver-less engines would wrongly + // become non-destroyable off their creating thread. A resolver-less engine + // has no napi state and stays destroyable from any thread. uv_mutex_lock(&g_mutex); engine_bridge_t* owned = bridge_find(handle); - if (owned != NULL) { + if (owned != NULL && owned->resolver_js != NULL) { uv_thread_t self = uv_thread_self(); if (!uv_thread_equal(&self, &owned->owner)) { uv_mutex_unlock(&g_mutex); @@ -1652,34 +1702,51 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { return NULL; } } - uv_mutex_unlock(&g_mutex); - if (fn_destroy_engine) { - void* thread = NULL; - if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, handle); fn_detach_thread(thread); } - } - // Unlink the bridge from g_bridges, but only free it now if no streaming/ - // transform op is still in flight. A background op can still call back into - // resolve_module_callback with this bridge as ctx (F1), so if in_flight > 0 - // we mark destroy_pending and defer the free to the completion sentinel, - // which drains on this same owner thread. Deleting the napi_ref is only legal - // on the owner thread, and destroyEngine is called from it, so we finalize - // here in the common (not-in-flight) case. - uv_mutex_lock(&g_mutex); + // Round-9 (#1): unlink the record and decide, under the lock, whether the + // registry removal (fn_destroy_engine) and the record free must be DEFERRED. + // If an op is in flight, its worker may not yet have called + // ScriptRuntime.get(handle) (the first statement of the Java entrypoint) -- + // removing the registry entry now would make that lookup fail with + // "Unknown engine handle". So defer BOTH the registry removal and the free + // to the last op draining (bridge_end_op -> bridge_finalize with + // do_registry_remove=true), which runs on this same owner thread. When no op + // is in flight, remove the registry entry and finalize immediately, as + // before. Every engine now has a record, so `found` is non-NULL for both + // resolver-backed and resolver-less engines. engine_bridge_t** pp = &g_bridges; engine_bridge_t* found = NULL; while (*pp != NULL) { if ((*pp)->handle == handle) { found = *pp; *pp = found->next; break; } pp = &(*pp)->next; } bool defer = false; - if (found != NULL) { - if (found->in_flight > 0) { found->destroy_pending = true; defer = true; } - } + // destroy_via_destroy_engine gates the deferred registry removal in + // bridge_end_op (see Step 5); set it together with destroy_pending here. + if (found != NULL && found->in_flight > 0) { found->destroy_pending = true; found->destroy_via_destroy_engine = true; defer = true; } uv_mutex_unlock(&g_mutex); + if (found != NULL) { - // Drop the env cleanup hook: whether we finalize now or defer to the - // draining op, the free happens explicitly, so Node must never invoke - // the hook on this (soon-to-be or already) freed bridge. - napi_remove_env_cleanup_hook(env, bridge_env_cleanup, found); - // Synchronous call on the JS thread -- env is live here. - if (!defer) bridge_finalize(found, /*env_still_alive=*/true); + // Drop the env cleanup hook (resolver-backed engines only ever registered + // one; napi_remove_env_cleanup_hook is a safe no-op if none was added). + // Whether we finalize now or defer, the free happens explicitly, so Node + // must never invoke the hook on this (soon-to-be or already) freed record. + if (found->resolver_js != NULL) { + napi_remove_env_cleanup_hook(env, bridge_env_cleanup, found); + } + if (!defer) { + // Not in flight: remove the registry entry AND finalize now, on this + // owner thread (env live). do_registry_remove=true folds the + // fn_destroy_engine call into bridge_finalize so it happens exactly + // once regardless of path. + bridge_finalize(found, /*env_still_alive=*/true, /*do_registry_remove=*/true); + } + // else: the draining op's bridge_end_op -> bridge_finalize performs both + // the registry removal and the free (see Step 5). + } else { + // No record found (should not happen now that every engine has one, but + // stay robust to a double-destroy or an unknown handle): fall back to the + // pre-round-9 behavior of removing the registry entry directly. + if (fn_destroy_engine) { + void* thread = NULL; + if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, handle); fn_detach_thread(thread); } + } } return NULL; } From 2a038f27d9ce37610e575cc8d50eefef720bf7d2 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 19:31:03 -0300 Subject: [PATCH 63/63] docs(node): fix stale bridge comments after round-9 all-engines record (#1) bridge_begin_op now returns a record for every engine, not just resolver-backed ones, and the streaming/transform completion path must call bridge_end_op for resolver-less engines too (it drives the deferred registry removal). Three comments still described the pre-round-9 "resolver-backed only / NULL for resolver-less / must not call bridge_end_op" contract, which is now the inverse of the load-bearing invariant. Comment-only; no logic change. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index c8103b0..8ce90a1 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -265,13 +265,16 @@ static void bridge_env_cleanup(void* arg) { bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/false); } -// Begin a streaming/transform op on a resolver-backed engine: look up the bridge -// and mark one op in flight so it (and its napi_ref) cannot be freed while the -// background uv_thread can still call resolve_module_callback with it (F1). -// Returns the bridge pointer (stable for the op's lifetime, since in_flight > 0 -// blocks both destroyEngine and the env cleanup hook from freeing it) or NULL for -// a resolver-less engine / unknown handle, in which case there is nothing to -// protect and completion must not call bridge_end_op. +// Begin a streaming/transform op: look up the engine's record and mark one op in +// flight so the record (and, for resolver-backed engines, its napi_ref) cannot be +// freed while the background uv_thread runs -- and, since round-9 (#1), so that +// destroyEngine defers the Java registry removal until this op drains. Every +// engine (resolver-backed or resolver-less) now has a record, so this returns a +// non-NULL pointer for any known handle; the completion sentinel MUST call +// bridge_end_op on it to balance in_flight and run any deferred destroy. Returns +// NULL only for an unknown handle (nothing to protect, no bridge_end_op needed). +// The returned pointer is stable for the op's lifetime because in_flight > 0 +// blocks both destroyEngine and the env cleanup hook from freeing the record. static engine_bridge_t* bridge_begin_op(long long handle) { uv_mutex_lock(&g_mutex); engine_bridge_t* b = bridge_find(handle); @@ -574,8 +577,10 @@ struct streaming_work { long long handle; char* script; char* inputs_json; - // Non-NULL only for resolver-backed engines: the bridge whose in_flight count - // this op holds. The completion sentinel calls bridge_end_op on it (F1). + // The engine's record whose in_flight count this op holds. Since round-9 (#1) + // every engine has a record, so this is non-NULL for any known handle (NULL only + // for an unknown handle). The completion sentinel calls bridge_end_op on it to + // balance in_flight and run any deferred destroy (F1). engine_bridge_t* bridge; }; @@ -934,8 +939,10 @@ struct transform_work { char* input_name; char* input_mime_type; char* input_charset; - // Non-NULL only for resolver-backed engines: the bridge whose in_flight count - // this op holds. The completion sentinel calls bridge_end_op on it (F1). + // The engine's record whose in_flight count this op holds. Since round-9 (#1) + // every engine has a record, so this is non-NULL for any known handle (NULL only + // for an unknown handle). The completion sentinel calls bridge_end_op on it to + // balance in_flight and run any deferred destroy (F1). engine_bridge_t* bridge; };