From 91d1acb2c3895a22eb178a9aec88ef55af256c53 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 7 Aug 2026 17:43:53 -0300 Subject: [PATCH 001/216] 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 00000000..5a491acc --- /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 9e3154382603011d6f5acbef3d6414de50aca6f7 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 10:22:31 -0300 Subject: [PATCH 002/216] 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 d6b80912..9f85a7f2 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 3e993c7e..2deaddd3 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 549ea3ac..30b1ed67 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 3371127a..d8db13ba 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 70f8044b..d3a2f3ef 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 ba508299f2a32e7821864f06f5080b1f35234ddf Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 10:37:16 -0300 Subject: [PATCH 003/216] 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 5aa31535..ed4ae2ae 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 1015beb240ac7291907c4ec785da12f692d385db Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 10:53:11 -0300 Subject: [PATCH 004/216] 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 5ae4ae04..78c93b87 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -24,23 +24,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 @@ -64,6 +51,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. @@ -91,6 +79,9 @@ export class DataWeave { if (this.initialized) return; try { ffi.initialize(this.libPath, this.addonPath); + 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}`); } @@ -103,6 +94,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; } @@ -122,14 +117,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); @@ -153,7 +141,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) + ); } /** @@ -188,7 +178,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 b75ee743..18ea40b2 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; } @@ -41,15 +44,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, @@ -58,16 +79,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 6578bb6b..deeaacb3 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 75e7da59..00000000 --- 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 3dc2fd42..00000000 --- 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 00000000..fa5aa100 --- /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 d7765026fff86e87d55317cbe5ece4136af2553d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 11:03:27 -0300 Subject: [PATCH 005/216] 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 78c93b87..e2bfb34a 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -77,12 +77,25 @@ export class DataWeave { */ initialize(): void { if (this.initialized) return; + let libRefAcquired = false; try { ffi.initialize(this.libPath, this.addonPath); + 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 00000000..3bda370a --- /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 47891294413669a709c09555ba8f078df72a1bec Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 11:13:17 -0300 Subject: [PATCH 006/216] 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 5c290c0f..495335b4 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -453,16 +453,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 274e6f98..648eeeb4 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 27ec81264c947b12d7a08d61ce66d99dfc75dd02 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 17:38:23 -0300 Subject: [PATCH 007/216] 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 00000000..d2b9e8d8 --- /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 ed4ae2ae..048e4d09 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 5f070dd7b1e342c12757d7d59d8fe32d247ca26f Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 17:46:24 -0300 Subject: [PATCH 008/216] 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 048e4d09..8d14b44c 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 5475cbdb10e6a89aa36aa67529eb6c927c42a5a9 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 17:53:26 -0300 Subject: [PATCH 009/216] 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 8d14b44c..3a8cdade 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 2d977d7088b101b025aa342598a95856f2c0d78f Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 18:02:04 -0300 Subject: [PATCH 010/216] 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 3a8cdade..ee594a9f 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 9f85a7f2..a71973b7 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 c71ac9f11197152e0ac419116ff7e13a13898810 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 18:10:13 -0300 Subject: [PATCH 011/216] 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 a71973b7..c2596084 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 2acaf2aaa24d3f6eb32a8fb3210735c595b9841c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 18:20:03 -0300 Subject: [PATCH 012/216] 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 deeaacb3..65ad6267 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 285d198be74d5b5ac1bfa48ed3b3f755b3079c18 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 19:02:09 -0300 Subject: [PATCH 013/216] 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 30b1ed67..f635ccf0 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 d3a2f3ef..bf35264a 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 596eb8b78d02b5d2059965bc01835475a6f7b2ee Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 10 Aug 2026 19:02:15 -0300 Subject: [PATCH 014/216] 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 d2b9e8d8..00000000 --- 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 8ace0204d330b0945f46a5c4fb95e20684f291ae Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 10:01:43 -0300 Subject: [PATCH 015/216] 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 00000000..4ae676c2 --- /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 ce6c5debfdb8f65ff64c626db6fd074d2fbe4e77 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 10:51:45 -0300 Subject: [PATCH 016/216] 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 ee594a9f..666cd977 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 32929fade3e03b3177cd09c58c65cf3ba202fad3 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:08:09 -0300 Subject: [PATCH 017/216] 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 666cd977..46a5d772 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 58a0bcf487b542d2bd0b7e9e4c362e5ad893aba1 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:16:02 -0300 Subject: [PATCH 018/216] 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 46a5d772..0bd8cfa3 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 df2f84ab52c90b665e08181634c8be1d7d09312d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:35:43 -0300 Subject: [PATCH 019/216] 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 0bd8cfa3..2d9abb98 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 2fb7af7eba3798a548dd7d3bc282318bc30fb0c4 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:40:33 -0300 Subject: [PATCH 020/216] 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 e2bfb34a..eb96ac18 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -104,14 +104,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; } @@ -262,9 +269,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(); } } diff --git a/native-lib/node/src/ffi.ts b/native-lib/node/src/ffi.ts index 18ea40b2..ef5c8cd1 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; @@ -91,6 +91,6 @@ export function runScriptTransformEngine( ); } -export function cleanup(): void { - getAddon().cleanup(); +export function cleanup(): Promise { + return getAddon().cleanup(); } From b0765262e500b28f9aab0683a5ef955060140baa Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 11:57:42 -0300 Subject: [PATCH 021/216] 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 65ad6267..7a842bbd 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 bacf1606..e5af4608 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 d1077e67..099659ab 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 fa5aa100..6dfdc7f9 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 5e79ab89a69e9f7eb16e11d3035c9786883b53d9 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 12:47:05 -0300 Subject: [PATCH 022/216] 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 7a842bbd..6f5b5eb0 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 9a1843c9aa19d5a609e3938ef80f95cd557d7ab0 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 13:08:11 -0300 Subject: [PATCH 023/216] 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 2d9abb98..ad1475db 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 4d134d005dbbf067faf38a86240bb152162575bc Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 14:09:15 -0300 Subject: [PATCH 024/216] 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 495335b4..49a9890c 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -197,15 +197,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 @@ -222,13 +222,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 b8dd857f39beb39436795ede11c8f36760d76eed Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 16:32:57 -0300 Subject: [PATCH 025/216] 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 ad1475db..c04e23de 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 fa4d48d7affc2713a4faad94250e8605b5c4fc7c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 16:40:49 -0300 Subject: [PATCH 026/216] 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 c04e23de..c9527068 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 33d1418ff5fcf0649d2810fb2c43673db68b5b37 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 16:46:43 -0300 Subject: [PATCH 027/216] 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 c9527068..b876f936 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 c2175eeae8b9d293b9345af99099cd451a237eef Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 16:51:54 -0300 Subject: [PATCH 028/216] 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 49a9890c..80838bb5 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -199,7 +199,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 eb96ac18..70063514 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -220,16 +220,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 5a172ea373418298c901a54e07781d9662a10c19 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 17:02:58 -0300 Subject: [PATCH 029/216] 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 70063514..6b223a5d 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -298,5 +298,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; } } From e228223881d0d3b9d8d85f173cfbe22a0cc2e557 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 18:10:00 -0300 Subject: [PATCH 030/216] 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 b876f936..881068a7 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 cc46a208917bfc12445ab997e1cd55755b8a06b2 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 18:29:00 -0300 Subject: [PATCH 031/216] 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 881068a7..e0592ffe 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 24fca451184f9c05b00c41018f229ffc2c5e9687 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 11 Aug 2026 18:34:07 -0300 Subject: [PATCH 032/216] 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 e0592ffe..b1da5728 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 a44707fd504a7dda1a03ccfbf8eb2827c2e9ef26 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 12 Aug 2026 09:03:16 -0300 Subject: [PATCH 033/216] 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 6b223a5d..a03c1f18 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -114,12 +114,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 3bda370a..495f8309 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 e44503853ce7fd13e978384a63c8a500b5f17c2f Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 12 Aug 2026 09:20:45 -0300 Subject: [PATCH 034/216] 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 b1da5728..c7aac684 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 3a1d38dadee54a2825050387b730f5979452b504 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 09:51:35 -0300 Subject: [PATCH 035/216] 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 c7aac684..04a992de 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 78e4a793f6bf2a26fb707524838fa8b4aa726ecc Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 10:06:31 -0300 Subject: [PATCH 036/216] 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 04a992de..7cdf6157 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 b5c779f5ea6c935f983f3b8102f1068fcd5805cf Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 10:13:47 -0300 Subject: [PATCH 037/216] 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 7cdf6157..504031fe 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 47ee6a5fc95b3771bfc6a4cd50dedcf9ae9a79a2 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 10:20:38 -0300 Subject: [PATCH 038/216] 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 504031fe..467484bd 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 ab2e47ce969c32e3fea799de459a5f9ad2d0c61e Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 18:15:18 -0300 Subject: [PATCH 039/216] 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 a03c1f18..2aaef9b4 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -52,6 +52,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. @@ -114,6 +115,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 495f8309..1a234b32 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 8799f76813303493fb78b82ecf4ae1f4c32acc96 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 13 Aug 2026 18:22:54 -0300 Subject: [PATCH 040/216] 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 467484bd..04a9f74e 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 e5e693e09e33e9b063fd052e7a8283f1715dabf4 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 12:29:08 -0300 Subject: [PATCH 041/216] 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 04a9f74e..1dff35c0 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 d9146b6b97efcbc51ff399409cb0740493637a86 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 15:02:15 -0300 Subject: [PATCH 042/216] 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 00000000..efa44cec --- /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 3a0526e9f17c68395299f2e7f5d1566ab2e64133 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 16:54:24 -0300 Subject: [PATCH 043/216] 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 00000000..7d67e352 --- /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 2e3e34037be4d7e028210502fb18cee11c937ec5 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 17:08:04 -0300 Subject: [PATCH 044/216] 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 2aaef9b4..7f7d961d 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -50,7 +50,7 @@ export class DataWeave { private readonly addonPath: string; 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; @@ -75,9 +75,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, this.addonPath); @@ -88,8 +95,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. @@ -99,7 +106,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"; } /** @@ -114,14 +121,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 { @@ -134,6 +143,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); @@ -141,7 +154,7 @@ export class DataWeave { } await ffi.cleanup(); } finally { - this.initialized = false; + this.state = "uninitialized"; } } @@ -157,7 +170,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); @@ -182,7 +195,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) @@ -210,7 +223,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"; @@ -234,10 +247,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 00000000..03f9a662 --- /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 01b11363d59a0178248862532774d9aa21b5141e Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 17:23:49 -0300 Subject: [PATCH 045/216] 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 7f7d961d..7ce13ba1 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -121,17 +121,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 1a234b32..807cf898 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 dfa75635f535d5ec6ccb077e7e551e85a76464fc Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 19:31:30 -0300 Subject: [PATCH 046/216] 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 1dff35c0..3dbe8e56 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 00000000..a87b7f18 --- /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 5b33e03da435b305f8197a62f71d56c2f01b1afb Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 19:52:28 -0300 Subject: [PATCH 047/216] 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 3dbe8e56..03559270 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 00000000..b593fa80 --- /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 f5dcde7188ad1695b4c94ca5a3a2343616d59b19 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 14 Aug 2026 20:07:40 -0300 Subject: [PATCH 048/216] 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 b593fa80..2feddfb8 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 792c582895883a67a2dc12f43a457dcfb9db3e49 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 12:01:44 -0300 Subject: [PATCH 049/216] 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 00000000..4fa2c0a1 --- /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 29552e8c835da52c11a7ad50dc2ebcd4ed7d7e77 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 12:41:28 -0300 Subject: [PATCH 050/216] 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 03559270..40130ab1 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 00000000..88dea6a2 --- /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 a24af59e3bbf060d3f866f235dadfbc489b02372 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 14:11:18 -0300 Subject: [PATCH 051/216] 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 40130ab1..0a5d5528 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 9de97a72e416a52de7aaac0f34f97032a94a874e Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 14:18:17 -0300 Subject: [PATCH 052/216] 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 0a5d5528..6f996cb1 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 00000000..3deae4c7 --- /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 49c80140461b7cd84a07f68f8c8ea3c88f02a0f8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 14:23:39 -0300 Subject: [PATCH 053/216] 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 648eeeb4..1549415f 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 8077355ebdefe59d020b0f1391cff70a06dafad2 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 16:18:21 -0300 Subject: [PATCH 054/216] 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 00000000..855f9fdc --- /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 1d520ee26a6b31c78a9d13fdb0e5623c2e569938 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 16:23:32 -0300 Subject: [PATCH 055/216] 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 6f996cb1..49192f7e 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 b4ccb528247444e20ce7a77b2a3ef23a7bdb0481 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 16:27:34 -0300 Subject: [PATCH 056/216] 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 00000000..9322c0de --- /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 ff7815097658b4e2e7734bbe17648427c940f6cc Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 17:16:52 -0300 Subject: [PATCH 057/216] =?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 00000000..4a898c85 --- /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 f34dd47dd91a237f43fb570055b0476c2554c5a9 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 17:27:21 -0300 Subject: [PATCH 058/216] =?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 4a898c85..92eab942 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 2d4ced4adc97d4fd9455d04f3bdd867a1665e462 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 17:43:47 -0300 Subject: [PATCH 059/216] 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 49192f7e..233a67c8 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 7030569f061158a31f4af896929c9c232100d0ee Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 18:55:29 -0300 Subject: [PATCH 060/216] 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 233a67c8..70ab0a4d 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 0e45c2a3de4e5c518bc3704179b6c6803b9dc2ea Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 19:10:20 -0300 Subject: [PATCH 061/216] 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 70ab0a4d..c8103b06 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 fd49179ea22a18b1e3c6adf2b7f7d0534d7769e9 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 18 Aug 2026 19:31:03 -0300 Subject: [PATCH 062/216] 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 c8103b06..8ce90a10 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; }; From d9bde0ed6a7c19c43220257682a124988b788754 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 11:00:41 -0300 Subject: [PATCH 063/216] W-23692110: Remove Java registry entry during Worker/env teardown (round 10 P1) + fix shutdown doc (P2) Round-10 andy review, both findings verified real. P1 (UAF): bridge_env_cleanup freed a resolver-backed engine's bridge but passed do_registry_remove=false, leaving the ScriptRuntime in the Java registry with a CallbackWeaveResourceResolver whose native ctx pointed at the freed bridge -> use-after-free on a later invocation of that handle. This was the round-9 env-cleanup defer choice; round 10 shows it is a bug. Fix: bridge_env_cleanup now removes the registry entry (fn_destroy_engine) on BOTH its direct (in_flight == 0) and deferred (in_flight > 0, drained via bridge_end_op) paths. Renamed the gating field destroy_via_destroy_engine -> deferred_registry_remove since it now covers both the destroyEngine and env-cleanup deferred paths. bridge_finalize's fn_destroy_engine call is now guarded on g_isolate != NULL so a main-env teardown after isolate teardown is a safe no-op rather than fn_attach_thread(NULL, ...). Exactly-once fn_destroy_engine preserved: destroyEngine removes the env cleanup hook, so the two registry-removal paths are mutually exclusive per handle (both run only on the owner JS thread). P2 (doc): dataweave.ts shutdown comment overpromised the exit hook. Node does not emit exit for SIGTERM/SIGINT/SIGKILL or every fatal mode; reworded as best-effort and advised callers to register their own handlers for the catchable signals (SIGKILL cannot be caught). No new runtime test (rounds 6-9 precedent: env-teardown UAF path is not deterministically forceable from JS/vitest). Node suite green: 878/59/0. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 71 ++++++++++++++++++++++---------- native-lib/node/src/dataweave.ts | 15 ++++--- 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 8ce90a10..a3bfda9e 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -102,11 +102,14 @@ 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; + // True when a destroy (via destroyEngine OR the env cleanup hook) was + // deferred because in_flight > 0; gates the deferred fn_destroy_engine + // registry removal in bridge_end_op. round-9 (#1) introduced this for the + // destroyEngine path; round-10 (#1) extended it to bridge_env_cleanup, which + // must ALSO remove the Java registry entry when its free is deferred -- + // otherwise a resolver-backed engine's ScriptRuntime is left registered with + // a CallbackWeaveResourceResolver whose ctx points at the freed bridge (UAF). + bool deferred_registry_remove; struct engine_bridge* next; } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex @@ -207,16 +210,28 @@ 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. -// `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). +// `do_registry_remove` is true when the caller must remove the Java registry +// entry (fn_destroy_engine) for this handle before freeing the record: the +// immediate destroyEngine path, or the deferred drain of either destroyEngine +// (round-9 #1) or the env cleanup hook (round-10 #1). fn_destroy_engine is +// called at most once per handle because destroyEngine and bridge_env_cleanup +// are mutually exclusive (destroyEngine removes the hook). It runs on whichever +// thread finalizes (the owner JS thread from the completion sentinel, +// destroyEngine's thread, or the env-cleanup hook 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) { + // g_isolate is read without g_mutex here -- the same lock-free g_isolate + // read napi_destroy_engine's fallback below already does, but with an added + // NULL check that makes a torn-down isolate a no-op instead of an unsafe + // fn_attach_thread(NULL, ...). This + // matters for the env-cleanup deferred-drain path, where the isolate may + // already be gone (main env tearing down after napi_cleanup tore it down); + // there the Java registry died with the isolate, so there is nothing to + // remove and skipping is correct. + if (do_registry_remove && fn_destroy_engine && g_isolate) { void* thread = NULL; if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, b->handle); fn_detach_thread(thread); } } @@ -253,6 +268,11 @@ static void bridge_env_cleanup(void* arg) { // background thread could still dereference this bridge). if (b->in_flight > 0) { b->destroy_pending = true; + // round-10 (#1): the draining op must ALSO remove the Java registry + // entry (like destroyEngine's deferred path), or the resolver engine's + // ScriptRuntime is left registered with a resolver ctx pointing at the + // freed bridge. Set the deferred-registry-removal flag here. + b->deferred_registry_remove = true; uv_mutex_unlock(&g_mutex); return; } @@ -262,7 +282,15 @@ 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, /*do_registry_remove=*/false); + // round-10 (#1): remove the Java registry entry too (do_registry_remove=true). + // This hook only ever fires for a resolver-backed engine that was never + // passed to destroyEngine (destroyEngine removes this hook), so its + // initialize() ref was never released either -> the isolate is still live + // and fn_destroy_engine's fresh-thread attach is legal (bridge_finalize + // guards on g_isolate for the main-env-after-isolate-teardown corner). Not + // removing it would leave a CallbackWeaveResourceResolver whose ctx is the + // freed bridge -> UAF on a later invocation of this handle. + bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/true); } // Begin a streaming/transform op: look up the engine's record and mark one op in @@ -295,11 +323,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; + bool remove_registry = finalize && b->deferred_registry_remove; uv_mutex_unlock(&g_mutex); - // 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. + // remove_registry is true when either destroyEngine (round-9 #1) or the env + // cleanup hook (round-10 #1) deferred the registry removal while this op was + // in flight; the draining op performs it exactly once here. bridge_finalize + // guards the call on g_isolate, so a teardown that raced ahead is a no-op. if (finalize) bridge_finalize(b, env_still_alive, /*do_registry_remove=*/remove_registry); } @@ -1724,9 +1753,9 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { 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; - // 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; } + // deferred_registry_remove gates the deferred registry removal in + // bridge_end_op; set it together with destroy_pending here. + if (found != NULL && found->in_flight > 0) { found->destroy_pending = true; found->deferred_registry_remove = true; defer = true; } uv_mutex_unlock(&g_mutex); if (found != NULL) { diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 7ce13ba1..14bff7e5 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -276,11 +276,16 @@ let cleanupStarted = false; * 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. + * - `exit` runs strictly synchronously and is only a best-effort fallback for + * the paths that skip `beforeExit` — `process.exit()`, an uncaught + * exception, and normal process termination. Because it is synchronous it + * can only run the fast cleanup path, so an in-flight async operation may be + * abandoned. Node does NOT emit `exit` (nor `beforeExit`) for termination + * signals such as SIGTERM/SIGINT/SIGKILL, nor for every fatal failure mode, + * so this is not a guarantee: callers that require graceful shutdown must + * register and await their own handlers for the catchable signals (e.g. + * `process.on("SIGTERM", async () => { await cleanup(); process.exit(0); })`); + * SIGKILL cannot be caught, so no in-process cleanup can run for it. * The `cleanupStarted` guard ensures only one of the two hooks actually * runs cleanup for a given shutdown. */ From c9989b83bc7ed9e9a040eb4889954d2b28258ab6 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 11:00:41 -0300 Subject: [PATCH 064/216] docs: round-10 design spec (worker-teardown dangling resolver ctx + shutdown doc) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...r-teardown-dangling-resolver-ctx-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-worker-teardown-dangling-resolver-ctx-design.md diff --git a/docs/superpowers/specs/2026-08-19-worker-teardown-dangling-resolver-ctx-design.md b/docs/superpowers/specs/2026-08-19-worker-teardown-dangling-resolver-ctx-design.md new file mode 100644 index 00000000..69686ae2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-worker-teardown-dangling-resolver-ctx-design.md @@ -0,0 +1,104 @@ +# Worker-Teardown Dangling Resolver Ctx & Shutdown-Doc Accuracy — Round 10 (W-23692110) + +**Status:** Design approved (lightweight round), ready for direct implementation. + +**Source review:** `docs/pr-157-follow-up-andy-code-review-10.md` (two findings, both verified against live source at commit `d504c0f`, the round-9 tip). + +**Scope:** `native-lib/node` only — `src/addon.c` (finding 1) and `src/dataweave.ts` (finding 2). Do **not** touch `native-lib/python/**`, the legacy singleton entrypoints, or `ScriptRuntime.getInstance()`. The Java side is not modified — the C addon must stop leaving a live registry entry pointed at freed memory rather than change Java's registry semantics. + +## Problem + +### #1 (P1) — Worker teardown frees a resolver bridge but leaves its Java registry entry (and resolver ctx) dangling + +`napi_create_engine_with_resolver` passes the `engine_bridge_t* bridge` to Java as the resolver ctx (`addon.c:1640`); Java's `CallbackWeaveResourceResolver` retains it, and `resolve_module_callback` casts that same ctx word back to `engine_bridge_t*` (`addon.c:1450`). + +When the owning Worker/main env tears down, the per-env cleanup hook `bridge_env_cleanup` runs. It **frees** the bridge — `bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/false)` at `addon.c:265` — but deliberately passes `do_registry_remove=false`, so it does **not** call `fn_destroy_engine`. The `ScriptRuntime` stays in the Java registry with a `CallbackWeaveResourceResolver` whose ctx now points at freed native memory. A subsequent invocation of that handle dereferences freed memory (UAF). + +This is exactly the round-9 decision: round 9 gave every engine a record and deferred registry removal for the `destroyEngine` path, but chose `do_registry_remove=false` on the env-cleanup path (`addon.c:105-108`) out of caution about calling `fn_destroy_engine` during env teardown. Round 10 shows that caution was wrong: leaving the registry entry is a UAF. + +**Both env-cleanup sub-paths have the gap:** +- Direct free (`in_flight == 0`, `addon.c:265`): frees with `do_registry_remove=false`. +- Deferred (`in_flight > 0`, `addon.c:254-258`): sets `destroy_pending=true` but leaves `destroy_via_destroy_engine=false`, so the later `bridge_end_op` → `bridge_finalize` drain (`addon.c:297-303`) also skips the registry removal. + +### #2 (P2) — Shutdown doc over-promises `exit`-hook coverage + +`dataweave.ts:276-280` says the synchronous `exit` hook is "the last-ditch fallback for `process.exit()`, uncaught exceptions, and fatal signals." Node does **not** emit `exit` for termination signals such as SIGTERM/SIGKILL (absent a JS signal handler), nor for all fatal failure modes. The comment should describe `exit` as best-effort only and tell callers who need guaranteed graceful shutdown to register and await their own signal handlers. + +## Design + +### 1. Remove the registry entry during env cleanup (finding #1) + +Make `bridge_env_cleanup` remove the Java registry entry before/when it frees the bridge, on **both** sub-paths, guarded on isolate liveness. + +**Why calling `fn_destroy_engine` here is safe (the round-9 caution, resolved):** +- `bridge_env_cleanup` is registered **only for resolver-backed engines** (`addon.c:1666`; resolver-less engines register no hook, `addon.c:1598-1601`), so this path is exactly the dangling-ctx case. +- `destroyEngine` removes the hook (`napi_remove_env_cleanup_hook`, `addon.c:1738`) for any engine it handles — deferred or not — so `bridge_env_cleanup` only ever fires for an engine that was **never** passed to `destroyEngine`. Such an engine's `initialize()` ref was likewise never released (both go through `doCleanup()`), so `g_ref_count > 0` and the process-wide GraalVM isolate is still alive: `fn_destroy_engine`'s fresh-thread attach is legal. +- `fn_destroy_engine` attaches its **own** isolate thread (not JS-thread-affine), so it is safe from the env-cleanup hook thread — the same property `destroyEngine`'s deferred-drain finalize already relies on. +- **The one exception:** the main env can tear down *after* `napi_cleanup` already tore down the isolate (`g_isolate == NULL`). Then the Java registry died with the isolate and there is nothing to remove — so the registry removal must be **guarded on `g_isolate != NULL`**. + +**Exactly-once preserved:** `destroyEngine` and `bridge_env_cleanup` are mutually exclusive per handle (destroyEngine removes the hook), so `fn_destroy_engine` still runs at most once per handle. + +**Changes (`addon.c`):** + +a. **Harden `bridge_finalize`'s registry-removal guard** to skip when the isolate is gone — protects every caller and covers the "isolate torn down by drain time" case for the deferred path: +```c +if (do_registry_remove && fn_destroy_engine && g_isolate) { + void* thread = NULL; + if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, b->handle); fn_detach_thread(thread); } +} +``` +(`g_isolate` is read outside `g_mutex` here — the same accepted pattern as `napi_destroy_engine`'s fallback at `addon.c:1753-1756`; the NULL check narrows the window and makes a torn-down isolate a no-op instead of an unsafe `fn_attach_thread(NULL, …)`.) + +b. **`bridge_env_cleanup` direct path** (`addon.c:265`): pass `do_registry_remove=true`: +```c +bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/true); +``` + +c. **`bridge_env_cleanup` deferred path** (`addon.c:254-258`): set the deferred-registry-removal flag so the draining op removes the entry: +```c +if (b->in_flight > 0) { + b->destroy_pending = true; + b->deferred_registry_remove = true; // env-cleanup, like destroyEngine, must remove the registry on drain + uv_mutex_unlock(&g_mutex); + return; +} +``` + +d. **Rename `destroy_via_destroy_engine` → `deferred_registry_remove`.** The field now gates the deferred registry removal for **both** `destroyEngine` and `bridge_env_cleanup`, so the old name (implying "only via destroyEngine") is actively misleading. Update the declaration/comment (`addon.c:105-109`), the set site in `napi_destroy_engine` (`addon.c:1729`), the new set site in `bridge_env_cleanup`, and the read in `bridge_end_op` (`addon.c:298`). Update the stale comments at `addon.c:105-108`, `261-265`, and `300-302` to state that the env-cleanup path now removes the registry. + +### 2. Correct the shutdown doc (finding #2) + +Reword `dataweave.ts:276-280` so the `exit` hook is described as best-effort synchronous cleanup that runs for `process.exit()`, uncaught exceptions, and normal process end — and explicitly note that Node does **not** emit `exit` for termination signals (SIGTERM/SIGKILL) or all fatal failure modes, so callers needing guaranteed graceful shutdown must register and await their own signal handlers. Doc-only; no behavior change. + +## Testing + +**No new runtime test.** Consistent with rounds 6–9: the env-teardown UAF path is not deterministically forceable from JS/vitest (it requires a Worker to exit with a live resolver engine and then re-invoke a freed handle across the teardown boundary — no addon-boundary fault-injection exists). Coverage is code reasoning against the exactly-once and isolate-liveness invariants above. #2 is doc-only. + +Baseline unchanged: **878 passed / 59 skipped / 0 failed**. + +## 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. +- `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 or a synchronous `napi_throw_error` — never `napi_reject_deferred`. +- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread. +- All shared C state (incl. every engine record's `in_flight`/`destroy_pending`/`deferred_registry_remove`) is read/written only under `g_mutex`, except the documented lock-free `g_isolate` NULL-check in `bridge_finalize` (matching the existing `napi_destroy_engine` fallback pattern). +- `fn_destroy_engine` is called **exactly once** per handle — the `destroyEngine` and `bridge_env_cleanup` paths stay mutually exclusive via hook removal. +- The owner-thread `destroyEngine` restriction stays scoped to engines with resolver `napi_ref` state. +- Preserve every round-1..9 fix. +- Node vitest baseline **878 passed / 59 skipped / 0 failed**. + +## Rejected Alternatives + +- **Leave the env-cleanup path as `do_registry_remove=false` and instead make Java's registry tolerate a freed ctx.** Rejected: out of scope (Node-binding-only) and the wrong layer — the addon must not leave a live registry entry pointing at freed memory. It also cannot: the ctx is opaque to Java. +- **Null the bridge's resolver fields instead of removing the registry entry, so a later `resolve_module_callback` fails closed.** Rejected: the bridge memory is freed, so there is nothing left to null; and the `ScriptRuntime` itself (script cache, module loader) would leak in the Java registry forever. Removing the registry entry reclaims both. +- **Unconditionally call `fn_destroy_engine` without the `g_isolate` guard.** Rejected: at main-env teardown after isolate destruction, `g_isolate == NULL` and `fn_attach_thread(NULL, …)` is unsafe; the registry is already gone, so the call is both dangerous and pointless. +- **Add a runtime regression test.** Rejected: not deterministically forceable (rounds 6–9 precedent); no addon-boundary fault injection for the Worker-exit-then-reinvoke race. +- **Keep the field name `destroy_via_destroy_engine`.** Rejected: after this change it also gates the env-cleanup path, so the name would misdescribe half its uses. From 66f7a92844db40758e6d283c01b7939f6ff51730 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 12:59:07 -0300 Subject: [PATCH 065/216] docs: round-11 design spec (engine-pin at admission + all-engines cleanup hook) Covers the 6 real findings from andy-code-review-11 + follow-up-code-review-2: per-engine pin folded into the locked admission transaction (streaming, transform, sync run), env cleanup hook for every engine (+ owner-thread destroy guard extended to all engines), register process exit hooks once, and real Node integration tests for the *_engine unknown/destroyed-handle contract. The dwlib C ABI break is documented as intended (no shims). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...e-pin-and-cleanup-hook-hardening-design.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-engine-pin-and-cleanup-hook-hardening-design.md diff --git a/docs/superpowers/specs/2026-08-19-engine-pin-and-cleanup-hook-hardening-design.md b/docs/superpowers/specs/2026-08-19-engine-pin-and-cleanup-hook-hardening-design.md new file mode 100644 index 00000000..fb933c9e --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-engine-pin-and-cleanup-hook-hardening-design.md @@ -0,0 +1,147 @@ +# Engine-Pin & All-Engines-Cleanup Hardening — Round 11 (W-23692110) + +**Status:** Design approved, ready for planning. + +**Source reviews:** `docs/pr-157-follow-up-andy-code-review-11.md` (2 findings) and `docs/pr-157-follow-up-code-review-2.md` (6 findings). All overlapping; deduplicated into 6 work items below. Verified against live source at commit `50b2930` (round-10 tip). + +**Scope:** `native-lib/node` only — `src/addon.c`, `src/dataweave.ts`, and Node integration tests under `tests/`. Do **not** touch `native-lib/python/**`, the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`), `dw_napi_run_script`, or `ScriptRuntime.getInstance()`. The Java side (`NativeLib.java`, `ScriptRuntime`) is read for context but not modified. + +## Problem + +The 11th "andy" review and a second general code review together raise 7 findings; 6 are real and one (the C ABI break) is a documented-by-design decision, not a code change. + +### #1 (P1) — Resolver-less engines leak on Worker exit (no env cleanup hook) + +`napi_create_engine` (resolver-less, `addon.c:1644`) links a per-engine record into `g_bridges` but registers **no** `napi_add_env_cleanup_hook`; only `napi_create_engine_with_resolver` does (`addon.c:1695`). A Worker (or the main thread) that creates a resolver-less `DataWeave` instance and terminates without calling `destroyEngine()` strands: the native `engine_bridge_t` record, the Java `ScriptRuntime` registry entry, and the native-library reference (`g_ref_count` never decremented for that instance). Repeated Worker create/terminate cycles leak engines and prevent isolate teardown. + +### #2 (P1) — Streaming/transform admission reserves the isolate before pinning the engine + +`napi_run_script_streaming_engine` reserves `g_active_ops++` at `addon.c:841` but does not pin the engine (`bridge_begin_op`) until `addon.c:925` — a wide window (arg extraction, `w`/tsfn/promise allocation) in which a concurrent Worker's `destroyEngine(handle)` observes `in_flight == 0`, unlinks and frees the bridge, and removes the Java registry entry. The already-admitted op then spawns its worker with `w->bridge` pointing at freed memory (or NULL after the fact) and can fail with "Unknown engine handle" or dereference the freed bridge in `resolve_module_callback`. `napi_run_script_transform_engine` has the identical shape (`g_active_ops++` at `addon.c:1324`, `bridge_begin_op` at `addon.c:1429`). + +### #3 (P1) — Synchronous `runScriptEngine` never pins the engine at all + +`napi_run_script_engine` (`addon.c:1791-1879`) increments `g_active_ops` (`:1847`) to protect the isolate but never calls `bridge_begin_op`. A concurrent Worker can `destroyEngine(handle)` while this synchronous call is attaching to Graal or executing `fn_run_script_engine` (`:1858`); for a resolver-backed engine that frees the bridge Java still holds as the resolver ctx → `resolve_module_callback` dereferences freed memory. `g_active_ops` gates only the *global isolate*, not the *per-engine* record. + +### #4 (documented, not a code change) — dwlib C ABI break + +This branch removes the exported `run_script_with_resolver` / `run_script_callback_with_resolver` / `run_script_input_output_callback_with_resolver` entrypoints (present on master) and replaces them with `create_engine` / `create_engine_with_resolver` / `destroy_engine` / `run_script_engine` / `run_script_callback_engine` / `run_script_input_output_callback_engine`, and inserts a `ctx` parameter into the `ResolveModuleCallback` signature. The three legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`) are preserved. This is the intended multi-engine redesign; dwlib is consumed by this repo's own Python and Node bindings in lockstep. **Decision (user):** document the break in the PR/spec; do NOT add compatibility shims. No code change in this round. + +### #5 (Medium) — Process exit listeners accumulate across singleton re-creation + +`getGlobalInstance` (`dataweave.ts:289-304`) attaches a `beforeExit` and an `exit` listener every time it (re)creates `globalInstance`; the module-level `cleanup()` (`:341-353`) nulls the singleton but never removes those listeners. Repeated init→cleanup→reinit cycles accumulate two listeners per cycle and eventually emit Node's `MaxListenersExceededWarning`. + +### #6 (Medium) — Unknown-handle coverage does not exercise the native entrypoints + +`ScriptRuntimeTest.unknownEngineHandleProducesExactErrorJson` (`ScriptRuntimeTest.java:677-683`) only asserts on the `UNKNOWN_ENGINE_HANDLE_JSON` constant and `ScriptRuntime.get`; it deliberately cannot invoke the `@CEntryPoint` methods (GraalVM word types don't box in a hosted JVM). So no test drives the `*_engine` entrypoints against unknown/destroyed handles through the real addon, nor exercises the cross-Worker run-vs-destroy race in #2/#3. + +## Design + +### 1. Register an env cleanup hook for every engine + extend the owner-thread destroy guard (finding #1) + +**Cleanup hook for all engines.** In `napi_create_engine`, store `rec->env = env` and register `napi_add_env_cleanup_hook(env, bridge_env_cleanup, rec)` — exactly as `napi_create_engine_with_resolver` already does. `bridge_env_cleanup` and `bridge_finalize` already handle a resolver-less record correctly: `resolver_js == NULL` → skip `napi_delete_reference`, still unlink from `g_bridges`, remove the Java registry entry (round-10 `do_registry_remove=true`), and free the record. So the round-10 registry-removal path now also reclaims resolver-less engines abandoned by a terminating env. `rec->owner` is already recorded (`addon.c:1643`). + +**Owner-thread destroy guard extends to all engines (approved contract change).** Registering a cleanup hook gives every engine env-affine state: the hook is bound to its creating env, and `napi_remove_env_cleanup_hook` (called by `destroyEngine` before an early free, `addon.c:1738`) is only valid on that owner env/thread. Today the cross-thread guard in `napi_destroy_engine` (`addon.c:1703`) fires only when `owned->resolver_js != NULL`. Change it to fire for **any** record (`owned != NULL`), so a resolver-less engine is also only destroyable from its creating thread. + +- **Why this is safe:** every JS `DataWeave` instance is constructed and destroyed on a single thread (its owning env), so the guard never rejects a legitimate call. This reverses the round-9 invariant "resolver-less engines remain destroyable from any thread," which was only ever exercised by the (now-closed) case of a resolver-less engine having no env-affine state. +- **Why the alternative is worse:** leaving the guard resolver-only while registering a hook means a cross-thread `destroyEngine` would either skip `napi_remove_env_cleanup_hook` (leaving Node holding a hook pointing at a freed record → UAF at env teardown) or call it cross-thread (undefined behavior). Extending the guard is the correct closure. + +Update the guard's comment block (`addon.c:1683-1700`) to state the guard now keys on "a record exists" because every engine carries an env cleanup hook, not just resolver `napi_ref` state. + +**`bridge_finalize` napi_ref deletion stays resolver-gated** (`addon.c:237`: `resolver_js != NULL && env != NULL`) — a resolver-less record has no ref to delete; only the hook registration and the owner guard change. + +### 2. Fold engine lookup + `in_flight++` into the locked admission transaction (findings #2, #3) + +Introduce a locked-admission variant so the per-engine pin happens in the **same** critical section as the `g_active_ops` reservation and lifecycle check, before any window a concurrent `destroyEngine` could use. + +**New helper** (`addon.c`, near `bridge_begin_op`): +```c +// Increment this engine's in_flight while g_mutex is ALREADY held (admission +// transaction). Caller must hold g_mutex. Returns the record (NULL if unknown +// handle -- nothing to pin, worker will surface "Unknown engine handle"). +static engine_bridge_t* bridge_begin_op_locked(long long handle) { + engine_bridge_t* b = bridge_find(handle); + if (b != NULL) b->in_flight++; + return b; +} +``` +`bridge_begin_op` stays for callers that need the self-locking form; internally it becomes `lock; b = bridge_begin_op_locked(handle); unlock; return b;`. + +**Streaming / transform:** in the admission critical section (`addon.c:835-842` / `1318-1325`), after `g_active_ops++`, also call `w->bridge = bridge_begin_op_locked(handle64)` **before** unlocking, and delete the later standalone `bridge_begin_op` call (`:925` / `:1429`). Every existing failure path between admission and the worker spawn (conversion errors, OOM, tsfn/promise creation failures, `spawn_rc != 0`) must now **also** release the pin. Because those paths currently only do the `g_active_ops--` release, each must additionally call `bridge_end_op(w->bridge, /*env_still_alive=*/true)` (the env is live on the JS admission thread) to balance `in_flight` and finalize if a concurrent destroy is now pending. The completion sentinel path is unchanged — it already calls `bridge_end_op`. + +- **Ordering:** with the pin taken under the same lock as the admission check, a concurrent `destroyEngine` either runs entirely before admission (then `bridge_find` in admission returns the record only if not yet destroyed; if already destroyed, the record is gone and the worker surfaces "Unknown engine handle" — no freed access) or entirely after (then `in_flight > 0`, so destroy defers per round-9/10). There is no interleaving where an admitted op observes a freed bridge. +- **Unwind completeness:** the plan must enumerate every early-return between the locked admission and the spawn and add the `bridge_end_op` release, mirroring how each already releases `g_active_ops`. A pin leaked here would wedge `destroyEngine` (never drains) exactly like a leaked `g_active_ops` wedges teardown. + +**Synchronous `runScriptEngine`:** pin the engine for the isolate-touching window. Because this path reserves `g_active_ops` *late* (`addon.c:1840-1848`, after arg extraction), take the pin in that same critical section: +```c +uv_mutex_lock(&g_mutex); +if (!g_initialized || (g_teardown_state != TEARDOWN_NONE && !g_teardown_cancelled)) { ... release, throw ... } +g_active_ops++; +engine_bridge_t* bridge = bridge_begin_op_locked(handle); +uv_mutex_unlock(&g_mutex); +``` +Then release the pin in **both** the attach-failure path and normal completion, alongside the existing `g_active_ops--`. The current post-run `bridge_find` + `resolver_results_free_all` (`addon.c:1860-1863`) uses the pinned `bridge` directly (no second lookup needed; the pin kept it alive). Release ordering at completion: after `resolver_results_free_all` and detach, call `bridge_end_op(bridge, /*env_still_alive=*/true)` — which may finalize a deferred destroy — then the existing `g_active_ops--` broadcast. `bridge_end_op` handles `NULL` (unknown handle) as a no-op. + +- **Sync-path note:** unlike streaming/transform there is no background thread, so `env_still_alive` is always true here (the JS thread runs the whole op). An unknown handle (`bridge == NULL`) still runs `fn_run_script_engine`, which returns the resolved "Unknown engine handle" JSON — behavior unchanged. + +### 3. Register process exit listeners exactly once (finding #5) + +Move the `beforeExit`/`exit` registration out of `getGlobalInstance` so it runs once per module, guarded by a module-scoped `let exitHooksRegistered = false` that is **never reset** (unlike `cleanupStarted`). The listeners already tolerate a null `globalInstance`: `cleanup()` no-ops when `globalInstance` is null, and `cleanupStarted` still coalesces `beforeExit`/`exit` for a given shutdown. So one registration covers every current and future revived singleton, and init→cleanup→reinit cycles no longer accumulate listeners. + +```ts +let exitHooksRegistered = false; +function registerExitHooksOnce(): void { + if (exitHooksRegistered) return; + exitHooksRegistered = true; + process.on("beforeExit", async () => { if (cleanupStarted) return; cleanupStarted = true; await cleanup(); }); + process.on("exit", () => { if (cleanupStarted) return; cleanup(); }); +} +``` +`getGlobalInstance` calls `registerExitHooksOnce()` after `globalInstance.initialize()`. Update the doc comment (`dataweave.ts:267-287`) to say the hooks are registered once for the process, not per singleton. + +### 4. Real *_engine unknown/destroyed-handle + run-vs-destroy tests (finding #6) + +Add **Node integration tests** (real addon, `vi.mock` of `ffi` is forbidden — mirror `tests/integration/independent-engines.test.ts`): + +- **Unknown / destroyed handle envelope:** for each of `runScriptEngine` (sync), `runScriptStreamingEngine`, `runScriptTransformEngine`, invoke against (a) a never-registered handle and (b) a handle whose engine was `destroyEngine`'d, and assert the result is the terminal `{"success":false,"error":"Unknown engine handle"}` envelope (resolved, not thrown for the async ops; the sync op returns the JSON string) and that the process does not crash and no C string leaks (the op resolves/returns cleanly). +- **Cross-Worker run-vs-destroy (findings #2/#3):** spin a `worker_threads` Worker that creates an engine and runs a stream/transform, and from another context destroy/cleanup during the admission window, asserting no crash and a clean terminal result. Note in the test file that this race is **not** deterministically forceable at a fixed interleaving (same limitation rounds 5–10 documented); the test is a best-effort probabilistic guard (loop N iterations) that is green on fixed code and cannot false-fail on it. If a deterministic hook proves infeasible, the test still asserts the unknown/destroyed-handle envelope contract, which is deterministic, and the concurrency correctness rests on the code reasoning in §2. + +These raise the vitest baseline above 878. The plan sets the exact new counts. + +## Testing + +- New Node integration tests per §4 (deterministic envelope assertions + best-effort race guard). +- No Java test change (the `@CEntryPoint` hosted-JVM limitation is real; coverage moves to the Node integration layer against the real addon, which is the correct layer). +- Findings #1/#2/#3 lifecycle correctness that is not deterministically forceable is covered by code reasoning against the invariants in §Design (same documented posture as rounds 5–10). + +## Verification + +- `cd native-lib/node && npm run build:addon` clean (no new warnings in touched regions); `npm run build` (tsc) clean. +- `npm test` green at the new baseline (set in the plan; ≥ 878 + new 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`), `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 (async) or a synchronous `napi_throw_error` (admission/arg/alloc/resource failures) — never `napi_reject_deferred`. +- `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`, every engine record's `in_flight`/`destroy_pending`/`deferred_registry_remove`) is read/written only under `g_mutex`, except the documented lock-free `g_isolate` NULL-check in `bridge_finalize`. +- 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. +- Every engine now carries an env cleanup hook, so the owner-thread `destroyEngine` guard keys on "a record exists," not on resolver `napi_ref` state. `bridge_finalize`'s `napi_ref` deletion stays resolver-gated. +- Per-engine `in_flight` and global `g_active_ops` stay **distinct** counters (per-handle registry drain vs. global isolate teardown) — not merged. +- Preserve every round-1..10 fix: coalesced `cleanup()`, the JS three-state lifecycle machine, the `TEARDOWN_*` state machine + `napi_initialize` adoption path (incl. round-7's `g_teardown_cancelled` carve-out), the worker-thread `g_active_ops` decrement, the atomic admission blocks, the round-6 handle-read validations, round-7 conversion-status checks, round-8 setup-allocation NULL checks, round-9 worker/callback OOM + N-API-create checks + deferred registry removal, round-10 env-cleanup registry removal + `g_isolate`-guarded finalize. +- Node vitest baseline currently **878 passed / 59 skipped / 0 failed**; this round raises it (new tests) and must stay green. + +## Rejected Alternatives + +- **#1 via a teardown-time sweep of `g_bridges` instead of per-engine hooks.** Rejected: a global sweep would run on whatever thread triggers isolate teardown, deleting env-affine records off their owner thread — the exact thread-affinity violation the per-env-hook design (F2) exists to avoid. Per-engine hooks dispose each record on its own env's thread. +- **#1 leaving the owner guard resolver-only while adding a hook to resolver-less engines.** Rejected: `napi_remove_env_cleanup_hook` on an early destroy would then run cross-thread (UB) or be skipped (dangling hook → UAF at env teardown). The guard must cover every hooked engine. +- **#2/#3 via a JS-side lease (await per-engine drain before destroy).** Rejected (same as round-9): no per-engine "await my ops" primitive exists at the JS layer; `run()` is synchronous and streaming is an abandonable generator. The authoritative pin lives in C, taken atomically at admission. +- **#2/#3 by re-looking-up the bridge after admission.** Rejected: a second lookup still races destroy in the gap; only holding the pin (`in_flight++`) under the admission lock closes the window. +- **#3 pinning the sync run at the top (before arg extraction).** Rejected: the arg-extraction/OOM path does not touch the engine, so pinning there only adds unwind sites; pin in the same late critical section as `g_active_ops`, matching the existing round-7 reasoning for that path. +- **#4 compatibility shims for the removed `*_with_resolver` ABI.** Rejected (user decision): dwlib is consumed by this repo's own bindings in lockstep; the redesign intentionally replaces that ABI. Documented as an intended break; no shims. +- **#5 removing listeners in `cleanup()` (retain references, `removeListener`).** Rejected in favor of register-once: simpler, no per-instance bookkeeping, and the hooks already tolerate a null singleton, so a single lifetime registration is correct and leak-free. +- **#6 adding a native fault-injection hook to force the race deterministically.** Rejected as test-only production surface (YAGNI), consistent with rounds 6–10; the deterministic envelope assertions plus a best-effort probabilistic race guard are the coverage. +- **Modifying the Java `ScriptRuntime` registry to tolerate late lookups.** Rejected as out of scope and the wrong layer — the C addon must hold the pin so the registry entry is never removed under an admitted op. From 08078ce2be831830692e9cf06e2bec86725ed218 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 13:06:16 -0300 Subject: [PATCH 066/216] W-23692110: Extract bridge_begin_op_locked for atomic admission-time engine pin Foundation for round-11 findings #2/#3: a locked-precondition variant of bridge_begin_op so the per-engine in_flight increment can happen inside the same g_mutex critical section as the g_active_ops admission reservation. bridge_begin_op now wraps it; behavior for existing callers is unchanged. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index a3bfda9e..e7ac9983 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -293,6 +293,19 @@ static void bridge_env_cleanup(void* arg) { bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/true); } +// Increment this engine's in_flight while g_mutex is ALREADY held. Used by the +// run/streaming/transform admission paths so the per-engine pin is taken in the +// SAME critical section as the g_active_ops reservation and the lifecycle check +// -- closing the round-11 window where a concurrent destroyEngine could observe +// in_flight == 0 and free the bridge under an already-admitted op. Returns the +// record, or NULL for an unknown handle (nothing to pin; the worker/native call +// surfaces "Unknown engine handle"). Caller MUST hold g_mutex. +static engine_bridge_t* bridge_begin_op_locked(long long handle) { + engine_bridge_t* b = bridge_find(handle); + if (b != NULL) b->in_flight++; + return b; +} + // 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 @@ -303,10 +316,13 @@ static void bridge_env_cleanup(void* arg) { // 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. +// Self-locking form of bridge_begin_op_locked: acquires g_mutex itself. Callers +// that need the pin taken atomically with another g_mutex-guarded check (e.g. +// the round-11 admission path) should call bridge_begin_op_locked directly +// instead. The completion sentinel MUST call bridge_end_op to balance in_flight. 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++; + engine_bridge_t* b = bridge_begin_op_locked(handle); uv_mutex_unlock(&g_mutex); return b; } From 811e8a18f48ddb8c934ce15d79668af67dff24bd Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 13:20:35 -0300 Subject: [PATCH 067/216] W-23692110: Pin engine in the admission transaction for streaming/transform (round 11 #2) Streaming and transform reserved g_active_ops and only pinned the engine (bridge_begin_op) much later, leaving a window where a concurrent destroyEngine saw in_flight == 0, freed the bridge, and removed the Java registry entry -- so the admitted op could start against a freed bridge or a missing registry entry. Take the pin (bridge_begin_op_locked) in the SAME g_mutex critical section as g_active_ops++, and release it via bridge_end_op on every early-return between admission and worker spawn (including the TRANSFORM_FAIL macro). The completion sentinel / spawn-failure path releases it exactly once, as before. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 112 +++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 47 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index e7ac9983..df8b0d92 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -306,26 +306,20 @@ static engine_bridge_t* bridge_begin_op_locked(long long handle) { return b; } -// 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 +// A streaming/transform/run op marks one op in flight on the engine's record 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. -// Self-locking form of bridge_begin_op_locked: acquires g_mutex itself. Callers -// that need the pin taken atomically with another g_mutex-guarded check (e.g. -// the round-11 admission path) should call bridge_begin_op_locked directly -// instead. The completion sentinel MUST call bridge_end_op to balance in_flight. -static engine_bridge_t* bridge_begin_op(long long handle) { - uv_mutex_lock(&g_mutex); - engine_bridge_t* b = bridge_begin_op_locked(handle); - uv_mutex_unlock(&g_mutex); - return b; -} +// engine (resolver-backed or resolver-less) now has a record, so +// bridge_begin_op_locked 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. Since round-11 (#2), every call site +// takes the pin atomically with its g_mutex-guarded admission check via +// bridge_begin_op_locked directly (no self-locking wrapper) -- see +// napi_run_script_streaming_engine / napi_run_script_transform_engine. // 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 @@ -855,17 +849,25 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i return NULL; } g_active_ops++; + // Round-11 (#2): pin the engine in the SAME critical section as the + // g_active_ops reservation, before any window a concurrent destroyEngine + // could use. NULL for an unknown handle (the worker surfaces "Unknown engine + // handle"). Stashed on w->bridge once w is allocated; every early-return + // below releases it via bridge_end_op alongside g_active_ops. + engine_bridge_t* pinned = bridge_begin_op_locked((long long)handle64); 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; if (napi_get_value_string_utf8(env, argv[1], NULL, 0, &script_len) != napi_ok) { + bridge_end_op(pinned, /*env_still_alive=*/true); 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) { + bridge_end_op(pinned, /*env_still_alive=*/true); 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; @@ -878,6 +880,7 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i 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. + bridge_end_op(pinned, /*env_still_alive=*/true); 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; @@ -887,6 +890,7 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i w->inputs_json = malloc(inputs_len + 1); if (w->script == NULL || w->inputs_json == NULL) { free(w->script); free(w->inputs_json); free(w); + bridge_end_op(pinned, /*env_still_alive=*/true); 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; @@ -894,27 +898,31 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i 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); + bridge_end_op(pinned, /*env_still_alive=*/true); 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; } - // 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). + // Round-9 (#3, updated round-11 #2): the resource creations below run AFTER + // g_active_ops was reserved (and after w + its buffers were allocated), and + // the engine pin (`pinned`) was already taken at admission. A failed create + // must release both the pin (bridge_end_op) and 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), the pin is + // stranded (blocks destroyEngine forever), or g_active_ops is stranded + // (teardown wedge). napi_value resource_name; if (napi_create_string_utf8(env, "dwStreaming", NAPI_AUTO_LENGTH, &resource_name) != napi_ok) { free(w->script); free(w->inputs_json); free(w); + bridge_end_op(pinned, /*env_still_alive=*/true); 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); + bridge_end_op(pinned, /*env_still_alive=*/true); 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; @@ -926,19 +934,18 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i // 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); + bridge_end_op(pinned, /*env_still_alive=*/true); 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 - // 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); + // Round-11 (#2): the pin was taken at admission (bridge_begin_op_locked) in + // the same critical section as g_active_ops, so a concurrent destroyEngine + // could never free this bridge under the admitted op. Just record it on w; + // the completion sentinel releases it via bridge_end_op. NULL for a + // resolver-less/unknown engine, handled everywhere as a no-op. + w->bridge = pinned; uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; @@ -1338,6 +1345,12 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i return NULL; } g_active_ops++; + // Round-11 (#2): pin the engine in the SAME critical section as the + // g_active_ops reservation, before any window a concurrent destroyEngine + // could use. NULL for an unknown handle (the worker surfaces "Unknown engine + // handle"). Stashed on w->bridge once w is allocated; every early-return + // below releases it via bridge_end_op alongside g_active_ops. + engine_bridge_t* pinned = bridge_begin_op_locked((long long)handle64); uv_mutex_unlock(&g_mutex); // Conversions run after the admission reservation above, so any throw here @@ -1351,6 +1364,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i // this standalone branch cannot use it (the macro dereferences w). struct transform_work* w = calloc(1, sizeof(struct transform_work)); if (w == NULL) { + bridge_end_op(pinned, /*env_still_alive=*/true); 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; @@ -1359,6 +1373,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i w->handle = (long long)handle64; #define TRANSFORM_FAIL(msg) do { \ + bridge_end_op(pinned, /*env_still_alive=*/true); \ 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); \ @@ -1398,14 +1413,16 @@ 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. + // Round-9 (#3, updated round-11 #2): check each resource creation; on + // failure release the engine pin (`pinned`, taken at admission) via + // bridge_end_op, release g_active_ops (verbatim), release any tsfn already + // created, free w + all five string buffers, and throw. 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; 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); + bridge_end_op(pinned, /*env_still_alive=*/true); 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; @@ -1413,6 +1430,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i 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); + bridge_end_op(pinned, /*env_still_alive=*/true); 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; @@ -1420,6 +1438,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i 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); + bridge_end_op(pinned, /*env_still_alive=*/true); 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; @@ -1430,19 +1449,18 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i 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); + bridge_end_op(pinned, /*env_still_alive=*/true); 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 - // 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); + // Round-11 (#2): the pin was taken at admission (bridge_begin_op_locked) in + // the same critical section as g_active_ops, so a concurrent destroyEngine + // could never free this bridge under the admitted op. Just record it on w; + // the completion sentinel releases it via bridge_end_op. NULL for a + // resolver-less/unknown engine, handled everywhere as a no-op. + w->bridge = pinned; uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; From 166430ec882e618ec38c404df3a652bf41b24359 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 13:31:51 -0300 Subject: [PATCH 068/216] W-23692110: Pin the engine for synchronous runScriptEngine (round 11 #3) The synchronous run path reserved g_active_ops (global isolate guard) but never pinned the specific engine, so a concurrent destroyEngine could free the resolver bridge -- still held by Java as the resolver ctx -- while this call was attaching to Graal or executing, causing a use-after-free in resolve_module_callback. Take the pin (bridge_begin_op_locked) in the same critical section as g_active_ops, reuse it for the post-run resolver_results_free_all (dropping a redundant second lookup), and release it via bridge_end_op in both the attach-failure and completion paths. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index df8b0d92..a3986a92 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1879,10 +1879,18 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) return NULL; } g_active_ops++; + // Round-11 (#3): pin the engine in the same critical section as the + // g_active_ops reservation so a concurrent destroyEngine cannot free the + // resolver bridge (still held by Java as the resolver ctx) while this + // synchronous op attaches to Graal or runs. NULL for a resolver-less/unknown + // handle -- bridge_end_op no-ops on NULL. Released in the attach-failure and + // completion paths below, alongside g_active_ops. + engine_bridge_t* bridge = bridge_begin_op_locked(handle); uv_mutex_unlock(&g_mutex); void* thread = NULL; if (fn_attach_thread(g_isolate, &thread) != 0) { + bridge_end_op(bridge, /*env_still_alive=*/true); 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"); @@ -1891,9 +1899,9 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) char* result = (char*)fn_run_script_engine(thread, handle, script, inputs); - uv_mutex_lock(&g_mutex); - engine_bridge_t* bridge = bridge_find(handle); - uv_mutex_unlock(&g_mutex); + // The pin taken at admission kept this record alive across the run, so no + // second lookup is needed. resolver_results_free_all is a no-op for a + // resolver-less/unknown engine (bridge == NULL). if (bridge != NULL) resolver_results_free_all(bridge); char* result_copy = result ? strdup(result) : NULL; @@ -1901,9 +1909,12 @@ 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. + // Round-11 (#3): release the per-engine pin (may finalize a destroy that a + // concurrent Worker deferred while this op held in_flight > 0), then release + // the global op reservation. env is live on this JS thread, so env_still_alive + // is true. Order: bridge_end_op before the g_active_ops release, mirroring + // streaming/transform completion. + bridge_end_op(bridge, /*env_still_alive=*/true); uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); napi_value out; From cb6a061bea94452a204e0567e22f87dccef9683d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 13:39:25 -0300 Subject: [PATCH 069/216] W-23692110: Register env cleanup hook for every engine + extend owner-thread destroy guard (round 11 #1) A resolver-less engine registered no env cleanup hook, so a Worker that created one and exited without destroyEngine() leaked the native record, the Java ScriptRuntime registry entry, and the native-lib reference -- blocking isolate teardown across Worker churn. Register the hook for every engine (bridge_env_cleanup/bridge_finalize already handle resolver_js == NULL). This gives every engine env-affine state, so the owner-thread destroyEngine guard now fires for any record, and napi_remove_env_cleanup_hook runs for every engine on an early free. bridge_finalize's napi_ref delete stays resolver-gated. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 60 ++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index a3986a92..7e2bfe71 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1658,10 +1658,13 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // 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). + // record leaves resolver_js/results NULL. Round-11 (#1): it now ALSO registers + // an env cleanup hook (mirroring napi_create_engine_with_resolver), because + // without one a Worker that creates a resolver-less engine and exits without + // destroyEngine() strands this record, the Java registry entry, and the + // native-lib reference. owner is recorded for symmetry but is NOT used to + // restrict destruction based on resolver state (see the owner guard in + // napi_destroy_engine, which now fires for any record). 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 @@ -1675,7 +1678,18 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { } rec->handle = handle; rec->owner = uv_thread_self(); + rec->env = env; uv_mutex_lock(&g_mutex); rec->next = g_bridges; g_bridges = rec; uv_mutex_unlock(&g_mutex); + // Round-11 (#1): register an env cleanup hook for EVERY engine, not just + // resolver-backed ones. Without it, a Worker that creates a resolver-less + // engine and exits without destroyEngine() strands this record, the Java + // ScriptRuntime registry entry, and the native-lib reference -- leaking + // engines and blocking isolate teardown across Worker churn. bridge_env_cleanup + // + bridge_finalize already handle a resolver-less record (resolver_js == NULL): + // skip the napi_ref delete, still unlink, remove the registry entry (round-10 + // do_registry_remove=true), and free. destroyEngine removes this hook before + // an early free so Node never invokes it on freed memory. + napi_add_env_cleanup_hook(env, bridge_env_cleanup, rec); napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; } @@ -1749,21 +1763,19 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { // (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. - // 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. + // disposes the bridge when that Worker tears down. 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: round-11 (#1) registers an env cleanup hook for EVERY + // engine (resolver-backed or not), so every record now carries env-affine + // N-API state -- napi_remove_env_cleanup_hook (called below before an early + // free) can only be invoked legally on the owner thread. The guard + // therefore fires for any record (owned != NULL), not just resolver-backed + // ones. bridge_finalize's napi_ref deletion stays resolver-gated + // (resolver_js != NULL && env != NULL) -- that part is unchanged. uv_mutex_lock(&g_mutex); engine_bridge_t* owned = bridge_find(handle); - if (owned != NULL && owned->resolver_js != NULL) { + if (owned != NULL) { uv_thread_t self = uv_thread_self(); if (!uv_thread_equal(&self, &owned->owner)) { uv_mutex_unlock(&g_mutex); @@ -1793,13 +1805,13 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { uv_mutex_unlock(&g_mutex); if (found != NULL) { - // 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); - } + // Drop the env cleanup hook. Round-11 (#1): every engine now registers + // one at creation (napi_create_engine / napi_create_engine_with_resolver), + // so this removal must run unconditionally, not just for resolver-backed + // engines. 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. + 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 From 8cc056d1abdfe5b3be5994423b2bbeb49175b678 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 14:08:43 -0300 Subject: [PATCH 070/216] W-23692110: Register process exit hooks once, not per singleton (round 11 #5) getGlobalInstance attached a beforeExit + exit listener every time it re-created the singleton, but cleanup() never removed them, so init/cleanup cycles accumulated two listeners each until MaxListenersExceededWarning. Register the pair exactly once for the module via a never-reset guard; the listeners already tolerate a null singleton (cleanup no-ops, cleanupStarted coalesces a shutdown). Adds a regression test asserting no accumulation. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 52 +++++++++++++++---- .../tests/unit/dataweave-initialize.test.ts | 31 ++++++++++- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 14bff7e5..af808ed1 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -266,10 +266,22 @@ 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; +// Process exit hooks are registered exactly once for the lifetime of the +// module, NOT per singleton. Re-creating the singleton after cleanup() must +// not attach a second pair of listeners (that accumulates until Node emits +// MaxListenersExceededWarning). The listeners tolerate a null globalInstance: +// cleanup() no-ops when there is nothing to release, and cleanupStarted +// coalesces beforeExit/exit for a given shutdown. Unlike cleanupStarted, this +// guard is never reset — that is the whole point. +let exitHooksRegistered = false; /** - * Returns the process-wide {@link DataWeave} singleton, creating and - * initializing it (and registering exit-cleanup hooks) on first use. + * Registers the process-wide exit-cleanup hooks exactly once for this + * module. Subsequent calls (e.g. from a revived singleton after cleanup()) + * are no-ops: the hooks registered on first use are reused for the rest of + * the process's lifetime, which is safe because they tolerate a null + * `globalInstance` and `cleanupStarted` coalesces beforeExit/exit for a + * given shutdown. * * Two hooks are registered, covering complementary cases: * - `beforeExit` fires when the event loop is about to drain naturally and @@ -289,19 +301,37 @@ let cleanupStarted = false; * The `cleanupStarted` guard ensures only one of the two hooks actually * runs cleanup for a given shutdown. */ +function registerExitHooksOnce(): void { + if (exitHooksRegistered) return; + exitHooksRegistered = true; + 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 + }); +} + +/** + * Returns the process-wide {@link DataWeave} singleton, creating and + * initializing it on first use (or after a prior {@link cleanup}). + * + * The exit-cleanup hooks are registered exactly once for the process via + * {@link registerExitHooksOnce}, not once per singleton: a singleton revived + * after cleanup() reuses the same pair of listeners rather than adding new + * ones, which would otherwise accumulate a pair per init/cleanup cycle until + * Node emits `MaxListenersExceededWarning`. Reuse is safe because the + * listeners tolerate a null `globalInstance` and `cleanupStarted` coalesces + * beforeExit/exit for a given shutdown. + */ function getGlobalInstance(): DataWeave { if (!globalInstance) { globalInstance = new DataWeave(); globalInstance.initialize(); - 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 - }); + registerExitHooksOnce(); } return globalInstance; } diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 807cf898..80ff84ae 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -18,7 +18,7 @@ vi.mock("../../src/ffi", () => ({ })); import * as ffi from "../../src/ffi"; -import { DataWeave } from "../../src/dataweave"; +import { DataWeave, run, cleanup } from "../../src/dataweave"; import { DataWeaveError } from "../../src/errors"; describe("DataWeave.initialize() native ref-count safety", () => { @@ -195,4 +195,33 @@ describe("DataWeave.initialize() native ref-count safety", () => { expect(ffi.cleanup).toHaveBeenCalledTimes(1); expect(ffi.destroyEngine).toHaveBeenCalledTimes(1); }); + + it("does not accumulate process exit listeners across init/cleanup cycles", async () => { + // The module-level `run`/`cleanup` convenience API drives the lazily + // created singleton through `getGlobalInstance()`, which is what + // registers the process-wide beforeExit/exit hooks (registerExitHooksOnce + // in src/dataweave.ts). Unlike the other tests in this file, this doesn't + // construct DataWeave directly, so it hits DataWeave's default + // `findLibrary()` lookup. Point DATAWEAVE_NATIVE_LIB at this test file + // (guaranteed to exist) so that lookup succeeds without depending on a + // real built dwlib -- ffi.initialize() is mocked, so the path's contents + // are never touched. + const prevEnvLib = process.env.DATAWEAVE_NATIVE_LIB; + process.env.DATAWEAVE_NATIVE_LIB = __filename; + try { + const before = process.listenerCount("exit") + process.listenerCount("beforeExit"); + // Drive several singleton create -> cleanup cycles via the module API. + for (let i = 0; i < 5; i++) { + run("%dw 2.0\noutput application/json\n---\n1 + 1"); // creates the singleton (+ hooks on first) + await cleanup(); // releases the singleton + } + const after = process.listenerCount("exit") + process.listenerCount("beforeExit"); + // Register-once: at most the single pair added on the very first create, + // never one pair per cycle. + expect(after - before).toBeLessThanOrEqual(2); + } finally { + if (prevEnvLib === undefined) delete process.env.DATAWEAVE_NATIVE_LIB; + else process.env.DATAWEAVE_NATIVE_LIB = prevEnvLib; + } + }); }); From 500279542afa4dfa3ecc2f59d9d9c589a190655d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 14:24:42 -0300 Subject: [PATCH 071/216] W-23692110: Node integration tests for *_engine unknown/destroyed-handle contract (round 11 #6) The Java ScriptRuntimeTest only asserted on the UNKNOWN_ENGINE_HANDLE_JSON constant (the @CEntryPoint methods cannot run in a hosted JVM), so nothing drove the *_engine entrypoints through the real addon against unknown or destroyed handles. Add real-addon integration tests asserting the terminal {"success":false,"error":"Unknown engine handle"} envelope for runScriptEngine, runScriptStreamingEngine, and runScriptTransformEngine on unknown and destroyed handles, plus a best-effort cross-Worker run-vs-destroy guard (the exact race interleaving is not deterministically forceable, per the rounds 5-10 posture). Co-Authored-By: Claude Sonnet 5 --- .../engine-handle-contract.test.ts | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 native-lib/node/tests/integration/engine-handle-contract.test.ts diff --git a/native-lib/node/tests/integration/engine-handle-contract.test.ts b/native-lib/node/tests/integration/engine-handle-contract.test.ts new file mode 100644 index 00000000..71f30e6e --- /dev/null +++ b/native-lib/node/tests/integration/engine-handle-contract.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, afterAll } from "vitest"; +import * as ffi from "../../src/ffi"; +import { findLibrary, buildInputsJson } from "../../src/utils"; + +// W-23692110 round 11 finding #6. +// +// The Java `ScriptRuntimeTest` only asserts on the UNKNOWN_ENGINE_HANDLE_JSON +// constant -- the @CEntryPoint methods it wraps cannot run in a hosted JVM, so +// nothing has ever driven the real `*_engine` entrypoints through the +// compiled addon against an unknown or destroyed handle. This file closes +// that gap: it loads the REAL addon (no `vi.mock` of ffi) and drives +// `runScriptEngine` / `runScriptStreamingEngine` / `runScriptTransformEngine` +// directly through the raw `ffi` module -- the addon boundary the finding is +// about -- against handles that were never registered and against handles +// that were registered and then destroyed. +// +// Confirmed empirically (see task-6-report.md) against the real addon: +// - sync `runScriptEngine` RETURNS the JSON string +// `{"success":false,"error":"Unknown engine handle"}` -- it does not throw. +// - `runScriptStreamingEngine` / `runScriptTransformEngine` RESOLVE (never +// reject) their promise with that same JSON string as the terminal +// metadata; no chunk callback fires for an unknown/destroyed handle. +// This is the same envelope produced by NativeLib.UNKNOWN_ENGINE_HANDLE_JSON +// on the Java side (native-lib/src/main/java/org/mule/weave/lib/NativeLib.java), +// threaded back through addon.c's engine entrypoints and unmodified by the TS +// parsing layer (parseNativeResponse / parseStreamingResult in src/result.ts). +// +// The native addon globals (g_ref_count, g_initialized, g_bridges, etc.) are +// process-wide C statics -- vitest's per-file module isolation does NOT reset +// them. Every ffi.initialize() here is balanced by a final ffi.cleanup() (via +// afterAll) so this file doesn't strand a ref-count bump or a leaked engine +// bridge for sibling integration test files sharing the same vitest worker +// process, mirroring independent-engines.test.ts and handle-validation.test.ts. +describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { + afterAll(async () => { + // Idempotent: a no-op if nothing is left to release. + await ffi.cleanup(); + }); + + // A handle value that was never handed out by createEngine()/ + // createEngineWithResolver() (those only ever return small positive + // handles from the Java-side registry) and can never collide with one. + const UNKNOWN_HANDLE = Number.MAX_SAFE_INTEGER; + const UNKNOWN_ENVELOPE = { success: false, error: "Unknown engine handle" }; + + it("runScriptEngine on a never-registered handle returns the terminal envelope, does not throw", () => { + ffi.initialize(findLibrary()); + + let raw: string | undefined; + expect(() => { + raw = ffi.runScriptEngine( + UNKNOWN_HANDLE, + "%dw 2.0\noutput application/json\n---\n1 + 1", + buildInputsJson({}) + ); + }).not.toThrow(); + + expect(JSON.parse(raw!)).toEqual(UNKNOWN_ENVELOPE); + }); + + it("runScriptStreamingEngine on a never-registered handle resolves (never rejects) with the terminal envelope", async () => { + ffi.initialize(findLibrary()); + + const chunks: Buffer[] = []; + const raw = await ffi.runScriptStreamingEngine( + UNKNOWN_HANDLE, + "%dw 2.0\noutput application/json\n---\n[1, 2, 3]", + buildInputsJson({}), + (chunk) => chunks.push(chunk) + ); + + expect(JSON.parse(raw)).toEqual(UNKNOWN_ENVELOPE); + // No output was ever produced for an engine that doesn't exist. + expect(chunks).toHaveLength(0); + }); + + it("runScriptTransformEngine on a never-registered handle resolves (never rejects) with the terminal envelope", async () => { + ffi.initialize(findLibrary()); + + let readCalls = 0; + let firstRead = true; + const readCb = (_bufSize: number): Buffer | null => { + readCalls++; + if (firstRead) { + firstRead = false; + return Buffer.from("1"); + } + return null; + }; + const chunks: Buffer[] = []; + const writeCb = (chunk: Buffer) => chunks.push(chunk); + + const raw = await ffi.runScriptTransformEngine( + UNKNOWN_HANDLE, + "output application/json\n---\npayload", + "{}", + "payload", + "application/json", + null, + readCb, + writeCb + ); + + expect(JSON.parse(raw)).toEqual(UNKNOWN_ENVELOPE); + // The unknown-handle rejection happens before a worker is ever spawned, + // so the read/write callbacks are never invoked. + expect(readCalls).toBe(0); + expect(chunks).toHaveLength(0); + }); + + it("all three entrypoints on a destroyed handle return/resolve the same terminal envelope, after proving the handle worked", async () => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + + // Prove the handle is genuinely live before destroying it. + const preDestroy = JSON.parse( + ffi.runScriptEngine(handle, "%dw 2.0\noutput application/json\n---\n1 + 1", buildInputsJson({})) + ); + expect(preDestroy.success).toBe(true); + + ffi.destroyEngine(handle); + + // Sync entrypoint: returns the envelope, does not throw. + let syncRaw: string | undefined; + expect(() => { + syncRaw = ffi.runScriptEngine(handle, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})); + }).not.toThrow(); + expect(JSON.parse(syncRaw!)).toEqual(UNKNOWN_ENVELOPE); + + // Streaming entrypoint: resolves with the envelope. + const streamChunks: Buffer[] = []; + const streamRaw = await ffi.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1, 2, 3]", + buildInputsJson({}), + (chunk) => streamChunks.push(chunk) + ); + expect(JSON.parse(streamRaw)).toEqual(UNKNOWN_ENVELOPE); + expect(streamChunks).toHaveLength(0); + + // Transform entrypoint: resolves with the envelope. + let transformReadCalls = 0; + let transformFirstRead = true; + const transformReadCb = (_bufSize: number): Buffer | null => { + transformReadCalls++; + if (transformFirstRead) { + transformFirstRead = false; + return Buffer.from("1"); + } + return null; + }; + const transformChunks: Buffer[] = []; + const transformRaw = await ffi.runScriptTransformEngine( + handle, + "output application/json\n---\npayload", + "{}", + "payload", + "application/json", + null, + transformReadCb, + (chunk) => transformChunks.push(chunk) + ); + expect(JSON.parse(transformRaw)).toEqual(UNKNOWN_ENVELOPE); + expect(transformReadCalls).toBe(0); + expect(transformChunks).toHaveLength(0); + }); + + // Best-effort probabilistic guard (green on fixed code, cannot false-fail + // on it) -- matching the documented posture of rounds 5-10's cross-Worker + // races (see run-admission.test.ts / admission-during-teardown.test.ts): + // the exact interleaving of a concurrent destroyEngine() against the + // admission window of an in-flight streaming/transform op on the SAME + // handle is not deterministically forceable from JS. + // + // This harness has no existing `worker_threads` pattern to reuse (checked: + // no test file under tests/integration uses `worker_threads`/`Worker`), and + // spinning up a real Worker here would still race the SAME non-deterministic + // window -- it would not make the interleaving forceable, only add overhead + // and flakiness risk without truer coverage. Instead this uses the closest + // deterministic proxy available on a single thread: destroyEngine() is + // fired synchronously immediately after admission of the op (right after + // starting runScriptStreamingEngine, before awaiting it), which is exactly + // when a genuinely concurrent Worker's destroyEngine() would most plausibly + // land relative to the round-11 #2/#3 pin taken under g_mutex at admission. + // Because the pin is taken atomically at admission, this same-thread + // ordering deterministically lands AFTER the pin, so on fixed code every + // iteration is expected to observe a valid successful result (the pin keeps + // the engine alive for the run) -- but the test tolerates either outcome + // (success or the terminal Unknown-engine-handle envelope) and only fails + // if the process crashes or an iteration returns something outside that + // closed set, so it cannot false-fail on the fix and stays meaningful if + // future changes narrow the pinned window. + it( + "best-effort: destroyEngine() racing an in-flight streaming op never crashes and always ends in a valid result or the terminal envelope", + async () => { + ffi.initialize(findLibrary()); + + const ITERATIONS = 50; + for (let i = 0; i < ITERATIONS; i++) { + const handle = ffi.createEngine(); + const chunks: Buffer[] = []; + const resultPromise = ffi.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1, 2, 3]", + buildInputsJson({}), + (chunk) => chunks.push(chunk) + ); + // Fire the racing destroy as close to the admission window as this + // single thread allows: immediately after starting the op, before + // awaiting it. + expect(() => ffi.destroyEngine(handle)).not.toThrow(); + + const raw = await resultPromise; + const parsed = JSON.parse(raw); + + if (parsed.success) { + expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toEqual([1, 2, 3]); + } else { + expect(parsed).toEqual(UNKNOWN_ENVELOPE); + expect(chunks).toHaveLength(0); + } + } + }, + 60000 + ); + + it("final cleanup drains the shared isolate (idempotent)", async () => { + await ffi.cleanup(); + // Calling it again must remain a safe no-op, mirroring + // independent-engines.test.ts's final teardown discipline. + await expect(ffi.cleanup()).resolves.toBeUndefined(); + }); +}); From bdf103a3bb98a326f122efdbcd91aa46334d0a8d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 14:51:24 -0300 Subject: [PATCH 072/216] W-23692110: Fix initialize/cleanup ref-count imbalance in engine-handle-contract.test.ts (round 11 #6 fix-loop 1) Round-11 #6 review flagged that the test file called ffi.initialize() 5 times but ffi.cleanup() only 3 times, leaving a net +2 ref-count surplus and contradicting the file's own header comment claiming a balanced 1:1 initialize/cleanup pair. It also hollowed out the "final cleanup drains the shared isolate" test: since the ref count never reached zero across the file, that test's assertion was trivially true regardless of whether genuine teardown occurred. Move to a single ffi.initialize() in beforeAll (mirroring independent-engines.test.ts), remove the redundant per-test initialize() calls, and make the final test's one ffi.cleanup() the sole balancing release that brings g_ref_count to zero -- then assert a subsequent engine-level call genuinely observes "Not initialized" (proving teardown actually happened) before checking a second cleanup() stays a safe no-op. Co-Authored-By: Claude Sonnet 5 --- .../engine-handle-contract.test.ts | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/native-lib/node/tests/integration/engine-handle-contract.test.ts b/native-lib/node/tests/integration/engine-handle-contract.test.ts index 71f30e6e..bbbd674c 100644 --- a/native-lib/node/tests/integration/engine-handle-contract.test.ts +++ b/native-lib/node/tests/integration/engine-handle-contract.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; import * as ffi from "../../src/ffi"; import { findLibrary, buildInputsJson } from "../../src/utils"; @@ -27,13 +27,29 @@ import { findLibrary, buildInputsJson } from "../../src/utils"; // // The native addon globals (g_ref_count, g_initialized, g_bridges, etc.) are // process-wide C statics -- vitest's per-file module isolation does NOT reset -// them. Every ffi.initialize() here is balanced by a final ffi.cleanup() (via -// afterAll) so this file doesn't strand a ref-count bump or a leaked engine -// bridge for sibling integration test files sharing the same vitest worker -// process, mirroring independent-engines.test.ts and handle-validation.test.ts. +// them, and napi_initialize/napi_cleanup are plain integer ref-counts (one +// increment per initialize(), one decrement per cleanup(), teardown only on +// the transition to zero). So this file calls ffi.initialize() exactly ONCE +// for the whole suite (beforeAll), balanced by exactly one ffi.cleanup() that +// brings the ref count to zero (in the last real test, "final cleanup..." +// below) -- mirroring independent-engines.test.ts's single +// initialize()/cleanup() pair rather than handle-validation.test.ts's +// per-test balancing (that file calls initialize()/cleanup() once per test, +// which does not fit here since several tests below deliberately build on a +// still-live engine/isolate from a prior test). The trailing afterAll is a +// pure safety net (idempotent no-op on the happy path) in case an earlier +// assertion throws before the drainage test runs, so this file never strands +// a ref-count bump for sibling integration test files sharing the same +// vitest worker process. describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { + beforeAll(() => { + ffi.initialize(findLibrary()); + }); + afterAll(async () => { - // Idempotent: a no-op if nothing is left to release. + // Idempotent: a no-op if the ref count already reached zero (the normal + // case -- the drainage test below already did that). A genuine safety + // net only if an earlier test threw before reaching that point. await ffi.cleanup(); }); @@ -44,8 +60,6 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { const UNKNOWN_ENVELOPE = { success: false, error: "Unknown engine handle" }; it("runScriptEngine on a never-registered handle returns the terminal envelope, does not throw", () => { - ffi.initialize(findLibrary()); - let raw: string | undefined; expect(() => { raw = ffi.runScriptEngine( @@ -59,8 +73,6 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { }); it("runScriptStreamingEngine on a never-registered handle resolves (never rejects) with the terminal envelope", async () => { - ffi.initialize(findLibrary()); - const chunks: Buffer[] = []; const raw = await ffi.runScriptStreamingEngine( UNKNOWN_HANDLE, @@ -75,8 +87,6 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { }); it("runScriptTransformEngine on a never-registered handle resolves (never rejects) with the terminal envelope", async () => { - ffi.initialize(findLibrary()); - let readCalls = 0; let firstRead = true; const readCb = (_bufSize: number): Buffer | null => { @@ -109,7 +119,6 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { }); it("all three entrypoints on a destroyed handle return/resolve the same terminal envelope, after proving the handle worked", async () => { - ffi.initialize(findLibrary()); const handle = ffi.createEngine(); // Prove the handle is genuinely live before destroying it. @@ -193,8 +202,6 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { it( "best-effort: destroyEngine() racing an in-flight streaming op never crashes and always ends in a valid result or the terminal envelope", async () => { - ffi.initialize(findLibrary()); - const ITERATIONS = 50; for (let i = 0; i < ITERATIONS; i++) { const handle = ffi.createEngine(); @@ -225,9 +232,23 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { ); it("final cleanup drains the shared isolate (idempotent)", async () => { + // Exactly one ffi.initialize() ran for this whole file (beforeAll), so + // this is the ONE balancing ffi.cleanup() that brings the native + // g_ref_count to zero and genuinely tears the isolate down (napi_cleanup + // Case 4, since no op is in flight) -- not a no-op decrement of a + // still-positive count left over from other tests. Prove that teardown + // actually happened, not just that the call resolved: a subsequent + // engine-level call must now observe "not initialized" rather than + // silently succeeding against a still-live isolate. await ffi.cleanup(); - // Calling it again must remain a safe no-op, mirroring - // independent-engines.test.ts's final teardown discipline. + + expect(() => + ffi.runScriptEngine(UNKNOWN_HANDLE, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) + ).toThrow(/not initialized/i); + + // A second cleanup() call after the ref count already reached zero must + // remain a safe no-op, mirroring independent-engines.test.ts's final + // teardown discipline. await expect(ffi.cleanup()).resolves.toBeUndefined(); }); }); From 9715e91feedb364c731a713cf50c3a5c11bb67e9 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 15:46:45 -0300 Subject: [PATCH 073/216] W-23692110: Fix two cleanup() doc bugs in Node README (round 12 #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two inaccurate claims in the cleanup() docs: 1. "exit is a fallback for ... fatal signals" — Node does NOT emit the 'exit' event for SIGTERM/SIGINT/SIGKILL, so neither the beforeExit nor the exit hook fires on those signals. Corrected to say neither hook covers signals and to point users at installing their own signal handler if they need a graceful drain on termination. 2. "teardown waits for it to drain anywhere in the process" — cleanup() only triggers native teardown (and the in-flight drain) when it releases the LAST g_ref_count reference. cleanup() on one instance while other initialized instances remain resolves as soon as that instance is released, without draining process-wide work. Documented the last-reference condition explicitly. Documentation only; no code change. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 80838bb5..851932e5 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -199,7 +199,7 @@ for await (const chunk of generator) { #### `cleanup(): Promise` -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. +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()` and uncaught exceptions — cases where `beforeExit` never fires — and cannot await the drain. Neither hook fires on `SIGTERM`, `SIGINT`, or `SIGKILL` (Node does not emit `exit` for signals), so install your own signal handler that calls `cleanup()` if you need a graceful drain on termination. Called manually, it releases this instance's reference to the native runtime; the shared native isolate is torn down only when the **last** initialized instance in the process is released. When this call releases that final reference, it resolves once native teardown has actually finished, waiting for any still-in-flight streaming/transform operation to drain first; otherwise (other instances remain initialized) it resolves as soon as this instance is released, without draining process-wide work. ```javascript import { cleanup } from 'dataweave-native'; From a6f335ba717e9d57d60756a6449658e0876dcc66 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 16:06:27 -0300 Subject: [PATCH 074/216] W-23692110: Add round-12 design spec (worker ref-leak & teardown-race hardening) Design for the 7 in-scope round-12 findings from docs/pr-157-follow-up-code-review-3.md (#2 High env-cleanup init-ref leak, #3 High deferred-finalize isolate-teardown race, #4/#5/#6 Medium code issues, #8/#9 Medium test-coverage gaps). #1 (dwlib C ABI break) is a decided pre-GA intended break; #7 (doc bugs) already fixed in e1b9ee0. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...leak-and-teardown-race-hardening-design.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md diff --git a/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md b/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md new file mode 100644 index 00000000..3e7ed908 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md @@ -0,0 +1,169 @@ +# Worker Ref-Leak & Teardown-Race Hardening — Round 12 (W-23692110) + +**Status:** Design approved, ready for planning. + +**Source review:** `docs/pr-157-follow-up-code-review-3.md` (9 findings), verified against live source at commit `e1b9ee0` (round-12 tip; round-11 code + the #7 doc fix). Two findings are already resolved and are out of scope for the implementation round below: + +- **#7 (docs)** — the two `cleanup()` README bugs (false "fatal signals" claim; over-broad "drains anywhere in the process" claim) are fixed in `e1b9ee0`. +- **#1 (dwlib C ABI break)** — factual and by design. The project is **pre-GA**; the multi-engine redesign intentionally replaces the `run_script_*_with_resolver` exports with the `*_engine` entrypoints and adds `ctx` to `ResolveModuleCallback`. No compatibility shims, no major-version ceremony required at this stage. **Decision (user): OK, not addressed.** No code change. + +**Scope:** `native-lib/node` only — `src/addon.c`, `src/dataweave.ts`, and Node integration tests under `tests/`. Do **not** touch `native-lib/python/**`, the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`), `dw_napi_run_script`, or `ScriptRuntime.getInstance()`. The Java side (`NativeLib.java`, `ScriptRuntime`) is read for context but not modified. + +## Problem + +Round 11 gave every engine an env cleanup hook so an abandoned Worker's env teardown reclaims the engine record and Java registry entry. A follow-up review found that reclamation is **incomplete** (the init reference leaks — #2) and that the deferred finalize path it relies on has a **teardown race** (#3), plus three medium code issues (#4, #5, #6) and two test-coverage gaps (#8, #9). All seven are verified real against live source. + +### #2 (High) — Abandoned-env teardown leaks the initialization reference + +Every `DataWeave` instance calls `ffi.initialize()` on construction (`dataweave.ts:87`), which does `g_ref_count++` (`addon.c:506`; also the fast-path `:477` and the adoption path `:463`). The only `g_ref_count--` is in `napi_cleanup` (`addon.c:2153`), reached from JS via `ffi.cleanup()`. When a Worker (or the main env) terminates **without** calling `cleanup()`, the env cleanup hook `bridge_env_cleanup` → `bridge_finalize` (`addon.c:252-293`) frees the engine record, deletes the napi_ref, and removes the Java registry entry — but never decrements `g_ref_count`. So the shared isolate's reference count never returns to zero and the isolate is never torn down. Repeated Worker create/terminate cycles without explicit `cleanup()` keep the isolate alive indefinitely. This directly contradicts the round-11 comment at `addon.c:1686` claiming the hook prevents leaking "the native-lib reference." + +### #3 (High) — Deferred registry removal attaches to an isolate that teardown may be destroying + +`bridge_finalize` (`addon.c:224-243`) reads `g_isolate` **without `g_mutex`** and calls `fn_attach_thread(g_isolate, &thread)` then `fn_destroy_engine(thread, …)` to remove the Java registry entry. The streaming/transform worker threads release their `g_active_ops` reservation (`addon.c:745-748` for streaming; the transform analogue) **before** the completion sentinel runs `bridge_end_op` → `bridge_finalize`. Once `g_active_ops` reaches 0, the `teardown_waiter_thread_fn` is free to begin `graal_tear_down_isolate()`. So the sequence + +1. worker releases `g_active_ops` (now 0), +2. waiter wakes, transitions `TEARING_DOWN`, calls `graal_tear_down_isolate()`, +3. sentinel's `bridge_finalize` reads `g_isolate` (passes the NULL check because step 2's clear hasn't landed / is racing) and calls `fn_attach_thread` on an isolate being destroyed + +is possible. This is **both** a C data race on `g_isolate` (lock-free read racing a write under lock) **and** an attach-vs-teardown TOCTOU. The round-11 whole-branch review adjudicated the *spawn-failure* variant benign because it runs with the reservation still held / isolate guaranteed alive; the **deferred-finalize** variant is not benign because it can run after `g_active_ops` is already 0. + +### #4 (Medium) — `runTransform` can dispatch on an engine cleaned up during input pre-buffering + +`runTransform` (`dataweave.ts:221-248`) calls `ensureReady()` (`:226`), then `await createChunkReader(input)` (`:234`) — a suspension point that, for async input, can take arbitrary time — then dispatches with `this.engineHandle!` (`:238`). A caller can start the transform, `cleanup()` the instance while the reader is pre-buffering, then resume into a dispatch with a cleared/destroyed handle. The round-11 C admission pin makes this **memory-safe** (worst case is a resolved `Unknown engine handle` envelope, not a UAF), but the readiness check is stale by the time of dispatch. + +### #5 (Medium) — Module-level `cleanup()` does not coalesce overlapping calls + +The module-level `cleanup()` (`dataweave.ts:371-383`) nulls `globalInstance` **synchronously** before awaiting `instance.cleanup()`. A second overlapping call sees `globalInstance === null` and resolves immediately, even though the first call's native teardown is still draining. The instance-level `cleanup()` correctly coalesces via `this.cleanupPromise` (`:131`); the module wrapper does not, so its contract ("resolves once native teardown has finished") is violated for the second caller. + +### #6 (Medium) — Ignored `napi_add_env_cleanup_hook` status leaks a returned handle + +`napi_create_engine` (`addon.c:1692`) and `napi_create_engine_with_resolver` (`addon.c:1743`) ignore the return status of `napi_add_env_cleanup_hook`. If registration fails, the function still returns a usable handle, but the engine now has **no** env cleanup hook, so an abandoned Worker permanently strands its engine record, Java registry entry, and (per #2) init reference. Engine creation is not all-or-nothing. + +### #8 (Medium) — The run-vs-destroy test cannot prove the pin guarantee + +`engine-handle-contract.test.ts:177-231` fires `destroyEngine()` on the same JS thread **after** admission, then accepts *either* success *or* the `Unknown engine handle` envelope. On fixed code the pin was already acquired at admission, so this ordering must deterministically succeed; accepting the error envelope means a regression that removes the pin still passes the test. The assertion is too weak to detect the very regression it exists to guard. + +### #9 (Medium) — No Worker integration coverage for the documented per-Worker model + +The README (`README.md:445-456`) instructs users to construct a separate resolver-backed `DataWeave` instance per Worker, but no test creates a `worker_threads` Worker. There is no coverage for resolver-backed/resolver-less engines inside a Worker, normal Worker exit without `cleanup()` (the #2 scenario), `Worker.terminate()`, independent module resolution, or subsequent main-thread initialization. + +## Design + +The two correctness fixes (#2, #3) share the teardown-coordination trio `g_ref_count` / `g_active_ops` / the lock-free `g_isolate` read. Per the approved approach, the fix is **robust but bounded**: close the race for real and track the init reference properly, using **targeted consolidation of only the ref-release/finalize step** where sharing is warranted — without re-opening the broader coordination substructure (the round-5 `TEARDOWN_*` state machine + adoption path, the round-9/10 deferred-removal logic) that took six rounds to stabilize. + +### 1. Release the init reference on abandoned-env teardown (#2) + +**New helper — `release_isolate_ref_locked()`** (caller holds `g_mutex`). It carries the exact "one initialization reference is going away" logic that `napi_cleanup` Case 5 already implements: decrement `g_ref_count`; if it reaches 0, drive the **existing** teardown decision (immediate teardown when `g_active_ops == 0`, or queue the `teardown_waiter` when `g_active_ops > 0`, setting `TEARDOWN_PENDING_WAIT`). This is *targeted* consolidation — only the decrement-and-maybe-teardown step, not the surrounding machinery. `napi_cleanup` is refactored to call it (behavior-preserving); the env-cleanup path calls it too. + +**Env-cleanup path releases the ref.** `bridge_env_cleanup` reclaims an abandoned env's engine. Because that env's `initialize()` did one `g_ref_count++` per engine it created, the reclamation must do one matching release per engine: + +- In `bridge_env_cleanup`'s **direct finalize** path (`in_flight == 0`, `addon.c:279-293`): after finalizing the record, call `release_isolate_ref_locked()` once, under `g_mutex`. +- In its **deferred-drain** path (`in_flight > 0`, marks `destroy_pending`/`deferred_registry_remove`, `addon.c:269-278`): the last op to drain (`bridge_end_op` → finalize) must perform the release. Thread a flag on the record — `deferred_ref_release` — set alongside `deferred_registry_remove` in the env-cleanup deferral, so `bridge_end_op` knows to release the ref exactly once when it finalizes. (The `destroyEngine` deferral does **not** set it — that path is paired with an explicit `ffi.cleanup()` in JS and must not double-release.) + +**Ownership rule (the invariant):** exactly one `g_ref_count` release per `initialize()`. `napi_cleanup` releases for instances torn down via explicit JS `cleanup()`; the env-cleanup path releases for instances abandoned by a terminating env. `destroyEngine` never releases (its JS caller always follows with `ffi.cleanup()`). These are mutually exclusive per engine because `destroyEngine` removes the env hook (so an engine reclaimed by the hook was never explicitly destroyed) and JS `cleanup()` calls `destroyEngine` then `ffi.cleanup()` on the *live* env (so the hook never fires for it). + +This makes the round-11 comment at `addon.c:1686` accurate. Update that comment to state the hook now also releases the init reference. + +### 2. Perform the isolate-touching finalize under the held admission reservation (#3) + +`g_active_ops > 0` is the exact invariant that keeps the isolate alive (the waiter blocks on `while (g_active_ops > 0)`). The fix makes the deferred registry-removal attach happen **while the op's own reservation still holds**, so the isolate provably cannot begin teardown during the attach. + +**Split `bridge_finalize` into two phases:** + +- `bridge_finalize_registry(b)` — the isolate-touching phase: `fn_attach_thread(g_isolate, …)` + `fn_destroy_engine(thread, b->handle)` + `fn_detach_thread`. Precondition: **called only while the caller holds a live `g_active_ops` reservation** (or otherwise guarantees the isolate is alive — e.g. the JS-thread destroyEngine paths where `g_isolate` is stable). Retains the `g_isolate != NULL` guard for the documented main-env-after-isolate-teardown corner, but that read is now inside the reservation window, so it is no longer racing a concurrent teardown. +- `bridge_finalize_free(b, env_still_alive)` — the non-isolate phase: napi_ref deletion (owner JS thread, env alive; stays resolver-gated) + `resolver_results_free_all` + `free(b)`. Touches no GraalVM isolate state. + +**Reorder the streaming/transform completion sentinels** so the sequence on the worker/owner completion path is: + +1. `bridge_end_op` decides finalize/registry-removal under `g_mutex` (unchanged decision logic), +2. if registry removal is due: `bridge_finalize_registry(b)` **while `g_active_ops` for this op is still held**, +3. release `g_active_ops` (the exact verbatim pattern), +4. `bridge_finalize_free(b, env_still_alive)`. + +i.e. move the `g_active_ops--` release to sit **between** the registry-removal attach and the record-free, instead of before both. The napi_ref deletion and record-free never touch the isolate, so doing them after the release is safe; the attach never happens outside the reservation window, so `g_isolate` is never read while a teardown could be destroying it. + +**Deadlock-safety (the load-bearing review gate):** holding `g_active_ops` across `bridge_finalize_registry` must not re-introduce the round-5 deadlock. The round-5 deadlock was: a *blocking wait on the JS event loop* while an op needed that loop. `bridge_finalize_registry` attaches its **own** Graal thread and makes **no** env-affine N-API call and **no** wait on the JS loop — it cannot depend on the event loop turning, so holding the reservation across it cannot wedge the waiter. This must be explicitly confirmed in review. + +**Preserves round-5's decrement-on-worker-thread reasoning:** the `g_active_ops--` stays on the worker/completion thread (not moved back to a JS callback); we only move *where within that completion path* it sits relative to the registry-removal attach. + +### 3. `runTransform` readiness re-check after pre-buffering (#4) + +In `runTransform` (`dataweave.ts`), call `this.ensureReady()` again immediately after `await createChunkReader(input)`, before `streamFromNative(...)`. If the instance was cleaned up during the await, the caller gets a synchronous `DataWeaveError` (the same error `ensureReady` throws elsewhere) instead of a resolved `Unknown engine handle` envelope. No lease is introduced — the authoritative guard is the C admission pin (round 11 #2/#3); this only improves the failure ergonomics for a misused instance. The first `ensureReady()` at the top stays (fail fast before pre-buffering when already not-ready). + +### 4. Module-level `cleanup()` coalescing (#5) + +Add a module-scoped `cleanupPromise: Promise | null`. The module `cleanup()` becomes: if `cleanupPromise` is set, return it; else if `globalInstance` is null, return; else capture the instance, null `globalInstance`, store `cleanupPromise = instance.cleanup()`, `await` it in a `try`, and clear `cleanupPromise` in `finally`. Overlapping callers all await the same promise and resolve only when the underlying native teardown finishes — matching the instance-level coalescing pattern. The `cleanupStarted` exit-hook coalescer is unchanged (it coalesces `beforeExit`/`exit` for a shutdown; this coalesces overlapping manual calls). Keep the `cleanupStarted = false` reset last, as today. + +### 5. Check `napi_add_env_cleanup_hook` status; make creation all-or-nothing (#6) + +In both `napi_create_engine` and `napi_create_engine_with_resolver`, capture the `napi_status` from `napi_add_env_cleanup_hook`. On non-`napi_ok`: + +- unlink the just-linked record from `g_bridges` (under `g_mutex`), +- `bridge_finalize_registry(record)` to remove the Java registry entry (the engine was just created on this same live thread; the isolate is alive and `g_active_ops` need not be held because we are on the creating JS thread before returning — `g_isolate` is stable here, the same condition the existing destroyEngine fallback relies on), +- `release_isolate_ref_locked()` to release this creation's init reference (this instance's `initialize()` bumped it), +- `bridge_finalize_free(record, /*env_still_alive=*/true)`, +- `napi_throw_error` and return NULL — no usable handle escapes. + +Because the record was just constructed and linked on this thread and no op could have been admitted against it yet (`in_flight == 0`, no concurrent admission — the JS wrapper hasn't returned the handle), the unlink-and-finalize is race-free. + +### 6. Strengthen the run-vs-destroy test (#8) + +In `engine-handle-contract.test.ts`, for the **admitted ordering** (destroy fired after the streaming/transform op is admitted), require **success + complete chunks** — remove the "or Unknown engine handle" acceptance for that specific ordering. On fixed code the pin is already held at admission, so success is guaranteed; a regression that drops the pin would now produce the error envelope and **fail** the test. Keep any genuinely-unforceable cross-thread interleaving as a separately-labeled best-effort probe. + +### 7. Worker integration tests (#9) + +Create `native-lib/node/tests/integration/worker-lifecycle.test.ts` using real `worker_threads` Workers loading the real compiled addon. Coverage: + +- **Resolver-backed engine in a Worker:** create, run a script that resolves a custom module via the Worker's `resolveModule`, assert correct output — proving per-Worker resolver binding. +- **Resolver-less engine in a Worker:** create, run, assert output. +- **Normal Worker exit without `cleanup()` (the #2 proof):** run N cycles of {spawn Worker → create engine → run → let the Worker exit without `cleanup()`}, then assert the main thread can still `initialize()` and run, and that the process is not wedged. This is the behavioral observation of the #2 ref release (pre-fix, the leaked ref would keep the isolate alive; the test asserts continued healthy operation and clean final teardown). +- **`Worker.terminate()` mid-life** then subsequent main-thread `initialize()`/run succeeds. +- **Explicit `cleanup()` inside a Worker** resolves and leaves the main thread healthy. + +**Shared-state discipline:** these Worker tests share the parent process's isolate. Each Worker's own engine lifecycle must be balanced, and the file must end with a final main-thread `cleanup()` so it doesn't perturb sibling integration files — the same discipline `independent-engines.test.ts` follows. Vitest `pool: "forks"` isolates per file, so the file's residual state does not leak across files, but within-file balance still matters for the assertions. + +**Determinism posture (stated in the test file):** exact cross-thread timing interleavings (#3's race) are **not** deterministically forceable — matching the rounds 5–11 posture. The deterministic teeth are #8's required-success admitted-ordering assertion and #2's "Worker exits → main thread still works + final teardown clean" assertion. #3's correctness rests on the code reasoning in Design §2 (the reservation window), with the Worker tests as best-effort probabilistic guards over N iterations that are green on fixed code and cannot false-fail on it. + +## Testing + +- Strengthened `engine-handle-contract.test.ts` admitted-ordering assertion (#8). +- New `worker-lifecycle.test.ts` (#9), doubling as behavioral coverage for #2 (and best-effort for #3). +- Unit coverage for the module-level `cleanup()` coalescing (#5): two overlapping `cleanup()` calls both await the same drain and neither resolves before native teardown completes. +- Unit coverage for `runTransform` re-check (#4): consuming a transform generator after the instance was cleaned up during the input await surfaces a `DataWeaveError` synchronously at resume, not a resolved error envelope. +- No Java test change (the `@CEntryPoint` hosted-JVM limitation is unchanged; coverage stays at the Node integration layer). +- #2/#3 lifecycle correctness that is not deterministically forceable is covered by code reasoning against the invariants in §Design plus the best-effort Worker guards (same documented posture as rounds 5–11). + +## Verification + +- `cd native-lib/node && npm run build:addon` clean (no new warnings in the touched regions: `bridge_finalize*`, `bridge_env_cleanup`, `bridge_end_op`, `napi_cleanup`, the two creators, the streaming/transform completion sentinels); `npm run build` (tsc) clean. +- `npm test` green at the new baseline (currently 885 passed / 59 skipped / 0 failed; this round adds the #5, #4, #8 assertions and the #9 Worker suite — the plan sets the exact new counts). +- `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 (async) or a synchronous `napi_throw_error` (admission/arg/alloc/resource failures) — never `napi_reject_deferred`. +- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread. No env-affine napi call may be made off the owning thread. `bridge_finalize_free`'s napi_ref deletion stays resolver-gated (`resolver_js != NULL && env != NULL`) and on the owner thread. +- All shared C state (`g_initialized`, `g_active_ops`, `g_teardown_state`, `g_teardown_cancelled`, `g_ref_count`, every engine record's `in_flight`/`destroy_pending`/`deferred_registry_remove`/the new `deferred_ref_release`) is read/written only under `g_mutex`, **except** the `g_isolate` read inside `bridge_finalize_registry`, which is now only reached while a live `g_active_ops` reservation (or the creating JS thread's stable-isolate guarantee) holds the isolate alive — closing the round-12 #3 race. +- 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);` +- **Exactly one `g_ref_count` release per `initialize()`** (the #2 invariant): `napi_cleanup` for explicitly-cleaned instances; the env-cleanup path for abandoned envs; `destroyEngine` never releases. Mutually exclusive per engine. +- `fn_destroy_engine` is called **exactly once** per handle. +- Per-engine `in_flight` and global `g_active_ops` stay **distinct** counters — not merged. +- The round-5 deadlock fix must be preserved: `g_active_ops--` stays on the worker/completion thread, never moved to a JS-thread callback; and no blocking wait on the JS event loop is introduced. Holding `g_active_ops` across `bridge_finalize_registry` is safe only because that phase makes no env-affine/JS-loop-dependent call — confirm in review. +- Preserve every round-1..11 fix: coalesced instance `cleanup()`, the JS three-state lifecycle machine, the `TEARDOWN_*` state machine + `napi_initialize` adoption path (incl. round-7's `g_teardown_cancelled` carve-out), the worker-thread `g_active_ops` decrement, the atomic admission blocks + round-11 admission-time engine pin (`bridge_begin_op_locked`) in all three run paths, the round-6 handle-read validations, round-7 conversion-status checks, round-8 setup-allocation NULL checks, round-9 worker/callback OOM + N-API-create checks + deferred registry removal, round-10 env-cleanup registry removal + `g_isolate`-guarded finalize, round-11 env cleanup hook for every engine + owner-thread destroy guard for every record + register-once exit hooks. +- Node vitest baseline currently **885 passed / 59 skipped / 0 failed**; this round raises it (new tests) and must stay green. + +## Rejected Alternatives + +- **#2 by decrementing `g_ref_count` inline in `bridge_finalize` without the shared helper.** Rejected: the "reached zero → immediate teardown vs. queue the waiter" decision already lives in `napi_cleanup` Case 5; duplicating it invites divergence. A single `release_isolate_ref_locked()` keeps both paths identical. +- **#2 by having `destroyEngine` also release the ref.** Rejected: `destroyEngine`'s JS caller (`doCleanup`) always follows with `ffi.cleanup()`, which releases the ref; adding a release in `destroyEngine` would double-release and tear the isolate down under live instances. +- **#3 by taking `g_mutex` around the `g_isolate` read + attach in `bridge_finalize`.** Rejected: `fn_attach_thread`/`fn_destroy_engine` enter GraalVM and can block; holding `g_mutex` across them would serialize all teardown coordination behind a GraalVM call and risk lock-ordering issues with the waiter. Gating on the already-held `g_active_ops` reservation is the correct, lock-free-read-safe closure. +- **#3 with a dedicated `g_finalizing` counter separate from `g_active_ops`.** Rejected as re-opening the coordination substructure the approved approach keeps bounded: it adds a second teardown-gating counter that the waiter must also wait on, duplicating what `g_active_ops` already expresses. Reusing the op's existing reservation window is simpler and provably correct. +- **#4 via a JS-side operation lease that blocks `cleanup()` until the transform completes.** Rejected (same as rounds 9/11): no per-engine "await my ops" primitive exists at the JS layer, and the authoritative pin already lives in C. The re-check is the minimal ergonomic close; the lease would duplicate the C pin's guarantee at a layer that cannot enforce it. +- **#5 by not nulling `globalInstance` until the drain settles.** Rejected: a concurrent convenience-API call would then revive/return the instance mid-teardown. Nulling synchronously (so new work builds a fresh instance) plus a module `cleanupPromise` (so overlapping `cleanup()`s coalesce) matches the instance-level design and is correct. +- **#6 by leaving the handle valid and logging on hook-registration failure.** Rejected: a handle with no env cleanup hook silently reintroduces exactly the #2 leak the round is closing. Creation must be all-or-nothing. +- **#8 keeping the "success OR error envelope" acceptance for the admitted ordering.** Rejected: that acceptance is precisely what lets a pin regression pass. The admitted ordering is deterministic on correct code, so the test must require success. +- **#9 driving the "cross-thread" scenario on a single JS thread only.** Rejected as insufficient for the documented per-Worker model: real `worker_threads` Workers are needed to exercise per-Worker engine binding and the abandoned-env (#2) path. The exact race remains best-effort, but the Worker lifecycle itself must be really exercised. +- **Modifying the Java `ScriptRuntime` to reference-count or tolerate late lookups.** Rejected as out of scope and the wrong layer — the C addon owns the isolate reference and the pin. From 78d78b16bedf65c5352ae240739100b8f3248379 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 19:50:19 -0300 Subject: [PATCH 075/216] W-23692110: Amend round-12 spec #3 to transient-reservation mechanism The literal "move g_active_ops-- onto the worker thread and reuse the op's own reservation across finalize" wording would restructure bridge_end_op and both completion sentinels across thread boundaries -- re-opening the round-5/9/10/11 coordination the bounded approach keeps closed. Replace with bridge_finalize_registry taking a transient g_active_ops reservation (check teardown state + g_active_ops++ in one critical section, attach, release), which closes the identical race with no completion-path change. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...leak-and-teardown-race-hardening-design.md | 76 ++++++++++++++----- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md b/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md index 3e7ed908..fc80c61b 100644 --- a/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md +++ b/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md @@ -64,27 +64,60 @@ The two correctness fixes (#2, #3) share the teardown-coordination trio `g_ref_c This makes the round-11 comment at `addon.c:1686` accurate. Update that comment to state the hook now also releases the init reference. -### 2. Perform the isolate-touching finalize under the held admission reservation (#3) +### 2. Guard the isolate-touching finalize with a transient admission reservation (#3) -`g_active_ops > 0` is the exact invariant that keeps the isolate alive (the waiter blocks on `while (g_active_ops > 0)`). The fix makes the deferred registry-removal attach happen **while the op's own reservation still holds**, so the isolate provably cannot begin teardown during the attach. +`g_active_ops > 0` is the exact invariant that keeps the isolate alive (the waiter blocks on `while (g_active_ops > 0)`; the Case-4 synchronous fast path holds `g_mutex` throughout its `g_active_ops == 0` check + teardown). The fix moves the lock-free `g_isolate` read + registry-removal attach into a **short, self-contained `g_active_ops` reservation taken under `g_mutex`**, gated on teardown state — so the isolate provably cannot begin teardown across the attach, and the record-lifecycle machinery (`in_flight`, the worker-thread `g_active_ops--`, `bridge_end_op`) is **not** restructured. -**Split `bridge_finalize` into two phases:** - -- `bridge_finalize_registry(b)` — the isolate-touching phase: `fn_attach_thread(g_isolate, …)` + `fn_destroy_engine(thread, b->handle)` + `fn_detach_thread`. Precondition: **called only while the caller holds a live `g_active_ops` reservation** (or otherwise guarantees the isolate is alive — e.g. the JS-thread destroyEngine paths where `g_isolate` is stable). Retains the `g_isolate != NULL` guard for the documented main-env-after-isolate-teardown corner, but that read is now inside the reservation window, so it is no longer racing a concurrent teardown. -- `bridge_finalize_free(b, env_still_alive)` — the non-isolate phase: napi_ref deletion (owner JS thread, env alive; stays resolver-gated) + `resolver_results_free_all` + `free(b)`. Touches no GraalVM isolate state. - -**Reorder the streaming/transform completion sentinels** so the sequence on the worker/owner completion path is: +> **Mechanism decision:** the approved approach is the **transient reservation** below, not the more invasive "move `in_flight--`/`g_active_ops--` onto the worker thread and split the completion path across threads." In the live code the op's own `g_active_ops--` happens on the worker thread (`streaming_thread_fn:746` / `transform_thread_fn:1241`) while the finalize decision runs later on the JS thread (`call_js_write` → `bridge_end_op` → `bridge_finalize`); threading the reservation through that split would re-open the round-5/9/10/11 completion coordination the "bounded" constraint keeps closed. The transient reservation closes the identical race by taking a *fresh* reservation only around the attach, wherever finalize happens. -1. `bridge_end_op` decides finalize/registry-removal under `g_mutex` (unchanged decision logic), -2. if registry removal is due: `bridge_finalize_registry(b)` **while `g_active_ops` for this op is still held**, -3. release `g_active_ops` (the exact verbatim pattern), -4. `bridge_finalize_free(b, env_still_alive)`. - -i.e. move the `g_active_ops--` release to sit **between** the registry-removal attach and the record-free, instead of before both. The napi_ref deletion and record-free never touch the isolate, so doing them after the release is safe; the attach never happens outside the reservation window, so `g_isolate` is never read while a teardown could be destroying it. - -**Deadlock-safety (the load-bearing review gate):** holding `g_active_ops` across `bridge_finalize_registry` must not re-introduce the round-5 deadlock. The round-5 deadlock was: a *blocking wait on the JS event loop* while an op needed that loop. `bridge_finalize_registry` attaches its **own** Graal thread and makes **no** env-affine N-API call and **no** wait on the JS loop — it cannot depend on the event loop turning, so holding the reservation across it cannot wedge the waiter. This must be explicitly confirmed in review. +**Split `bridge_finalize` into two phases:** -**Preserves round-5's decrement-on-worker-thread reasoning:** the `g_active_ops--` stays on the worker/completion thread (not moved back to a JS callback); we only move *where within that completion path* it sits relative to the registry-removal attach. +- `bridge_finalize_registry(b)` — the isolate-touching phase. It takes its **own** transient `g_active_ops` reservation, checking teardown state in the *same critical section* as the increment: + + ```c + static void bridge_finalize_registry(engine_bridge_t* b) { + if (b == NULL || !fn_destroy_engine) return; + uv_mutex_lock(&g_mutex); + // If the isolate is already being physically torn down, or is gone, the + // Java registry died (or is dying) with it -- nothing to remove, and an + // attach would race graal_tear_down_isolate. Skip. The check and the + // g_active_ops++ are ONE critical section, so no teardown path (Case-4 + // sync, which holds g_mutex throughout; the waiter's TEARING_DOWN publish, + // also under g_mutex) can interleave between them. + if (g_teardown_state == TEARDOWN_TEARING_DOWN || g_isolate == NULL) { + uv_mutex_unlock(&g_mutex); + return; + } + g_active_ops++; // pins the live isolate against teardown + uv_mutex_unlock(&g_mutex); + + void* thread = NULL; + if (fn_attach_thread(g_isolate, &thread) == 0) { + fn_destroy_engine(thread, b->handle); + fn_detach_thread(thread); + } + + uv_mutex_lock(&g_mutex); + g_active_ops--; + uv_cond_broadcast(&g_teardown_cond); // verbatim release pattern + uv_mutex_unlock(&g_mutex); + } + ``` + +- `bridge_finalize_free(b, env_still_alive)` — the non-isolate phase: napi_ref deletion (owner JS thread, env alive; stays resolver-gated `resolver_js != NULL && env != NULL`) + `resolver_results_free_all` + `free(b)`. Touches no GraalVM isolate state. + +`bridge_finalize(b, env_still_alive, do_registry_remove)` becomes a thin wrapper preserving its exact current signature and every call site: `if (do_registry_remove) bridge_finalize_registry(b); bridge_finalize_free(b, env_still_alive);`. All existing callers (the two creators' rollback, `bridge_env_cleanup` direct path, `bridge_end_op`, `napi_destroy_engine` immediate path) keep calling `bridge_finalize` unchanged — the reservation-guarded registry removal is now automatic for all of them. + +**No completion-path restructuring.** `streaming_thread_fn` / `transform_thread_fn` keep their existing worker-thread `g_active_ops--` (verbatim) and `bridge_end_op` calls exactly as-is; `bridge_end_op` keeps its `in_flight--` + finalize-decision logic exactly as-is. Only the *body* of the registry-removal step (now inside `bridge_finalize_registry`) changes. + +**Why this closes the race, against all three teardown paths:** +- **Waiter (Case 5 → `TEARING_DOWN`):** the waiter publishes `TEARDOWN_TEARING_DOWN` under `g_mutex` *before* dropping the lock to call `graal_tear_down_isolate`. `bridge_finalize_registry`'s check+increment is one critical section: either it runs first (increments `g_active_ops`, so the waiter's `while (g_active_ops > 0 ...)` blocks until the attach completes and releases), or the waiter wins and publishes `TEARING_DOWN`/clears `g_isolate` first (so the check skips). No attach ever overlaps `graal_tear_down_isolate`. +- **Sync fast path (Case 4):** holds `g_mutex` across its `g_active_ops == 0` check *and* the spawn/join of `cleanup_thread_fn`. `bridge_finalize_registry` cannot acquire the lock mid-teardown; it either increments before Case 4 reads `g_active_ops` (Case 4 then sees > 0 and defers to a waiter) or runs after Case 4 cleared `g_isolate`/`g_initialized` (check skips). +- **Adoption:** never tears down (`g_teardown_cancelled`), so `g_isolate` stays valid; a stray attach is harmless. + +**Deadlock-safety (the load-bearing review gate):** the transient reservation must not re-introduce the round-5 deadlock. Round-5's deadlock was a *blocking wait on the JS event loop* while an op needed that loop. `bridge_finalize_registry` attaches its **own** Graal thread, makes **no** env-affine N-API call and **no** wait on the JS loop, and its reservation is released in the same function after a bounded `fn_destroy_engine` — it cannot depend on the event loop turning, and its reservation is never held across a JS callback. This must be explicitly confirmed in review. + +**Preserves round-5's decrement-on-worker-thread reasoning:** the op's own `g_active_ops--` stays on the worker thread, untouched. `bridge_finalize_registry`'s reservation is an additional, independent, short-lived one. ### 3. `runTransform` readiness re-check after pre-buffering (#4) @@ -146,12 +179,12 @@ Create `native-lib/node/tests/integration/worker-lifecycle.test.ts` using real ` - Handle width stays C `long long` everywhere. - Errors for run/streaming/transform APIs surface as **resolved** JSON string values (async) or a synchronous `napi_throw_error` (admission/arg/alloc/resource failures) — never `napi_reject_deferred`. - `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread. No env-affine napi call may be made off the owning thread. `bridge_finalize_free`'s napi_ref deletion stays resolver-gated (`resolver_js != NULL && env != NULL`) and on the owner thread. -- All shared C state (`g_initialized`, `g_active_ops`, `g_teardown_state`, `g_teardown_cancelled`, `g_ref_count`, every engine record's `in_flight`/`destroy_pending`/`deferred_registry_remove`/the new `deferred_ref_release`) is read/written only under `g_mutex`, **except** the `g_isolate` read inside `bridge_finalize_registry`, which is now only reached while a live `g_active_ops` reservation (or the creating JS thread's stable-isolate guarantee) holds the isolate alive — closing the round-12 #3 race. +- All shared C state (`g_initialized`, `g_active_ops`, `g_teardown_state`, `g_teardown_cancelled`, `g_ref_count`, every engine record's `in_flight`/`destroy_pending`/`deferred_registry_remove`/the new `deferred_ref_release`) is read/written only under `g_mutex`. In `bridge_finalize_registry` the `g_teardown_state`/`g_isolate` check and the transient `g_active_ops++` are one critical section under `g_mutex`; the subsequent `g_isolate` read for the attach happens only after that increment pinned the isolate alive (the check having ruled out `TEARING_DOWN`/NULL) — closing the round-12 #3 race. - 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);` - **Exactly one `g_ref_count` release per `initialize()`** (the #2 invariant): `napi_cleanup` for explicitly-cleaned instances; the env-cleanup path for abandoned envs; `destroyEngine` never releases. Mutually exclusive per engine. - `fn_destroy_engine` is called **exactly once** per handle. - Per-engine `in_flight` and global `g_active_ops` stay **distinct** counters — not merged. -- The round-5 deadlock fix must be preserved: `g_active_ops--` stays on the worker/completion thread, never moved to a JS-thread callback; and no blocking wait on the JS event loop is introduced. Holding `g_active_ops` across `bridge_finalize_registry` is safe only because that phase makes no env-affine/JS-loop-dependent call — confirm in review. +- The round-5 deadlock fix must be preserved: the op's own `g_active_ops--` stays on the worker/completion thread, never moved to a JS-thread callback; and no blocking wait on the JS event loop is introduced. `bridge_finalize_registry`'s transient reservation is taken and released within that one function, never held across a JS callback, and its guarded step makes no env-affine/JS-loop-dependent call — confirm in review. - Preserve every round-1..11 fix: coalesced instance `cleanup()`, the JS three-state lifecycle machine, the `TEARDOWN_*` state machine + `napi_initialize` adoption path (incl. round-7's `g_teardown_cancelled` carve-out), the worker-thread `g_active_ops` decrement, the atomic admission blocks + round-11 admission-time engine pin (`bridge_begin_op_locked`) in all three run paths, the round-6 handle-read validations, round-7 conversion-status checks, round-8 setup-allocation NULL checks, round-9 worker/callback OOM + N-API-create checks + deferred registry removal, round-10 env-cleanup registry removal + `g_isolate`-guarded finalize, round-11 env cleanup hook for every engine + owner-thread destroy guard for every record + register-once exit hooks. - Node vitest baseline currently **885 passed / 59 skipped / 0 failed**; this round raises it (new tests) and must stay green. @@ -159,8 +192,9 @@ Create `native-lib/node/tests/integration/worker-lifecycle.test.ts` using real ` - **#2 by decrementing `g_ref_count` inline in `bridge_finalize` without the shared helper.** Rejected: the "reached zero → immediate teardown vs. queue the waiter" decision already lives in `napi_cleanup` Case 5; duplicating it invites divergence. A single `release_isolate_ref_locked()` keeps both paths identical. - **#2 by having `destroyEngine` also release the ref.** Rejected: `destroyEngine`'s JS caller (`doCleanup`) always follows with `ffi.cleanup()`, which releases the ref; adding a release in `destroyEngine` would double-release and tear the isolate down under live instances. -- **#3 by taking `g_mutex` around the `g_isolate` read + attach in `bridge_finalize`.** Rejected: `fn_attach_thread`/`fn_destroy_engine` enter GraalVM and can block; holding `g_mutex` across them would serialize all teardown coordination behind a GraalVM call and risk lock-ordering issues with the waiter. Gating on the already-held `g_active_ops` reservation is the correct, lock-free-read-safe closure. -- **#3 with a dedicated `g_finalizing` counter separate from `g_active_ops`.** Rejected as re-opening the coordination substructure the approved approach keeps bounded: it adds a second teardown-gating counter that the waiter must also wait on, duplicating what `g_active_ops` already expresses. Reusing the op's existing reservation window is simpler and provably correct. +- **#3 by taking `g_mutex` around the `g_isolate` read + attach in `bridge_finalize`.** Rejected: `fn_attach_thread`/`fn_destroy_engine` enter GraalVM and can block; holding `g_mutex` across them would serialize all teardown coordination behind a GraalVM call and risk lock-ordering issues with the waiter. The transient reservation holds `g_mutex` only for the check+increment, then releases it before the GraalVM attach. +- **#3 by moving `in_flight--`/`g_active_ops--` onto the worker thread and reusing the op's own reservation across the finalize (spec's earlier literal wording).** Rejected as re-opening the round-5/9/10/11 completion coordination the approved approach keeps bounded: in the live code the op's `g_active_ops--` is on the worker thread while the finalize decision runs later on the JS thread via `bridge_end_op`; threading one reservation across that split would restructure `bridge_end_op` and both completion sentinels across thread boundaries. The transient reservation closes the identical race by taking a *fresh* short-lived reservation only around the attach, wherever finalize runs — no completion-path restructuring. +- **#3 with a dedicated `g_finalizing` counter separate from `g_active_ops`.** Rejected as re-opening the coordination substructure the approved approach keeps bounded: it adds a second teardown-gating counter that the waiter must also wait on, duplicating what `g_active_ops` already expresses. A transient `g_active_ops` reservation reuses the counter the waiter already blocks on and is provably correct. - **#4 via a JS-side operation lease that blocks `cleanup()` until the transform completes.** Rejected (same as rounds 9/11): no per-engine "await my ops" primitive exists at the JS layer, and the authoritative pin already lives in C. The re-check is the minimal ergonomic close; the lease would duplicate the C pin's guarantee at a layer that cannot enforce it. - **#5 by not nulling `globalInstance` until the drain settles.** Rejected: a concurrent convenience-API call would then revive/return the instance mid-teardown. Nulling synchronously (so new work builds a fresh instance) plus a module `cleanupPromise` (so overlapping `cleanup()`s coalesce) matches the instance-level design and is correct. - **#6 by leaving the handle valid and logging on hook-registration failure.** Rejected: a handle with no env cleanup hook silently reintroduces exactly the #2 leak the round is closing. Creation must be all-or-nothing. From e04570f1fe9faf0f06fe3d5eb2d91b3644002234 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 20:50:17 -0300 Subject: [PATCH 076/216] W-23692110: Extract release_isolate_ref_locked from napi_cleanup (round 12 #2 prep) Behavior-preserving refactor: lift napi_cleanup's Case 1..5 decrement-and-teardown body into a g_mutex-held helper that the round-12 #2 abandoned-env path will reuse. napi_cleanup now just locks and delegates. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 18 ++++++++--- .../integration/instance-lifecycle.test.ts | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 7e2bfe71..47696762 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2141,10 +2141,14 @@ static napi_value already_resolved_promise(napi_env env) { return promise; } -static napi_value napi_cleanup(napi_env env, napi_callback_info info) { - (void)info; - uv_mutex_lock(&g_mutex); - +// Releases ONE initialization reference on the shared isolate. Caller MUST +// hold g_mutex; this function UNLOCKS g_mutex before returning (the sync and +// waiter teardown paths both require dropping the lock). Returns the napi +// promise to hand back to the JS caller. This is napi_cleanup's original +// Case 1..5 body, extracted verbatim so the abandoned-env path (round-12 #2) +// can share the exact same "reached zero -> tear down now vs. defer to the +// waiter" decision without duplicating it. +static napi_value release_isolate_ref_locked(napi_env env) { // 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 @@ -2267,6 +2271,12 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { return promise; } +static napi_value napi_cleanup(napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + return release_isolate_ref_locked(env); // unlocks g_mutex, returns the promise +} + // --- Module init --- static void init_g_mutex(void) { diff --git a/native-lib/node/tests/integration/instance-lifecycle.test.ts b/native-lib/node/tests/integration/instance-lifecycle.test.ts index 03f9a662..a263f9c6 100644 --- a/native-lib/node/tests/integration/instance-lifecycle.test.ts +++ b/native-lib/node/tests/integration/instance-lifecycle.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, afterEach } from "vitest"; import { DataWeave } from "../../src/dataweave"; import { DataWeaveError } from "../../src/errors"; +import * as ffi from "../../src/ffi"; +import { findLibrary, buildInputsJson } from "../../src/utils"; // Same-instance lifecycle regression tests (round 6, W-23692110). Round 5's // coverage used a second instance; the same-instance cleanup window is exactly @@ -76,3 +78,33 @@ describe("instance lifecycle during cleanup (round 6)", () => { await Promise.all([a, b]); }); }); + +// Round 12, Task 1: napi_cleanup's Case 1..5 decrement-and-teardown body was +// lifted verbatim into release_isolate_ref_locked() so a later task (round-12 +// #2) can reuse it from the abandoned-env path. This is a behavior-preserving +// refactor; this test pins the observable contract it must not disturb: the +// balancing cleanup() call that drops the ref count to zero must actually +// tear the isolate down synchronously, not leave it silently live. +// +// Driven through the raw `ffi` boundary (like handle-validation.test.ts and +// engine-handle-contract.test.ts), with a balanced initialize()/cleanup() +// pair, so this file doesn't leak a ref-count bump into sibling integration +// test files sharing the same vitest worker process. +describe("napi_cleanup refactor preserves last-release teardown (round 12 Task 1)", () => { + it("the balancing cleanup() actually tears the isolate down (subsequent engine call sees not-initialized)", async () => { + ffi.initialize(findLibrary()); + const h = ffi.createEngine(); + const envelope = JSON.parse( + ffi.runScriptEngine(h, "%dw 2.0\noutput application/json\n---\n1 + 1", buildInputsJson({})) + ); + expect(envelope.success).toBe(true); + expect(JSON.parse(Buffer.from(envelope.result, "base64").toString("utf-8"))).toBe(2); + ffi.destroyEngine(h); + await ffi.cleanup(); + // Ref count reached 0 and the isolate was torn down: a fresh engine call + // must observe "not initialized", not silently run on a live isolate. + expect(() => + ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) + ).toThrow(/not initialized/i); + }); +}); From 8756d15a4211a8d93751e0cfe1eba86b94e43e4f Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 19 Aug 2026 21:00:20 -0300 Subject: [PATCH 077/216] W-23692110: Split bridge_finalize; guard registry attach with transient g_active_ops reservation (round 12 #3) bridge_finalize_registry now checks teardown state and takes a transient g_active_ops reservation in one critical section before the g_isolate attach, so graal_tear_down_isolate() can never run across fn_destroy_engine. bridge_finalize_free does the resolver-gated napi_ref delete + record free. The 3-arg bridge_finalize wrapper and all call sites are unchanged. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 61 +++++++++++++++---- .../engine-handle-contract.test.ts | 31 ++++++++++ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 47696762..8869191a 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -221,20 +221,46 @@ static engine_bridge_t* bridge_find(long long handle) { // 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; - // g_isolate is read without g_mutex here -- the same lock-free g_isolate - // read napi_destroy_engine's fallback below already does, but with an added - // NULL check that makes a torn-down isolate a no-op instead of an unsafe - // fn_attach_thread(NULL, ...). This - // matters for the env-cleanup deferred-drain path, where the isolate may - // already be gone (main env tearing down after napi_cleanup tore it down); - // there the Java registry died with the isolate, so there is nothing to - // remove and skipping is correct. - if (do_registry_remove && fn_destroy_engine && g_isolate) { - void* thread = NULL; - if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, b->handle); fn_detach_thread(thread); } +// #3 (round 12): the isolate-touching registry removal. Takes a TRANSIENT +// g_active_ops reservation so graal_tear_down_isolate() cannot run across the +// attach. The teardown-state check and the g_active_ops++ are ONE critical +// section: no teardown path can interleave between "isolate is live" and +// "reservation taken". Callable from any thread NOT holding g_mutex. +static void bridge_finalize_registry(engine_bridge_t* b) { + if (b == NULL || fn_destroy_engine == NULL) return; + uv_mutex_lock(&g_mutex); + // If the waiter already committed to physical teardown (TEARING_DOWN) or the + // isolate is already gone, the Java registry died/dies with it -- nothing to + // remove, and attaching would race graal_tear_down_isolate. Skip. Because + // the waiter publishes TEARING_DOWN (and Case 4 holds g_mutex across its + // g_active_ops==0 check + teardown) under this same lock, this check plus the + // increment below cannot be split by a teardown. + if (g_teardown_state == TEARDOWN_TEARING_DOWN || g_isolate == NULL) { + uv_mutex_unlock(&g_mutex); + return; } + g_active_ops++; // pins the live isolate against teardown for this attach + uv_mutex_unlock(&g_mutex); + + void* thread = NULL; + if (fn_attach_thread(g_isolate, &thread) == 0) { + fn_destroy_engine(thread, b->handle); + fn_detach_thread(thread); + } + + // Verbatim g_active_ops release pattern. + uv_mutex_lock(&g_mutex); + g_active_ops--; + uv_cond_broadcast(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); +} + +// The non-isolate finalize phase: delete the resolver napi_ref (owner JS thread +// only, and only while its env is alive -- resolver-gated), free tracked result +// buffers, free the record. Touches no GraalVM isolate state, so it is safe to +// run after the g_active_ops reservation above is released. +static void bridge_finalize_free(engine_bridge_t* b, bool env_still_alive) { + if (b == NULL) return; if (env_still_alive && b->resolver_js != NULL && b->env != NULL) { napi_delete_reference(b->env, b->resolver_js); } @@ -242,6 +268,15 @@ static void bridge_finalize(engine_bridge_t* b, bool env_still_alive, bool do_re free(b); } +// Thin wrapper preserving the original signature and every call site. Registry +// removal (if requested) runs first under its transient reservation, then the +// record is freed. +static void bridge_finalize(engine_bridge_t* b, bool env_still_alive, bool do_registry_remove) { + if (b == NULL) return; + if (do_registry_remove) bridge_finalize_registry(b); + bridge_finalize_free(b, env_still_alive); +} + // 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 diff --git a/native-lib/node/tests/integration/engine-handle-contract.test.ts b/native-lib/node/tests/integration/engine-handle-contract.test.ts index bbbd674c..21ca609f 100644 --- a/native-lib/node/tests/integration/engine-handle-contract.test.ts +++ b/native-lib/node/tests/integration/engine-handle-contract.test.ts @@ -231,6 +231,37 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { 60000 ); + it("deferred registry removal after an in-flight op finalizes without wedging the isolate (round 12 #3)", async () => { + // Uses the shared beforeAll isolate. Create an engine, start a streaming + // op, destroy the engine while the op is admitted, drain the op. The + // deferred finalize (bridge_end_op -> bridge_finalize_registry) must + // complete and a subsequent run on a fresh engine must still work + // (isolate not torn down / not wedged by the transient reservation). + const handle = ffi.createEngine(); + const chunks: Buffer[] = []; + const resultPromise = ffi.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1, 2, 3]", + buildInputsJson({}), + (chunk) => chunks.push(chunk) + ); + expect(() => ffi.destroyEngine(handle)).not.toThrow(); + const raw = await resultPromise; + const parsed = JSON.parse(raw); + // Pin held at admission (round 11) -> success expected; either way no crash. + if (parsed.success) { + expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toEqual([1, 2, 3]); + } + // Isolate still healthy after the deferred finalize ran: + const h2 = ffi.createEngine(); + const envelope = JSON.parse( + ffi.runScriptEngine(h2, "%dw 2.0\noutput application/json\n---\n2 + 2", buildInputsJson({})) + ); + expect(envelope.success).toBe(true); + expect(JSON.parse(Buffer.from(envelope.result, "base64").toString("utf-8"))).toBe(4); + ffi.destroyEngine(h2); + }); + it("final cleanup drains the shared isolate (idempotent)", async () => { // Exactly one ffi.initialize() ran for this whole file (beforeAll), so // this is the ONE balancing ffi.cleanup() that brings the native From 9e1e11e2f82da38e9e10b91d01f7c3fd7f9cb2e5 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 09:43:13 -0300 Subject: [PATCH 078/216] W-23692110: Release the init reference on abandoned-env teardown (round 12 #2) bridge_env_cleanup now releases the engine's initialize() reference: directly when in_flight==0, or deferred via the new deferred_ref_release flag when an op is draining (bridge_end_op performs it). isolate_ref_release_core_locked is the promise-less sibling of release_isolate_ref_locked -- it drives the same last-release teardown decision without binding a napi promise to a tearing-down env. Exactly one release per initialize(); destroyEngine still never releases. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 120 +++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 7 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 8869191a..f3ec4e01 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -110,6 +110,15 @@ typedef struct engine_bridge { // otherwise a resolver-backed engine's ScriptRuntime is left registered with // a CallbackWeaveResourceResolver whose ctx points at the freed bridge (UAF). bool deferred_registry_remove; + // True when bridge_env_cleanup deferred an ABANDONED-env finalize because + // in_flight > 0. Unlike deferred_registry_remove (which the destroyEngine + // path also sets), this is set ONLY by the env-cleanup hook, and tells the + // draining op (bridge_end_op) to ALSO release this engine's initialize() + // reference (round-12 #2) -- exactly one release per abandoned engine. The + // destroyEngine deferral never sets it (that path is paired with an explicit + // ffi.cleanup() in JS, which releases the ref itself; setting it would + // double-release). + bool deferred_ref_release; struct engine_bridge* next; } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex @@ -161,6 +170,12 @@ typedef struct teardown_waiter { } teardown_waiter_t; static teardown_waiter_t* g_teardown_waiters = NULL; // linked list, guarded by g_mutex +// Forward declaration: defined near release_isolate_ref_locked (after +// cleanup_thread_fn/teardown_waiter_thread_fn, which it spawns), but needed by +// bridge_env_cleanup/bridge_end_op above that point. See the definition for +// full documentation. +static void isolate_ref_release_core_locked(void); + // 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 @@ -308,9 +323,20 @@ static void bridge_env_cleanup(void* arg) { // ScriptRuntime is left registered with a resolver ctx pointing at the // freed bridge. Set the deferred-registry-removal flag here. b->deferred_registry_remove = true; + // round-12 (#2): the draining op must ALSO release this abandoned + // engine's initialize() reference (see engine_bridge_t.deferred_ref_release). + b->deferred_ref_release = true; uv_mutex_unlock(&g_mutex); return; } + // in_flight == 0: finalize now. Release the abandoned engine's init + // reference under the lock first (round-12 #2), THEN unlock and finalize. + // The order matters: isolate_ref_release_core_locked may tear the isolate + // down (or start the waiter), and bridge_finalize_registry inside finalize + // checks teardown state under g_mutex, so a torn-down/TEARING_DOWN isolate + // makes the registry removal a correct no-op (the Java registry died with + // the isolate). + isolate_ref_release_core_locked(); uv_mutex_unlock(&g_mutex); // We are inside Node's invocation of this hook, so we must not (and need not) @@ -369,6 +395,13 @@ static void bridge_end_op(engine_bridge_t* b, bool env_still_alive) { b->in_flight--; bool finalize = (b->destroy_pending && b->in_flight == 0); bool remove_registry = finalize && b->deferred_registry_remove; + bool release_ref = finalize && b->deferred_ref_release; + // round-12 (#2): if the env-cleanup hook deferred this abandoned engine's + // finalize, release its initialize() reference here, under the same lock, as + // the last op drains. Do it BEFORE unlocking so the teardown decision is made + // atomically with the in_flight==0 observation. destroyEngine's deferral does + // NOT set deferred_ref_release (its JS caller releases via ffi.cleanup()). + if (release_ref) isolate_ref_release_core_locked(); uv_mutex_unlock(&g_mutex); // remove_registry is true when either destroyEngine (round-9 #1) or the env // cleanup hook (round-10 #1) deferred the registry removal while this op was @@ -1696,10 +1729,13 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // record leaves resolver_js/results NULL. Round-11 (#1): it now ALSO registers // an env cleanup hook (mirroring napi_create_engine_with_resolver), because // without one a Worker that creates a resolver-less engine and exits without - // destroyEngine() strands this record, the Java registry entry, and the - // native-lib reference. owner is recorded for symmetry but is NOT used to - // restrict destruction based on resolver state (see the owner guard in - // napi_destroy_engine, which now fires for any record). + // destroyEngine() would strand this record, the Java registry entry, and the + // native-lib reference. Round-12 (#2) closed the last of those: the hook now + // reclaims all three -- the record and registry entry via bridge_finalize, + // and the native-lib initialize() reference via isolate_ref_release_core_locked. + // owner is recorded for symmetry but is NOT used to restrict destruction based + // on resolver state (see the owner guard in napi_destroy_engine, which now + // fires for any record). 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 @@ -1717,13 +1753,16 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { uv_mutex_lock(&g_mutex); rec->next = g_bridges; g_bridges = rec; uv_mutex_unlock(&g_mutex); // Round-11 (#1): register an env cleanup hook for EVERY engine, not just // resolver-backed ones. Without it, a Worker that creates a resolver-less - // engine and exits without destroyEngine() strands this record, the Java + // engine and exits without destroyEngine() would strand this record, the Java // ScriptRuntime registry entry, and the native-lib reference -- leaking // engines and blocking isolate teardown across Worker churn. bridge_env_cleanup // + bridge_finalize already handle a resolver-less record (resolver_js == NULL): // skip the napi_ref delete, still unlink, remove the registry entry (round-10 - // do_registry_remove=true), and free. destroyEngine removes this hook before - // an early free so Node never invokes it on freed memory. + // do_registry_remove=true), and free. Round-12 (#2) closed the reference leak: + // the hook now also releases the native-lib initialize() reference (directly, + // or via bridge_end_op if an op is draining), so all three are reclaimed. + // destroyEngine removes this hook before an early free so Node never invokes + // it on freed memory. napi_add_env_cleanup_hook(env, bridge_env_cleanup, rec); napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; @@ -2176,6 +2215,67 @@ static napi_value already_resolved_promise(napi_env env) { return promise; } +// Promise-less core of an isolate-reference release. Caller holds g_mutex and +// this function KEEPS it held (does not unlock). Decrements g_ref_count and, on +// the last release, drives teardown WITHOUT binding any napi promise/waiter: +// - g_active_ops == 0 -> synchronous cleanup_thread_fn (same as Case 4). +// - g_active_ops > 0 -> spawn the waiter thread with an EMPTY waiter list +// (TEARDOWN_PENDING_WAIT); it tears down (or is adopted) +// with no promises to resolve. +// - a teardown already pending (TEARDOWN_NONE != state) -> nothing to do; the +// existing waiter will tear down; this release just +// drops the count. +// Used by the abandoned-env path (bridge_env_cleanup / bridge_end_op, round-12 +// #2), which has no live JS caller to hand a promise to. +// +// Deliberately does NOT call (or get called by) release_isolate_ref_locked +// below: that promise-bearing sibling needs per-caller promise plumbing this +// core omits on purpose (binding a waiter/promise to a tearing-down env is a +// thread-affinity hazard). They share the last-release *policy* only; see +// release_isolate_ref_locked's header comment for the promise-bearing twin. +static void isolate_ref_release_core_locked(void) { + if (g_ref_count > 0) { + g_ref_count--; + } + if (g_ref_count > 0) return; // not the last reference + if (g_teardown_state != TEARDOWN_NONE) return; // a teardown is already driving + + 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; + 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); + } + if (torn_down) { + g_thread = NULL; + g_isolate = NULL; + g_initialized = 0; + g_ref_count = 0; + } + return; + } + + // g_active_ops > 0: defer to the waiter thread, no promises attached. + g_teardown_state = TEARDOWN_PENDING_WAIT; + g_teardown_cancelled = false; + g_teardown_waiters = NULL; // no JS caller waiting + 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; + int spawn_rc = uv_thread_create_ex(&waiter_tid, &waiter_opts, teardown_waiter_thread_fn, NULL); + if (spawn_rc != 0) { + // Waiter never started: roll back so state is not wedged and the isolate + // stays live (mirrors napi_cleanup Case-5 spawn-failure degradation). + g_teardown_state = TEARDOWN_NONE; + g_ref_count = 1; + } +} + // Releases ONE initialization reference on the shared isolate. Caller MUST // hold g_mutex; this function UNLOCKS g_mutex before returning (the sync and // waiter teardown paths both require dropping the lock). Returns the napi @@ -2183,6 +2283,12 @@ static napi_value already_resolved_promise(napi_env env) { // Case 1..5 body, extracted verbatim so the abandoned-env path (round-12 #2) // can share the exact same "reached zero -> tear down now vs. defer to the // waiter" decision without duplicating it. +// +// Deliberately does NOT call (or get called by) isolate_ref_release_core_locked +// above: this promise-bearing version needs to bind a napi_deferred/waiter to +// `env` for Cases 3/5, which the promise-less core intentionally cannot do +// (there is no live JS caller in the abandoned-env path). They share the +// last-release policy only, not the promise mechanics. static napi_value release_isolate_ref_locked(napi_env env) { // 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 From e839c7cb9d3cef95b13ae2b94c02386c4d84e377 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 10:59:32 -0300 Subject: [PATCH 079/216] W-23692110: Document the 1:1 initialize<->engine-bridge assumption (round 12 #2 review) isolate_ref_release_core_locked implicitly assumes the sanctioned pairing of one initialize() reference to one engine bridge, as the product-facing DataWeave class enforces. Nothing in addon.c enforces this for a raw-ffi caller: creating multiple engines under a single initialize() would register one env-cleanup hook per engine, each capable of over-releasing g_ref_count under a cross-env teardown race. Comment-only; no behavior change. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index f3ec4e01..6bee6504 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2233,6 +2233,15 @@ static napi_value already_resolved_promise(napi_env env) { // core omits on purpose (binding a waiter/promise to a tearing-down env is a // thread-affinity hazard). They share the last-release *policy* only; see // release_isolate_ref_locked's header comment for the promise-bearing twin. +// +// Assumes the sanctioned 1:1 pairing of one initialize() reference to one +// engine bridge, exactly as the product-facing DataWeave class enforces (one +// initialize() call per engine, released together by one cleanup()). Nothing +// in this file enforces that pairing for a raw-ffi caller: creating multiple +// engines under a single initialize() registers one env-cleanup hook per +// engine, and each abandoned engine's hook would call this function -- so an +// out-of-contract multi-engine-per-initialize() caller could over-release +// g_ref_count under a cross-env teardown race. static void isolate_ref_release_core_locked(void) { if (g_ref_count > 0) { g_ref_count--; From b7784e6e7adf80d414de7d18cacd48405f3d740c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 11:10:48 -0300 Subject: [PATCH 080/216] W-23692110: Re-check readiness in runTransform after input pre-buffering (round 12 #4) createChunkReader can await arbitrarily for async input; if cleanup() runs during that await, the resumed dispatch used a nulled handle and could surface as a non-DataWeaveError failure instead of a clean synchronous DataWeaveError. Re-call ensureReady() after the await so the caller gets a synchronous DataWeaveError. C admission pin remains the authoritative guard. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 8 ++++ .../integration/instance-lifecycle.test.ts | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index af808ed1..71d653b9 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -236,6 +236,14 @@ export class DataWeave { const readCb = await createChunkReader(input); + // The instance may have been cleaned up while an async input pre-buffered + // (createChunkReader can await arbitrarily long). Re-check readiness so a + // caller that raced cleanup() gets a synchronous DataWeaveError rather than + // a resolved "Unknown engine handle" envelope. The C admission pin is the + // authoritative memory-safety guard (round 11 #2/#3); this only improves the + // failure ergonomics for a misused instance. (round 12 #4) + this.ensureReady(); + return yield* streamFromNative((writeCb) => ffi.runScriptTransformEngine( this.engineHandle!, diff --git a/native-lib/node/tests/integration/instance-lifecycle.test.ts b/native-lib/node/tests/integration/instance-lifecycle.test.ts index a263f9c6..fe6c882f 100644 --- a/native-lib/node/tests/integration/instance-lifecycle.test.ts +++ b/native-lib/node/tests/integration/instance-lifecycle.test.ts @@ -108,3 +108,41 @@ describe("napi_cleanup refactor preserves last-release teardown (round 12 Task 1 ).toThrow(/not initialized/i); }); }); + +// Round 12, Task 4: createChunkReader pre-buffers async inputs by awaiting +// the entire iterable up front (see reader.ts), because the native read +// callback is invoked synchronously and cannot await. That await can span +// arbitrarily long, so if the caller cleans up the instance while it's in +// flight, runTransform must re-check readiness on resume rather than +// dispatching to a nulled/destroyed engine handle. +describe("runTransform re-checks readiness after async input pre-buffering (round 12 Task 4)", () => { + it("throws a synchronous DataWeaveError if cleanup() runs during createChunkReader's await, instead of resolving an error envelope", async () => { + const dw = new DataWeave(); + dw.initialize(); + + // An async input whose iterator blocks until released, so cleanup() can + // run while createChunkReader is still pre-buffering it. + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + async function* slowInput(): AsyncGenerator { + await gate; + yield Buffer.from("[1,2,3]"); + } + + const gen = dw.runTransform("%dw 2.0\noutput application/json\n---\npayload", slowInput(), { + mimeType: "application/json", + }); + + // Start driving the generator; it suspends awaiting createChunkReader -> + // slowInput's gate. + const firstNext = gen.next(); + // Clean up while the input is still pre-buffering. + await dw.cleanup(); + // Release the gate so createChunkReader's await resolves; the readiness + // re-check must now throw synchronously rather than proceeding to a + // nulled engine handle. + release(); + + await expect(firstNext).rejects.toBeInstanceOf(DataWeaveError); + }); +}); From a26e1192781d89e4a7c7103757f09cda5191818c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 11:21:50 -0300 Subject: [PATCH 081/216] W-23692110: Make engine creation all-or-nothing on cleanup-hook failure (round 12 #6) Both creators now capture napi_add_env_cleanup_hook's status; on failure they unlink the record, remove the Java registry entry, release the init reference, free the record, and throw -- so no handle without a cleanup hook (which would re-introduce the #2 leak) ever reaches JS. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 40 +++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 6bee6504..80c20ffd 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1763,7 +1763,25 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // or via bridge_end_op if an op is draining), so all three are reclaimed. // destroyEngine removes this hook before an early free so Node never invokes // it on freed memory. - napi_add_env_cleanup_hook(env, bridge_env_cleanup, rec); + napi_status hook_st = napi_add_env_cleanup_hook(env, bridge_env_cleanup, rec); + if (hook_st != napi_ok) { + // Creation must be all-or-nothing (round-12 #6): without a cleanup hook a + // Worker that abandons this engine would strand the record, the Java + // registry entry, and the init reference. Unlink, remove the registry + // entry, release this creation's init ref, free, and throw -- no usable + // handle escapes. The record was just linked on this thread with + // in_flight==0 and its handle was never returned to JS, so no op can be + // in flight against it. + uv_mutex_lock(&g_mutex); + engine_bridge_t** pp = &g_bridges; + while (*pp != NULL) { if (*pp == rec) { *pp = rec->next; break; } pp = &(*pp)->next; } + isolate_ref_release_core_locked(); + uv_mutex_unlock(&g_mutex); + bridge_finalize_registry(rec); + bridge_finalize_free(rec, /*env_still_alive=*/true); + napi_throw_error(env, NULL, "Failed to register engine cleanup hook"); + return NULL; + } napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; } @@ -1814,7 +1832,25 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i // 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_status hook_st = napi_add_env_cleanup_hook(env, bridge_env_cleanup, bridge); + if (hook_st != napi_ok) { + // Creation must be all-or-nothing (round-12 #6): without a cleanup hook a + // Worker that abandons this engine would strand the record, the Java + // registry entry, and the init reference. Unlink, remove the registry + // entry, release this creation's init ref, free, and throw -- no usable + // handle escapes. The record was just linked on this thread with + // in_flight==0 and its handle was never returned to JS, so no op can be + // in flight against it. + uv_mutex_lock(&g_mutex); + engine_bridge_t** pp = &g_bridges; + while (*pp != NULL) { if (*pp == bridge) { *pp = bridge->next; break; } pp = &(*pp)->next; } + isolate_ref_release_core_locked(); + uv_mutex_unlock(&g_mutex); + bridge_finalize_registry(bridge); + bridge_finalize_free(bridge, /*env_still_alive=*/true); + napi_throw_error(env, NULL, "Failed to register engine cleanup hook"); + return NULL; + } napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; } From 7ef75471142a2cdafe35193f15cec456a4c9e3d6 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 12:08:08 -0300 Subject: [PATCH 082/216] W-23692110: Fix double-release of init ref in engine-creation hook-failure path (round 12 #6 fix round 1) The hook-failure branches added in b6ab957 called isolate_ref_release_core_locked() before throwing, but that throw propagates to initialize()'s TS catch, which already calls ffi.cleanup() (releasing the same ref) whenever ffi.initialize() succeeded before engine creation threw. That's two releases for one initialize() increment -- masked in a single-instance process by the g_ref_count>0 guard, but a live UAF hazard with a second engine instance still holding a reference (native release 1->0 tears the isolate down under it). Remove the native release from both creators' failure branches so the TS catch's ffi.cleanup() remains the single release, matching every sibling creation-failure path (invalid-handle guards, alloc failure) that already relies on it. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 44 +++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 80c20ffd..7edbf77a 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1766,16 +1766,23 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { napi_status hook_st = napi_add_env_cleanup_hook(env, bridge_env_cleanup, rec); if (hook_st != napi_ok) { // Creation must be all-or-nothing (round-12 #6): without a cleanup hook a - // Worker that abandons this engine would strand the record, the Java - // registry entry, and the init reference. Unlink, remove the registry - // entry, release this creation's init ref, free, and throw -- no usable - // handle escapes. The record was just linked on this thread with - // in_flight==0 and its handle was never returned to JS, so no op can be - // in flight against it. + // Worker that abandons this engine would strand the record and the Java + // registry entry. Unlink, remove the registry entry, free, and throw -- + // no usable handle escapes. The record was just linked on this thread + // with in_flight==0 and its handle was never returned to JS, so no op + // can be in flight against it. + // Do NOT release the init reference here (fix round 1): this throw + // propagates to initialize()'s TS catch (dataweave.ts), which sees + // libRefAcquired==true and calls ffi.cleanup() -- that is the ONE + // release for this creation's ref, matching every sibling + // creation-failure path (invalid-handle guard, alloc failure) that also + // leaves the release to the TS catch. Releasing natively here too would + // double-decrement g_ref_count -- masked in a single-instance process + // (the guard no-ops a second release at 0) but a live UAF hazard with a + // second engine instance still holding a reference. uv_mutex_lock(&g_mutex); engine_bridge_t** pp = &g_bridges; while (*pp != NULL) { if (*pp == rec) { *pp = rec->next; break; } pp = &(*pp)->next; } - isolate_ref_release_core_locked(); uv_mutex_unlock(&g_mutex); bridge_finalize_registry(rec); bridge_finalize_free(rec, /*env_still_alive=*/true); @@ -1835,16 +1842,25 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i napi_status hook_st = napi_add_env_cleanup_hook(env, bridge_env_cleanup, bridge); if (hook_st != napi_ok) { // Creation must be all-or-nothing (round-12 #6): without a cleanup hook a - // Worker that abandons this engine would strand the record, the Java - // registry entry, and the init reference. Unlink, remove the registry - // entry, release this creation's init ref, free, and throw -- no usable - // handle escapes. The record was just linked on this thread with - // in_flight==0 and its handle was never returned to JS, so no op can be - // in flight against it. + // Worker that abandons this engine would strand the record and the Java + // registry entry. Unlink, remove the registry entry, free, and throw -- + // no usable handle escapes. The record was just linked on this thread + // with in_flight==0 and its handle was never returned to JS, so no op + // can be in flight against it. + // Do NOT release the init reference here (fix round 1): this throw + // propagates to initialize()'s TS catch (dataweave.ts), which sees + // libRefAcquired==true and calls ffi.cleanup() -- that is the ONE + // release for this creation's ref, matching every sibling + // creation-failure path (resolver invalid-handle guard uses + // bridge_finalize with do_registry_remove=false and also does NOT + // release) that also leaves the release to the TS catch. Releasing + // natively here too would double-decrement g_ref_count -- masked in a + // single-instance process (the guard no-ops a second release at 0) but + // a live UAF hazard with a second engine instance still holding a + // reference. uv_mutex_lock(&g_mutex); engine_bridge_t** pp = &g_bridges; while (*pp != NULL) { if (*pp == bridge) { *pp = bridge->next; break; } pp = &(*pp)->next; } - isolate_ref_release_core_locked(); uv_mutex_unlock(&g_mutex); bridge_finalize_registry(bridge); bridge_finalize_free(bridge, /*env_still_alive=*/true); From 8a038fe89e75826d9f0a70a18b2f6c9c5fddd454 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 12:15:30 -0300 Subject: [PATCH 083/216] W-23692110: Coalesce overlapping module-level cleanup() calls (round 12 #5) Add a module-scoped cleanupPromise so a second overlapping module cleanup() awaits the same in-flight drain instead of resolving immediately on a nulled globalInstance. Mirrors the instance-level coalescing; globalInstance still nulls synchronously and cleanupStarted still resets last. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 31 +++++++++++++------ .../integration/instance-lifecycle.test.ts | 27 +++++++++++++++- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 71d653b9..fbc3afe7 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -274,6 +274,13 @@ 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; +// Coalesces overlapping module-level cleanup() calls, mirroring the +// instance-level DataWeave.cleanupPromise. Without it, the second of two +// overlapping module cleanup() calls sees globalInstance already nulled and +// resolves immediately -- before the first call's native teardown finishes, +// violating cleanup()'s "resolves once native teardown has finished" contract +// for the last reference. (round 12 #5) +let cleanupPromise: Promise | null = null; // Process exit hooks are registered exactly once for the lifetime of the // module, NOT per singleton. Re-creating the singleton after cleanup() must // not attach a second pair of listeners (that accumulates until Node emits @@ -380,16 +387,20 @@ export function runTransform( * singleton is created lazily on the next convenience-API call. */ export async function cleanup(): Promise { - if (globalInstance) { - 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. + // Coalesce overlapping calls onto one drain (round 12 #5). + if (cleanupPromise) return cleanupPromise; + if (!globalInstance) return; + const instance = globalInstance; + globalInstance = null; + cleanupPromise = instance.cleanup(); + try { + await cleanupPromise; + } finally { + cleanupPromise = null; + // Reset the exit-hook guard only after the drain has fully completed, so a + // revived singleton gets its own live hooks for the next real exit. Must + // stay last: resetting earlier could let a concurrent `exit` firing on this + // same shutdown re-enter cleanup while the async drain above is in flight. cleanupStarted = false; } } diff --git a/native-lib/node/tests/integration/instance-lifecycle.test.ts b/native-lib/node/tests/integration/instance-lifecycle.test.ts index fe6c882f..08b411d9 100644 --- a/native-lib/node/tests/integration/instance-lifecycle.test.ts +++ b/native-lib/node/tests/integration/instance-lifecycle.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from "vitest"; -import { DataWeave } from "../../src/dataweave"; +import { DataWeave, run, cleanup } from "../../src/dataweave"; import { DataWeaveError } from "../../src/errors"; import * as ffi from "../../src/ffi"; import { findLibrary, buildInputsJson } from "../../src/utils"; @@ -146,3 +146,28 @@ describe("runTransform re-checks readiness after async input pre-buffering (roun await expect(firstNext).rejects.toBeInstanceOf(DataWeaveError); }); }); + +// Round 12, Task 6: the exported module-level cleanup() nulls globalInstance +// synchronously, then awaits instance.cleanup(). A second overlapping +// module-level cleanup() call must coalesce onto the SAME in-flight drain +// rather than seeing globalInstance already nulled and resolving immediately +// -- before the first call's native teardown actually finishes. +describe("module-level cleanup() coalescing (round 12 Task 6)", () => { + it("module-level cleanup() coalesces overlapping calls (round 12 #5)", async () => { + // Create the singleton. + expect(run("%dw 2.0\noutput application/json\n---\n1 + 1").success).toBe(true); + + let firstSettled = false; + const p1 = cleanup().then(() => { firstSettled = true; }); + // Second call overlaps the first's in-flight drain. + const p2 = cleanup(); + // The coalesced second call must not resolve before the first's drain does. + await p2; + expect(firstSettled).toBe(true); + await p1; + + // A subsequent run lazily revives the singleton (no wedged state). + expect(run("%dw 2.0\noutput application/json\n---\n2 + 2").success).toBe(true); + await cleanup(); + }); +}); From d7c4464ba37b7559da580e53fbe526457f9dd873 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 12:27:33 -0300 Subject: [PATCH 084/216] W-23692110: Require success for the admitted run-vs-destroy ordering (round 12 #8) The same-thread destroyEngine()-after-admission ordering is deterministic because the round-11 pin is already held at admission, so the op must succeed with complete chunks. Drop the 'or Unknown engine handle' tolerance for this ordering so a regression that removes the pin now fails the test. Co-Authored-By: Claude Sonnet 5 --- .../engine-handle-contract.test.ts | 64 +++++++++---------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/native-lib/node/tests/integration/engine-handle-contract.test.ts b/native-lib/node/tests/integration/engine-handle-contract.test.ts index 21ca609f..04a192c2 100644 --- a/native-lib/node/tests/integration/engine-handle-contract.test.ts +++ b/native-lib/node/tests/integration/engine-handle-contract.test.ts @@ -174,33 +174,29 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { expect(transformChunks).toHaveLength(0); }); - // Best-effort probabilistic guard (green on fixed code, cannot false-fail - // on it) -- matching the documented posture of rounds 5-10's cross-Worker - // races (see run-admission.test.ts / admission-during-teardown.test.ts): - // the exact interleaving of a concurrent destroyEngine() against the - // admission window of an in-flight streaming/transform op on the SAME - // handle is not deterministically forceable from JS. + // Same-thread post-admission ordering (deterministic, not best-effort): + // destroyEngine() is fired synchronously immediately after admission of the + // op (right after starting runScriptStreamingEngine, before awaiting it). + // The round-11 #2/#3 pin is taken atomically at admission, under g_mutex, in + // bridge_begin_op_locked -- so this same-thread ordering deterministically + // lands AFTER the pin is already held. That means the op MUST complete + // successfully with complete chunks; there is no closed set of "success or + // Unknown-engine-handle envelope" to tolerate here, because the envelope can + // only arise if the pin were NOT held at admission. Requiring success (and + // no longer accepting the envelope) makes this test fail if a future + // regression drops the admission-time pin, instead of silently passing by + // returning the accepted terminal envelope. // - // This harness has no existing `worker_threads` pattern to reuse (checked: - // no test file under tests/integration uses `worker_threads`/`Worker`), and - // spinning up a real Worker here would still race the SAME non-deterministic - // window -- it would not make the interleaving forceable, only add overhead - // and flakiness risk without truer coverage. Instead this uses the closest - // deterministic proxy available on a single thread: destroyEngine() is - // fired synchronously immediately after admission of the op (right after - // starting runScriptStreamingEngine, before awaiting it), which is exactly - // when a genuinely concurrent Worker's destroyEngine() would most plausibly - // land relative to the round-11 #2/#3 pin taken under g_mutex at admission. - // Because the pin is taken atomically at admission, this same-thread - // ordering deterministically lands AFTER the pin, so on fixed code every - // iteration is expected to observe a valid successful result (the pin keeps - // the engine alive for the run) -- but the test tolerates either outcome - // (success or the terminal Unknown-engine-handle envelope) and only fails - // if the process crashes or an iteration returns something outside that - // closed set, so it cannot false-fail on the fix and stays meaningful if - // future changes narrow the pinned window. + // Genuinely concurrent cross-thread interleavings (a real Worker racing + // destroyEngine() against admission on a different thread) are a distinct, + // non-deterministic window that this same-thread ordering does not exercise + // and cannot stand in for. That case remains covered best-effort by the + // forthcoming Worker-based suite (Task 8), matching the documented posture + // of rounds 5-10's cross-Worker races (see run-admission.test.ts / + // admission-during-teardown.test.ts) -- it is not tolerated away in this + // test. it( - "best-effort: destroyEngine() racing an in-flight streaming op never crashes and always ends in a valid result or the terminal envelope", + "destroyEngine() fired right after admission of an in-flight streaming op deterministically succeeds (pin held at admission)", async () => { const ITERATIONS = 50; for (let i = 0; i < ITERATIONS; i++) { @@ -212,20 +208,18 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { buildInputsJson({}), (chunk) => chunks.push(chunk) ); - // Fire the racing destroy as close to the admission window as this - // single thread allows: immediately after starting the op, before - // awaiting it. + // Fire destroy immediately after admission, before awaiting. The round-11 + // pin is taken atomically at admission (under g_mutex, in + // bridge_begin_op_locked), so this ordering lands AFTER the pin and the + // op MUST complete successfully. Requiring success (not tolerating the + // Unknown-engine-handle envelope) makes this test fail if a regression + // drops the admission-time pin. expect(() => ffi.destroyEngine(handle)).not.toThrow(); const raw = await resultPromise; const parsed = JSON.parse(raw); - - if (parsed.success) { - expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toEqual([1, 2, 3]); - } else { - expect(parsed).toEqual(UNKNOWN_ENVELOPE); - expect(chunks).toHaveLength(0); - } + expect(parsed.success).toBe(true); + expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toEqual([1, 2, 3]); } }, 60000 From 6ed316b83f40df11c99baeb5daf8b6f875aad10f Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 12:38:48 -0300 Subject: [PATCH 085/216] W-23692110: Add worker_threads lifecycle integration suite (round 12 #9) Real Worker coverage for the documented per-Worker engine model: resolver-backed and resolver-less engines running inside a Worker, built-in module resolution, N abandon-without-cleanup cycles proving the main thread stays healthy (round-12 #2 behavior), and Worker.terminate() mid-life. Workers load the built addon directly via an inline eval body; the file balances shared isolate state with a final main-thread cleanup. Co-Authored-By: Claude Sonnet 5 --- .../integration/worker-lifecycle.test.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 native-lib/node/tests/integration/worker-lifecycle.test.ts diff --git a/native-lib/node/tests/integration/worker-lifecycle.test.ts b/native-lib/node/tests/integration/worker-lifecycle.test.ts new file mode 100644 index 00000000..06688eb7 --- /dev/null +++ b/native-lib/node/tests/integration/worker-lifecycle.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { Worker } from "node:worker_threads"; +import { join } from "node:path"; +import * as ffi from "../../src/ffi"; +import { findLibrary, buildInputsJson } from "../../src/utils"; + +// W-23692110 round 12 #9: real worker_threads coverage for the documented +// per-Worker engine model (README "Custom module resolvers and Worker threads"). +// +// Workers cannot execute the TS sources (npm test runs vitest with no build for +// worker code, and a Worker spawns a fresh Node runtime), so each worker body is +// an inline JS string (eval:true) that require()s the BUILT addon directly -- +// the same raw-addon boundary engine-handle-contract.test.ts drives. addonPath +// and the dwlib path are resolved on the main thread and passed via workerData. +// +// Determinism posture: exact cross-thread teardown interleavings are NOT +// deterministically forceable (best-effort, matching rounds 5-11). The +// deterministic assertions here are: resolver-backed/less engines produce +// correct output inside a Worker, and after N Worker create/exit-without- +// cleanup() cycles the main thread still initializes/runs and the final +// teardown is clean (the round-12 #2 behavioral proof). + +const ADDON_PATH = join(__dirname, "..", "..", "build", "Release", "dwlib_addon.node"); +const LIB_PATH = findLibrary(); + +// Runs one Worker to completion and returns its posted message. `mode` selects +// resolver-backed vs resolver-less and whether the Worker cleans up or abandons. +function runWorker(opts: { + mode: "resolver" | "plain"; + cleanup: boolean; + script: string; +}): Promise<{ ok: boolean; output?: string; error?: string }> { + const body = ` + const { parentPort, workerData } = require('node:worker_threads'); + (async () => { + const addon = require(workerData.addonPath); + addon.initialize(workerData.libPath); + let handle; + if (workerData.mode === 'resolver') { + const resolver = (modulePath) => + modulePath === 'org/test/w.dwl' + ? '%dw 2.0\\nfun greet(n) = "W:" ++ n' + : null; + handle = addon.createEngineWithResolver(resolver); + } else { + handle = addon.createEngine(); + } + let msg; + try { + const raw = addon.runScriptEngine(handle, workerData.script, '{}'); + const parsed = JSON.parse(raw); + if (parsed.success === false) { + msg = { ok: false, error: parsed.error }; + } else { + // Non-streaming engine result carries base64 'result'; decode it. + const out = parsed.result ? Buffer.from(parsed.result, 'base64').toString('utf-8') : ''; + msg = { ok: true, output: out }; + } + } catch (e) { + msg = { ok: false, error: String(e) }; + } + if (workerData.cleanup) { + try { addon.destroyEngine(handle); } catch (_) {} + await addon.cleanup(); + } + parentPort.postMessage(msg); + // For the abandon variant we deliberately return WITHOUT cleanup so the + // env cleanup hook fires as the Worker env tears down. + })().catch((e) => { parentPort.postMessage({ ok: false, error: String(e) }); }); + `; + return new Promise((resolve, reject) => { + const w = new Worker(body, { + eval: true, + workerData: { addonPath: ADDON_PATH, libPath: LIB_PATH, mode: opts.mode, cleanup: opts.cleanup, script: opts.script }, + }); + let msg: any; + w.once("message", (m) => { msg = m; }); + w.once("error", reject); + w.once("exit", (code) => { + if (code !== 0 && !msg) reject(new Error("Worker exited " + code)); + else resolve(msg); + }); + }); +} + +describe("worker_threads engine lifecycle (round 12 #9)", () => { + afterAll(async () => { + // Final main-thread balancing cleanup so this file does not perturb sibling + // integration files sharing the vitest worker process. + await ffi.cleanup(); + }); + + it("a resolver-backed engine in a Worker resolves the Worker's own module", async () => { + const script = "%dw 2.0\nimport org::test::w\noutput application/json\n---\nw::greet(\"X\")"; + const msg = await runWorker({ mode: "resolver", cleanup: true, script }); + expect(msg.ok).toBe(true); + expect(JSON.parse(msg.output!)).toBe("W:X"); + }); + + it("a resolver-less engine in a Worker runs a plain script", async () => { + const script = "%dw 2.0\noutput application/json\n---\n6 * 7"; + const msg = await runWorker({ mode: "plain", cleanup: true, script }); + expect(msg.ok).toBe(true); + expect(JSON.parse(msg.output!)).toBe(42); + }); + + it("built-in modules resolve in a resolver-backed engine inside a Worker", async () => { + const script = + "%dw 2.0\nimport dw::core::Strings\noutput application/json\n---\nStrings::capitalize(\"hello\")"; + const msg = await runWorker({ mode: "resolver", cleanup: true, script }); + expect(msg.ok).toBe(true); + expect(JSON.parse(msg.output!)).toBe("Hello"); + }); + + it("N Workers that exit WITHOUT cleanup() do not wedge the isolate; main thread stays healthy (round 12 #2)", async () => { + const CYCLES = 5; + for (let i = 0; i < CYCLES; i++) { + const msg = await runWorker({ + mode: "resolver", + cleanup: false, // exit without cleanup -> env cleanup hook fires + script: "%dw 2.0\noutput application/json\n---\n" + i, + }); + expect(msg.ok).toBe(true); + } + // After all those abandoned Workers, the main thread must still initialize + // and run. Pre-fix, each abandoned Worker leaked its init reference and the + // isolate never returned to zero; the assertion here is behavioral (the + // process is not wedged and cleanup still tears down cleanly at afterAll). + ffi.initialize(LIB_PATH); + const h = ffi.createEngine(); + const envelope = JSON.parse( + ffi.runScriptEngine(h, "%dw 2.0\noutput application/json\n---\n1 + 1", buildInputsJson({})) + ); + expect(envelope.success).toBe(true); + expect(JSON.parse(Buffer.from(envelope.result, "base64").toString("utf-8"))).toBe(2); + ffi.destroyEngine(h); + await ffi.cleanup(); + }); + + it("Worker.terminate() mid-life leaves the main thread able to initialize and run", async () => { + const body = ` + const { workerData } = require('node:worker_threads'); + const addon = require(workerData.addonPath); + addon.initialize(workerData.libPath); + addon.createEngineWithResolver((p) => null); + // Spin so the parent can terminate() us mid-life (no message posted). + setInterval(() => {}, 10); + `; + const w = new Worker(body, { + eval: true, + workerData: { addonPath: ADDON_PATH, libPath: LIB_PATH }, + }); + // Give it time to initialize + create the engine, then terminate abruptly. + await new Promise((r) => setTimeout(r, 500)); + await w.terminate(); + + ffi.initialize(LIB_PATH); + const h = ffi.createEngine(); + const envelope = JSON.parse( + ffi.runScriptEngine(h, "%dw 2.0\noutput application/json\n---\n3 + 4", buildInputsJson({})) + ); + expect(envelope.success).toBe(true); + expect(JSON.parse(Buffer.from(envelope.result, "base64").toString("utf-8"))).toBe(7); + ffi.destroyEngine(h); + await ffi.cleanup(); + }); +}); From 3c7c1dde2edb617fb0c2e71609e2d93085ba5858 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 13:34:47 -0300 Subject: [PATCH 086/216] Fix module-level cleanup() orphaning a revived singleton; harden terminate() test Round-12 final review, three Minor findings, two files: - dataweave.ts: the exported module-level cleanup() coalesced overlapping calls purely on `cleanupPromise` being non-null, so a caller that revived the singleton via run() while an older drain was still in flight got the stale promise handed back -- the freshly revived instance was never drained and its native ref leaked for the rest of the process. Track which instance the in-flight promise is draining (`cleaningInstance`) and only coalesce when nothing new has been revived since. - worker-lifecycle.test.ts: the terminate() mid-life test used a fixed 500ms sleep before terminating (flaky under load, and could fire before the worker's engine existed) and had no error listener on the inline Worker (a worker-body throw could crash the vitest process). Replace the sleep with a ready handshake posted right after createEngineWithResolver, and wire up an error listener. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 51 +++++++++++++++--- .../integration/instance-lifecycle.test.ts | 53 +++++++++++++++++++ .../integration/worker-lifecycle.test.ts | 26 +++++++-- 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index fbc3afe7..4a18f58b 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -281,6 +281,14 @@ let cleanupStarted = false; // violating cleanup()'s "resolves once native teardown has finished" contract // for the last reference. (round 12 #5) let cleanupPromise: Promise | null = null; +// The instance that `cleanupPromise` is currently draining. Needed because +// coalescing must NOT be keyed on the module-global promise alone: if a +// caller revives the singleton (via run()/getGlobalInstance()) while a prior +// drain is still in flight, a subsequent cleanup() must clean the freshly +// revived instance rather than returning the stale promise as if it had +// covered it too -- otherwise the revived instance's native ref is silently +// leaked (final-review round 12 #1, fixing round 12 Task 6's regression). +let cleaningInstance: DataWeave | null = null; // Process exit hooks are registered exactly once for the lifetime of the // module, NOT per singleton. Re-creating the singleton after cleanup() must // not attach a second pair of listeners (that accumulates until Node emits @@ -387,20 +395,49 @@ export function runTransform( * singleton is created lazily on the next convenience-API call. */ export async function cleanup(): Promise { - // Coalesce overlapping calls onto one drain (round 12 #5). - if (cleanupPromise) return cleanupPromise; + // Coalesce overlapping calls onto one drain (round 12 #5) -- but ONLY when + // nothing new has been revived since that drain started. If `globalInstance` + // is still the same instance the in-flight promise is draining, or is null + // (nobody has revived since), it's safe to piggyback on the existing + // promise. If a DIFFERENT instance is now the singleton (a caller called + // run() and revived it while the old drain was still in flight), that new + // instance has never been handed to a cleanup() call -- returning the old + // promise here would resolve as if it had been cleaned when it hasn't, + // leaking its native ref for the rest of the process (final-review round 12 + // #1). Fall through and drain the current instance instead. + if (cleanupPromise && (globalInstance === null || globalInstance === cleaningInstance)) { + return cleanupPromise; + } if (!globalInstance) return; const instance = globalInstance; globalInstance = null; + // Chosen semantics for overlapping different-instance drains: coalescing + // tracks only the MOST RECENT drain. An older drain that is still in flight + // when a newer one starts is not stomped -- it keeps running against its own + // promise, which whoever started it already holds and will await -- but it + // stops being the thing later cleanup() calls coalesce onto. Two distinct + // instances tearing down concurrently is fine: each owns its own engine + // handle and native ref, exactly like two DataWeave instances calling + // .cleanup() independently. This keeps the invariant that matters: no + // cleanup() call ever returns as if it drained an instance it didn't. + cleaningInstance = instance; cleanupPromise = instance.cleanup(); try { await cleanupPromise; } finally { - cleanupPromise = null; - // Reset the exit-hook guard only after the drain has fully completed, so a - // revived singleton gets its own live hooks for the next real exit. Must - // stay last: resetting earlier could let a concurrent `exit` firing on this - // same shutdown re-enter cleanup while the async drain above is in flight. + // Only clear the shared coalescing state if it's still ours to clear -- + // i.e. nobody has started a newer drain (for a newer revived instance) + // that has since taken over `cleanupPromise`/`cleaningInstance`. Guards + // against this drain's finally clobbering a later drain's in-flight state. + if (cleaningInstance === instance) { + cleanupPromise = null; + cleaningInstance = null; + } + // Reset the exit-hook guard only after THIS drain has fully completed, so + // a revived singleton gets its own live hooks for the next real exit. + // Must stay last: resetting earlier could let a concurrent `exit` firing + // on this same shutdown re-enter cleanup while the async drain above is + // in flight. cleanupStarted = false; } } diff --git a/native-lib/node/tests/integration/instance-lifecycle.test.ts b/native-lib/node/tests/integration/instance-lifecycle.test.ts index 08b411d9..ee1a1675 100644 --- a/native-lib/node/tests/integration/instance-lifecycle.test.ts +++ b/native-lib/node/tests/integration/instance-lifecycle.test.ts @@ -171,3 +171,56 @@ describe("module-level cleanup() coalescing (round 12 Task 6)", () => { await cleanup(); }); }); + +// Final review round 12 #1: Task 6's coalescing guard (`if (cleanupPromise) +// return cleanupPromise;`) is unconditional, so a caller that revives the +// singleton (via run()) while an OLDER drain is still in flight gets the OLD +// drain's promise handed back by the newer cleanup() call -- the freshly +// revived instance is never hooked up to any doCleanup()/ffi.cleanup() call +// and its native ref leaks for the rest of the process. Pinned via the same +// ref-count proxy as the "napi_cleanup refactor" test above: after both +// cleanup() calls settle, the isolate's ref count must have actually returned +// to zero (not be left at 1 by a leaked, unrevived-then-abandoned instance). +describe("module-level cleanup() does not orphan a revived singleton (final review round 12 #1)", () => { + it("cleanup() started during an in-flight drain cleans the CURRENT (revived) singleton, not the stale one", async () => { + // (a) Create the singleton (instance A). + expect(run("%dw 2.0\noutput application/json\n---\n1 + 1").success).toBe(true); + + // (b) Start draining A WITHOUT awaiting. + const p1 = cleanup(); + + // (c) Revive a FRESH singleton (instance B) while A's drain is in flight. + expect(run("%dw 2.0\noutput application/json\n---\n2 + 2").success).toBe(true); + + // (d) Call cleanup() again. Under the bug this returns p1 verbatim, + // leaving B's native ref uncleaned once both promises settle. + const p2 = cleanup(); + await Promise.all([p1, p2]); + + // (e) Prove B was actually torn down via the isolate's ref count, the same + // technique as "napi_cleanup refactor preserves last-release teardown" + // above: do one extra balanced initialize()/cleanup() pair. If the ref + // count was already back to zero (both A and B cleaned), this nets back + // to zero and a subsequent raw engine call observes "not initialized". If + // B's ref instead leaked, the ref count is already >=1 going into this + // balanced pair, so it nets to >=1 afterward and the isolate stays alive + // -- the subsequent call would NOT report "not initialized". + ffi.initialize(findLibrary()); + const h = ffi.createEngine(); + const envelope = JSON.parse( + ffi.runScriptEngine(h, "%dw 2.0\noutput application/json\n---\n5 + 5", buildInputsJson({})) + ); + expect(envelope.success).toBe(true); + expect(JSON.parse(Buffer.from(envelope.result, "base64").toString("utf-8"))).toBe(10); + ffi.destroyEngine(h); + await ffi.cleanup(); // Balances the initialize() just above, ONLY. + + expect(() => + ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) + ).toThrow(/not initialized/i); + + // The singleton revives cleanly again afterward -- no wedged module state. + expect(run("%dw 2.0\noutput application/json\n---\n3 + 3").success).toBe(true); + await cleanup(); + }); +}); diff --git a/native-lib/node/tests/integration/worker-lifecycle.test.ts b/native-lib/node/tests/integration/worker-lifecycle.test.ts index 06688eb7..ad43adf8 100644 --- a/native-lib/node/tests/integration/worker-lifecycle.test.ts +++ b/native-lib/node/tests/integration/worker-lifecycle.test.ts @@ -139,10 +139,15 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { it("Worker.terminate() mid-life leaves the main thread able to initialize and run", async () => { const body = ` - const { workerData } = require('node:worker_threads'); + const { parentPort, workerData } = require('node:worker_threads'); const addon = require(workerData.addonPath); addon.initialize(workerData.libPath); addon.createEngineWithResolver((p) => null); + // Signal readiness only once the engine is actually live, so the parent + // terminates a worker that genuinely has a live engine rather than + // racing a fixed sleep against initialize()/createEngineWithResolver on + // a possibly-loaded box (final review round 12 #3). + parentPort.postMessage('ready'); // Spin so the parent can terminate() us mid-life (no message posted). setInterval(() => {}, 10); `; @@ -150,8 +155,23 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { eval: true, workerData: { addonPath: ADDON_PATH, libPath: LIB_PATH }, }); - // Give it time to initialize + create the engine, then terminate abruptly. - await new Promise((r) => setTimeout(r, 500)); + // A throw in the worker body (e.g. a bad addon path) must fail this test + // cleanly rather than crash the vitest process -- the ad hoc Worker here, + // unlike runWorker() above, previously had no error listener wired up + // (final review round 12 #2). + const workerError = new Promise((_, reject) => w.once("error", reject)); + // Avoid an unhandled-rejection warning if "error" fires (or would fire) + // after the race below has already settled via the "ready" path. + workerError.catch(() => {}); + // Wait for the worker to report the engine is live, racing against a + // generous timeout so a slow box doesn't false-fail this test, then + // terminate abruptly. + const ready = new Promise((resolve) => w.once("message", (m) => { if (m === "ready") resolve(); })); + await Promise.race([ + ready, + workerError, + new Promise((_, reject) => setTimeout(() => reject(new Error("worker did not signal ready in time")), 10000)), + ]); await w.terminate(); ffi.initialize(LIB_PATH); From 5d59364fa3eff01016d3f0da561fbcfc0a240d71 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 17:13:18 -0300 Subject: [PATCH 087/216] W-23692110: Add round-13 design spec (per-env init-reference ownership, review #5) Fixes follow-up review #5: g_ref_count is a bare global with no notion of which napi_env owns each reference, so a raw initialize()-once + createEngine()-N consumer's abandoned env fires N per-engine release hooks against a count of 1, tearing the isolate down under still-live engines (potentially in another env). Design tracks init-reference ownership per env (g_ref_count == sum of per-env init_refs), stops the per-engine hook from releasing the isolate reference, and gates cleanup() on the calling env's ownership. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...per-env-init-reference-ownership-design.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md diff --git a/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md b/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md new file mode 100644 index 00000000..c0d95619 --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md @@ -0,0 +1,184 @@ +# Per-Env Init-Reference Ownership — Round 13 (W-23692110) + +**Status:** Design approved (user), ready for planning. + +**Source review:** `docs/pr-157-follow-up-code-review-4.md`, Finding #5 (Medium), verified against live source at commit `765c273` (round-12 tip). Findings #1, #2, #3, #4, #6 in that review are test-quality/coverage items or already-shipped fixes and are **out of scope** for this round (they may be addressed in a separate test-hardening round); this round fixes only #5, the one production-correctness finding. + +**Scope:** `native-lib/node` only — `src/addon.c` and Node integration tests under `tests/`. Do **not** touch `native-lib/python/**`, 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 read for context but not modified. `src/dataweave.ts` is **not** modified — the product-facing `DataWeave` class already maintains the sanctioned 1:1 pairing, so no JS change is needed; the fix hardens the C boundary underneath it. + +## Problem + +### #5 (Medium) — Abandoned-env reference release relies on an unenforced raw-addon invariant + +`g_ref_count` (`addon.c:37`) is a single process-global reference counter with **no notion of which `napi_env` owns each reference**. Its accounting assumes a strict **1 `initialize()` ↔ 1 engine ↔ 1 `cleanup()`** pairing: + +- `napi_initialize` does `g_ref_count++` at three sites: the adoption path (`:531`), the already-initialized fast path (`:545`), and the create path (`:574`). +- `napi_cleanup` → `release_isolate_ref_locked` does the matching `g_ref_count--` (`:2358-2359`) and, on the last release, drives isolate teardown. +- An **abandoned engine's** env-cleanup hook also releases one reference: `bridge_env_cleanup` (`:339`, direct path) or `bridge_end_op` (`:404`, deferred path), gated by `engine_bridge_t.deferred_ref_release`, calling `isolate_ref_release_core_locked()`. + +The defect: the **per-engine** env-cleanup hook releases a reference that logically belongs to **`initialize()`**, not to the engine. The product `DataWeave` class calls `initialize()` exactly once per engine and releases them together via one `cleanup()`, so the counts happen to match. But the addon exports raw `initialize`, `createEngine`, and `createEngineWithResolver` (`addon.c:2494-2504`) with **nothing enforcing the pairing**. A raw consumer that does `initialize()` **once**, then `createEngine()` **N times**, registers **N** per-engine cleanup hooks against a reference count of **1**. When that env is abandoned: + +1. the first engine's hook (`bridge_env_cleanup` → `isolate_ref_release_core_locked`) drives `g_ref_count` `1 → 0`, +2. `isolate_ref_release_core_locked` (`:2297-2338`) sees zero and **tears the isolate down** (synchronously when `g_active_ops == 0`, or queues the waiter otherwise), +3. the remaining `N-1` engines — and, in a multi-env process, **another env's still-valid engines** — are now operating on a torn-down isolate. + +This is a use-after-free / premature-teardown hazard, documented but unenforced in the comment at `addon.c:2289-2296`. Finding #5 asks that the addon boundary either enforce the pairing, track init ownership separately from engine records, or make the raw surface inaccessible. The raw `.node` file cannot truly be made inaccessible (anything can `require()` it), and enforcing one-engine-per-init would reject valid multi-engine usage. **Decision (user): track initialization ownership separately from engine records** — the robust option that fixes the UAF while preserving the multi-engine feature. + +## Design + +Introduce **per-`napi_env` init-reference accounting** so `g_ref_count` becomes a derived total rather than a bare global that any engine hook can drive to zero. One invariant governs the whole design: + +> **`g_ref_count` == Σ `init_refs` over all live env records.** + +Every reference in the global count is owned by exactly one env's record; a reference can only be released by the same env that acquired it (via that env's `cleanup()`) or by that env's death hook (releasing all of that env's outstanding references at once). The per-engine cleanup hook stops touching `g_ref_count` entirely — which is the actual bug fix. The teardown decision still fires only on the true global last-release, and only from an env-scoped release path, so it can never tear the isolate down while another env holds a reference. + +### 1. New per-env record and registry + +```c +// One record per napi_env that has ever taken an init reference (via +// initialize()). init_refs is that env's net initialize()-minus-cleanup() +// balance. The record is created lazily on the env's first initialize(), +// registers exactly one env-death hook (env_init_cleanup) at creation, and is +// freed when its env dies (that hook) after releasing every reference the env +// still holds. All fields mutated only under g_mutex. +// +// INVARIANT: g_ref_count == sum of init_refs over all records in g_env_recs. +typedef struct env_init_rec { + napi_env env; + int init_refs; + struct env_init_rec* next; +} env_init_rec_t; +static env_init_rec_t* g_env_recs = NULL; // linked list, guarded by g_mutex +``` + +Helpers (all require the caller to hold `g_mutex`): + +- `env_init_rec_t* env_init_rec_find_locked(napi_env env)` — linear scan of `g_env_recs`, returns the record or NULL. Mirrors `bridge_find`. +- `env_init_rec_t* env_init_rec_acquire_locked(napi_env env, bool* is_new)` — find-or-create the record and `init_refs++`. Sets `*is_new = true` when it just allocated the record (the caller must then register the env-death hook, outside any napi-illegal context — see §3). Returns NULL only on `calloc` failure (caller treats as a hard error and does not bump `g_ref_count`). + +### 2. `napi_initialize` — acquire a per-env reference alongside `g_ref_count` + +Each of the three `g_ref_count++` sites gains a paired `init_refs` acquire on the calling env, under the same `g_mutex` hold that already guards the `g_ref_count++`: + +- **Adoption path (`:530-534`):** currently `g_teardown_cancelled = true; g_ref_count++; broadcast; unlock; return`. Add `env_init_rec_acquire_locked(env, &is_new)` before the `g_ref_count++`. On `calloc` failure: do **not** cancel the teardown, do **not** bump `g_ref_count`; unlock and `napi_throw_error(env, NULL, "Failed to allocate env init record")`, return NULL. (The teardown stays queued; the caller's initialize failed cleanly.) +- **Fast path (`:544-548`):** `if (g_initialized) { g_ref_count++; ... }` — add the acquire before the bump, same failure handling (unlock + throw, no bump). +- **Create path (`:573-575`):** after a successful isolate build, before `g_ref_count++`, do the acquire. On `calloc` failure here the isolate was just built with `g_ref_count` still 0 and `g_initialized` about to be set — restore consistency by tearing back down is overkill; instead treat the record as required: set `g_initialized = 1` is **not** reached — unlock and throw before setting anything, having left `g_isolate`/`g_initialized` in the same "freshly built, ref 0" state the existing spawn-failure/`init` error paths already leave recoverable. **Simpler, chosen rule:** perform the `env_init_rec_acquire_locked` **first** (it only allocates a small node); only if it succeeds proceed to `g_initialized = 1; g_ref_count++`. This keeps the create path all-or-nothing without unwinding the isolate. + +**Hook registration for a new record.** When `env_init_rec_acquire_locked` reports `is_new`, register exactly one env-death hook for the init record: +`napi_add_env_cleanup_hook(env, env_init_cleanup, rec)`. This is legal in all three paths (they run on the env's own JS thread with the env alive). If the hook registration **fails**, the record cannot guarantee its references are reclaimed on env death — roll back: decrement the just-acquired `init_refs` (freeing the record if it drops to 0), do not bump `g_ref_count`, unlock, throw. This mirrors round-12 #6's all-or-nothing posture for the per-engine hook. + +Ordering note (LIFO): because the init-record hook is registered on the **first** `initialize()` for an env — before any engine is created — Node's env-cleanup hooks run **LIFO**, so `env_init_cleanup` runs **after** every per-engine `bridge_env_cleanup` for that env. Every engine bridge is thus finalized (Java registry entry removed, napi_ref deleted) while the isolate is **still alive**, and only then does the init record release the isolate reference(s). This preserves the exact ordering the round-10/11/12 fixes rely on. + +### 3. `env_init_cleanup` — release all of a dead env's references, once + +New env-death hook, registered per §2. Runs on the dying env's own thread with the env still alive (standard env-cleanup-hook contract): + +```c +static void env_init_cleanup(void* arg) { + env_init_rec_t* rec = (env_init_rec_t*)arg; + if (rec == NULL) return; + uv_mutex_lock(&g_mutex); + // Unlink from g_env_recs. + env_init_rec_t** pp = &g_env_recs; + while (*pp != NULL) { if (*pp == rec) { *pp = rec->next; break; } pp = &(*pp)->next; } + int n = rec->init_refs; + rec->init_refs = 0; + free(rec); + // Release exactly the references this env still held. release_n... makes the + // teardown decision at most ONCE, after decrementing all n, so it never + // spawns a second waiter or tears down an already-torn isolate mid-loop. + isolate_ref_release_n_locked(n); + uv_mutex_unlock(&g_mutex); +} +``` + +The env that reaches `env_init_cleanup` without having called `cleanup()` for each of its references (the abandoned-Worker case, and the raw multi-engine-per-init case) releases them here — **all at once, from a single env-scoped decision point.** Because `g_ref_count == Σ init_refs`, releasing this env's `n` reaches 0 **only** if no other env holds a reference, so an abandoned env-A can never tear down the isolate under a live env-B. + +### 4. Bounded multi-release helper `isolate_ref_release_n_locked` + +`isolate_ref_release_core_locked` (`:2297-2338`) currently decrements **one** reference and then makes the teardown decision. A naive loop calling it `n` times would, after the reference that reaches 0 tears down and sets `g_ref_count = 0`, make the remaining iterations no-op on an already-zero count — correct by luck, but it also re-runs the `g_teardown_state != TEARDOWN_NONE` early-return and would mis-handle the `g_active_ops > 0` waiter case if a second "last release" were computed. Make it explicit and single-decision: + +```c +// Release n (>=0) initialization references at once, then make the teardown +// decision AT MOST ONCE. Caller holds g_mutex; this KEEPS it held. Equivalent +// to n serial core releases for the count, but guarantees the reached-zero +// teardown/waiter logic runs exactly once. n==0 is a no-op. +static void isolate_ref_release_n_locked(int n) { + if (n <= 0) return; + if (g_ref_count >= n) g_ref_count -= n; else g_ref_count = 0; + if (g_ref_count > 0) return; // other envs still hold refs + if (g_teardown_state != TEARDOWN_NONE) return; // a teardown already drives + // ... the SAME reached-zero body as isolate_ref_release_core_locked: + // g_active_ops == 0 -> synchronous cleanup_thread_fn + clear globals; + // g_active_ops > 0 -> spawn waiter, TEARDOWN_PENDING_WAIT, empty list. +} +``` + +Refactor `isolate_ref_release_core_locked` to `isolate_ref_release_n_locked(1)` (behavior-preserving for the single-release callers). The single-decision reached-zero body is written once and shared. + +### 5. `napi_cleanup` — gate the release on the calling env's ownership + +`release_isolate_ref_locked(env)` (`:2353`) currently does an unconditional `if (g_ref_count > 0) g_ref_count--;`. Gate it on the calling env's own balance so an env can only release a reference it actually holds (user decision: gate `cleanup()` too, closing the symmetric over-`cleanup()` UAF): + +```c +static napi_value release_isolate_ref_locked(napi_env env) { + env_init_rec_t* rec = env_init_rec_find_locked(env); + if (rec == NULL || rec->init_refs == 0) { + // This env holds no init reference: a cleanup() with no matching + // initialize() on this env (or a double-cleanup()). Do NOT touch + // g_ref_count -- releasing here would steal another env's reference and + // could tear the isolate down under a live user. No-op: resolve immediately. + uv_mutex_unlock(&g_mutex); + return already_resolved_promise(env); + } + rec->init_refs--; + if (g_ref_count > 0) g_ref_count--; + // ... the rest of Cases 1..5 UNCHANGED (the decrement above replaces the old + // unconditional one; g_ref_count-driven teardown decision is identical). + ... +} +``` + +The record is **not** freed here even if `init_refs` hits 0 — its env is still alive and may `initialize()` again, and its env-death hook still needs to run (with `init_refs == 0`, `env_init_cleanup` releases nothing, which is correct). This matches the product pattern of `cleanup()` then possibly re-`initialize()` on the same env. + +### 6. Per-engine hook stops touching `g_ref_count` (the core fix) + +Remove the init-reference release from the per-engine path entirely: + +- Delete the `deferred_ref_release` field from `engine_bridge_t` (`:121`) and every write (`bridge_env_cleanup:328`) and read (`bridge_end_op:398,404`). +- `bridge_env_cleanup`'s direct path (`:339`) no longer calls `isolate_ref_release_core_locked()`. +- `bridge_end_op` (`:404`) no longer conditionally releases the ref. + +The per-engine hooks keep doing everything else — unlink the bridge, remove the Java registry entry (`do_registry_remove`), delete the resolver napi_ref, free the record. They simply no longer own an isolate reference, because they never did: the reference belongs to `initialize()`, now tracked by the env init record. + +`isolate_ref_release_core_locked` becomes reachable only via `isolate_ref_release_n_locked`; if no other caller remains, it is folded into the `n==1` path (kept as a thin wrapper only if a call site still reads better with it). + +## Invariants preserved / established + +1. **`g_ref_count == Σ init_refs`** — established; every `g_ref_count` mutation is paired with an `init_refs` mutation on a specific env (init: both +1; cleanup: both −1 for the calling env; env death: −n for the dying env). The three initialize sites, `release_isolate_ref_locked`, and `env_init_cleanup` are the *only* mutators of `g_ref_count` after this round. +2. **An env releases only what it owns** — both `cleanup()` (§5) and env-death (§3) are keyed on a specific env's record; neither can drive `g_ref_count` below the references still held by *other* envs. Closes the cross-env UAF (abandoned env) **and** the symmetric over-`cleanup()` UAF. +3. **Teardown fires only on true global last-release** — the reached-zero body runs only when `g_ref_count` hits 0 after an env-scoped decrement, exactly as before; the multi-release helper makes that decision **once** per env-death. +4. **`destroyEngine` never releases an init reference** — unchanged; it was never a `g_ref_count` mutator and still isn't. +5. **`fn_destroy_engine` called exactly once per handle** — unchanged; the per-engine finalize path is untouched except for dropping the ref release. +6. **Thread affinity** — `env_init_cleanup` runs on its env's own thread with the env alive (env-cleanup-hook contract), doing only `g_mutex`-guarded integer/list work and `free` — no env-affine napi calls, no cross-thread napi. The init-record hook is registered on the env's own thread. Consistent with the round-11/12 per-engine hook design. +7. **Deadlock/adoption state machine (`TEARDOWN_*`, `g_teardown_cancelled`, the waiter)** — untouched; the reached-zero body it hooks into is the same, now shared via `isolate_ref_release_n_locked`. +8. **Sanctioned 1:1 usage is behavior-identical** — one env, one `initialize()` (`init_refs 1`, hook registered), one engine, one `cleanup()` (`init_refs 0`, `g_ref_count 0`, teardown as today). All existing round-1..12 tests must stay green with no assertion changes. + +## Testing + +Real-addon integration tests under `native-lib/node/tests/integration/` (no `vi.mock` of `ffi`), mirroring `instance-lifecycle.test.ts`'s ref-count proxy technique (a subsequent raw engine call throwing `/not initialized/` proves the isolate reached zero refs and was torn down; a call that succeeds proves it is still alive). + +1. **Raw multi-engine-per-init does not prematurely tear down (the #5 core).** On one env (the main test thread): `ffi.initialize()` **once**, then `ffi.createEngine()` **twice** (handles h1, h2). Run a script on h2 to prove the isolate is live. `ffi.destroyEngine(h1)` — the isolate must remain alive: a run on h2 still succeeds. Then `ffi.destroyEngine(h2)` and one `ffi.cleanup()` (the single init reference). Now a fresh raw engine call must observe `/not initialized/`. *Pre-fix predicted behavior: acceptable here because destroyEngine (not the env hook) drives per-engine teardown and does not release the ref — so this test alone does not isolate #5; it guards that the multi-engine-per-init shape stays live under partial destroy.* **Primary #5 regression is test 2.** +2. **Over-`cleanup()` from an env cannot steal a reference / tear down under a live user.** `ffi.initialize()` once, `ffi.createEngine()` (h). Call `ffi.cleanup()` **twice**. The first releases this env's one reference (isolate torn down — this env owned exactly one). The second must be a **no-op** (`init_refs` already 0): it must not throw, and — critically — must not drive `g_ref_count` negative or perturb a *subsequently* re-initialized isolate. Prove: after the double-cleanup, `ffi.initialize()` again + `createEngine` + run succeeds (the second cleanup did not corrupt the count), then balance with one `cleanup()` and assert `/not initialized/`. +3. **Symmetric-ownership proof via the module API (regression guard for sanctioned path).** The existing `instance-lifecycle.test.ts` ref-count-proxy tests (napi_cleanup refactor; revived-singleton) must remain green unchanged — they already assert the 1:1 path tears down to zero. Add one assertion-level note only if needed; no new test required if these cover it. +4. **Worker abandonment still releases (round-12 #2 behavior preserved).** The existing `worker-lifecycle.test.ts` "N Workers exit without cleanup" test must remain green: each Worker does one `initialize()` + one engine, so its env-death `env_init_cleanup` releases exactly one reference — identical net behavior to the round-12 `deferred_ref_release` path it replaces. (If review round-4 Finding #1's stronger zero-reference assertion is added in a separate round, it must still pass here.) + +All tests balance shared isolate state (final `cleanup()` / `/not initialized/` probe) so they do not perturb sibling integration files sharing the vitest worker process. Full Node suite target: **895 passed / 59 skipped / 0 failed** plus the new tests (2 new integration tests → **897 passed / 59 skipped / 0 failed**), unless a new test file adds more. + +## Rejected alternatives + +- **Enforce one engine per `initialize()` at the addon boundary (review option 1).** Rejected: rejects valid raw multi-engine-per-init usage, and still requires per-init tracking to detect "this env already has a live engine under its current init reference" — no simpler than per-env accounting, strictly more restrictive. +- **Make the raw addon private/inaccessible (review option 3).** Rejected: `dwlib_addon.node` is a file on disk; any consumer can `require()` it. Narrowing the package's documented surface is a docs change that leaves the underlying C hazard intact — effectively won't-fix. +- **Reference-count per engine instead of per env.** Rejected: the reference semantically belongs to `initialize()` (isolate lifetime), not to an engine (Java registry entry lifetime). Coupling it to engines is exactly the mispairing that caused #5. +- **Keep `deferred_ref_release` and additionally cap releases at the env's engine count.** Rejected: still keyed on engines, still lets an abandoned env with more engines than its true init count over-release; per-env accounting is the correct key. +- **Store the init record via `napi_set_instance_data`.** Rejected: `napi_set_instance_data` is single-slot per env and may already be reserved by future addon needs; a `g_mutex`-guarded list mirrors the existing `g_bridges` pattern the codebase already reasons about, and is visible to the cross-env teardown decision that instance-data (env-local) is not. From 4d69a0433fcae94e3f3e9671b565c077832d114c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 20 Aug 2026 17:20:34 -0300 Subject: [PATCH 088/216] W-23692110: Add per-env init-reference record and helpers (round 13 #5 prep) Introduces g_env_recs (one env_init_rec_t per napi_env that took an init reference) plus env_init_rec_find_locked / env_init_rec_acquire_locked, both g_mutex-guarded. No behavior change yet -- wired into initialize()/cleanup()/ env death in the following tasks. Establishes the invariant to hold: g_ref_count == sum of per-env init_refs. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 7edbf77a..cded9ad5 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -123,6 +123,25 @@ typedef struct engine_bridge { } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex +// One record per napi_env that has ever taken an init reference (via +// initialize()). init_refs is that env's net initialize()-minus-cleanup() +// balance. Created lazily on the env's first initialize(); registers exactly +// one env-death hook (env_init_cleanup) at creation; freed by that hook when +// its env dies (after releasing every reference the env still holds). All +// fields mutated ONLY under g_mutex. +// +// INVARIANT: g_ref_count == sum of init_refs over all records in g_env_recs. +// This is the round-13 (#5) fix: the isolate's reference count is owned per +// env, so an abandoned env (or a raw multi-engine-per-initialize() consumer) +// can only release the references IT holds -- it can never drive g_ref_count +// to zero and tear the isolate down while ANOTHER env's engines are live. +typedef struct env_init_rec { + napi_env env; + int init_refs; + struct env_init_rec* next; +} env_init_rec_t; +static env_init_rec_t* g_env_recs = 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 @@ -210,6 +229,35 @@ static engine_bridge_t* bridge_find(long long handle) { return NULL; } +// Find this env's init record, or NULL. Caller MUST hold g_mutex. +static env_init_rec_t* env_init_rec_find_locked(napi_env env) { + for (env_init_rec_t* r = g_env_recs; r != NULL; r = r->next) { + if (r->env == env) return r; + } + return NULL; +} + +// Find-or-create this env's init record and increment its init_refs. Sets +// *is_new = true iff a record was just allocated (the caller must then register +// the env-death hook on its own thread). Returns the record, or NULL only on +// calloc failure (caller must NOT bump g_ref_count in that case). Caller MUST +// hold g_mutex. +static env_init_rec_t* env_init_rec_acquire_locked(napi_env env, bool* is_new) { + *is_new = false; + env_init_rec_t* r = env_init_rec_find_locked(env); + if (r == NULL) { + r = (env_init_rec_t*)calloc(1, sizeof(env_init_rec_t)); + if (r == NULL) return NULL; + r->env = env; + r->init_refs = 0; + r->next = g_env_recs; + g_env_recs = r; + *is_new = true; + } + r->init_refs++; + return r; +} + // 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 From 7660c176c8db852c8e28c8453e575affe5cb4463 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 09:46:28 -0300 Subject: [PATCH 089/216] W-23692110: Add bounded isolate_ref_release_n_locked; core release wraps n=1 (round 13 #5 prep) Extracts the reached-zero teardown decision into isolate_ref_release_n_locked(n) so a multi-reference release (an env's whole balance) makes the teardown/waiter decision exactly once instead of re-entering it per reference. isolate_ref_release_core_locked becomes a thin wrapper over n=1 -- behavior-preserving; suite unchanged at 895/59/0. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 81 +++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index cded9ad5..1df6362e 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2315,39 +2315,18 @@ static napi_value already_resolved_promise(napi_env env) { return promise; } -// Promise-less core of an isolate-reference release. Caller holds g_mutex and -// this function KEEPS it held (does not unlock). Decrements g_ref_count and, on -// the last release, drives teardown WITHOUT binding any napi promise/waiter: -// - g_active_ops == 0 -> synchronous cleanup_thread_fn (same as Case 4). -// - g_active_ops > 0 -> spawn the waiter thread with an EMPTY waiter list -// (TEARDOWN_PENDING_WAIT); it tears down (or is adopted) -// with no promises to resolve. -// - a teardown already pending (TEARDOWN_NONE != state) -> nothing to do; the -// existing waiter will tear down; this release just -// drops the count. -// Used by the abandoned-env path (bridge_env_cleanup / bridge_end_op, round-12 -// #2), which has no live JS caller to hand a promise to. -// -// Deliberately does NOT call (or get called by) release_isolate_ref_locked -// below: that promise-bearing sibling needs per-caller promise plumbing this -// core omits on purpose (binding a waiter/promise to a tearing-down env is a -// thread-affinity hazard). They share the last-release *policy* only; see -// release_isolate_ref_locked's header comment for the promise-bearing twin. -// -// Assumes the sanctioned 1:1 pairing of one initialize() reference to one -// engine bridge, exactly as the product-facing DataWeave class enforces (one -// initialize() call per engine, released together by one cleanup()). Nothing -// in this file enforces that pairing for a raw-ffi caller: creating multiple -// engines under a single initialize() registers one env-cleanup hook per -// engine, and each abandoned engine's hook would call this function -- so an -// out-of-contract multi-engine-per-initialize() caller could over-release -// g_ref_count under a cross-env teardown race. -static void isolate_ref_release_core_locked(void) { - if (g_ref_count > 0) { - g_ref_count--; - } - if (g_ref_count > 0) return; // not the last reference - if (g_teardown_state != TEARDOWN_NONE) return; // a teardown is already driving +// Release n (>=0) initialization references at once, then make the teardown +// decision AT MOST ONCE. Caller holds g_mutex and this KEEPS it held. n==0 is a +// no-op. Equivalent to n serial single-releases for the COUNT, but guarantees +// the reached-zero teardown/waiter logic runs exactly once (a serial loop would +// re-enter the decision on an already-zero count). Used by env_init_cleanup +// (round-13 #5) to release all of a dead env's references from one decision +// point, and by the single-release callers via isolate_ref_release_core_locked. +static void isolate_ref_release_n_locked(int n) { + if (n <= 0) return; + if (g_ref_count >= n) g_ref_count -= n; else g_ref_count = 0; + if (g_ref_count > 0) return; // other envs still hold references + if (g_teardown_state != TEARDOWN_NONE) return; // a teardown already drives if (g_active_ops == 0) { uv_thread_t tid; @@ -2378,13 +2357,45 @@ static void isolate_ref_release_core_locked(void) { waiter_opts.stack_size = 2 * 1024 * 1024; int spawn_rc = uv_thread_create_ex(&waiter_tid, &waiter_opts, teardown_waiter_thread_fn, NULL); if (spawn_rc != 0) { - // Waiter never started: roll back so state is not wedged and the isolate - // stays live (mirrors napi_cleanup Case-5 spawn-failure degradation). g_teardown_state = TEARDOWN_NONE; g_ref_count = 1; } } +// Promise-less core of an isolate-reference release. Caller holds g_mutex and +// this function KEEPS it held (does not unlock). Decrements g_ref_count and, on +// the last release, drives teardown WITHOUT binding any napi promise/waiter: +// - g_active_ops == 0 -> synchronous cleanup_thread_fn (same as Case 4). +// - g_active_ops > 0 -> spawn the waiter thread with an EMPTY waiter list +// (TEARDOWN_PENDING_WAIT); it tears down (or is adopted) +// with no promises to resolve. +// - a teardown already pending (TEARDOWN_NONE != state) -> nothing to do; the +// existing waiter will tear down; this release just +// drops the count. +// Used by the abandoned-env path (bridge_env_cleanup / bridge_end_op, round-12 +// #2), which has no live JS caller to hand a promise to. +// +// Deliberately does NOT call (or get called by) release_isolate_ref_locked +// below: that promise-bearing sibling needs per-caller promise plumbing this +// core omits on purpose (binding a waiter/promise to a tearing-down env is a +// thread-affinity hazard). They share the last-release *policy* only; see +// release_isolate_ref_locked's header comment for the promise-bearing twin. +// +// Assumes the sanctioned 1:1 pairing of one initialize() reference to one +// engine bridge, exactly as the product-facing DataWeave class enforces (one +// initialize() call per engine, released together by one cleanup()). Nothing +// in this file enforces that pairing for a raw-ffi caller: creating multiple +// engines under a single initialize() registers one env-cleanup hook per +// engine, and each abandoned engine's hook would call this function -- so an +// out-of-contract multi-engine-per-initialize() caller could over-release +// g_ref_count under a cross-env teardown race. +// +// Retained as a thin wrapper over isolate_ref_release_n_locked(1) for any +// remaining single-release caller. +static void isolate_ref_release_core_locked(void) { + isolate_ref_release_n_locked(1); +} + // Releases ONE initialization reference on the shared isolate. Caller MUST // hold g_mutex; this function UNLOCKS g_mutex before returning (the sync and // waiter teardown paths both require dropping the lock). Returns the napi From 0963c348810ec36a2f006f1ef9ac30b287f91644 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 09:53:10 -0300 Subject: [PATCH 090/216] W-23692110: Acquire a per-env init reference in initialize() at all three sites (round 13 #5) initialize()'s adoption, fast, and create paths now find-or-create the calling env's init record and increment its init_refs alongside g_ref_count++, and register one env-death hook per env on first use (all-or-nothing: a calloc or hook-registration failure rolls back and throws without bumping g_ref_count). env_init_cleanup body follows in the next task. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 1df6362e..ca74b59f 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -541,6 +541,40 @@ static void init_thread_fn(void* arg) { args->result = 0; } +// Forward declaration: the env-death hook that reclaims an abandoned env's +// init references. Defined below (round-13 #5); registered here (in +// env_init_acquire_and_hook) because napi_add_env_cleanup_hook is only legal +// while the env is alive on its own JS thread, which napi_initialize is. +static void env_init_cleanup(void* arg); // defined below (round-13 #5) + +// Acquire one init reference for `env` under g_mutex, registering the env-death +// hook on first use. Returns true on success (caller then does g_ref_count++); +// on failure the caller must NOT bump g_ref_count -- it unlocks and throws. +// Caller MUST hold g_mutex; this function keeps it held on success and on the +// calloc-failure return. On hook-registration failure it rolls back the +// just-acquired init_refs (freeing the record if it drops to 0) so no orphan +// record without a death hook survives. +static bool env_init_acquire_and_hook(napi_env env) { + bool is_new = false; + env_init_rec_t* rec = env_init_rec_acquire_locked(env, &is_new); + if (rec == NULL) return false; // calloc failed + if (is_new) { + napi_status hs = napi_add_env_cleanup_hook(env, env_init_cleanup, rec); + if (hs != napi_ok) { + // Roll back: this record has no death hook, so its references would + // never be reclaimed. Drop the one we just took; free if now empty. + rec->init_refs--; + if (rec->init_refs == 0) { + env_init_rec_t** pp = &g_env_recs; + while (*pp != NULL) { if (*pp == rec) { *pp = rec->next; break; } pp = &(*pp)->next; } + free(rec); + } + return false; + } + } + return true; +} + static napi_value napi_initialize(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value argv[1]; @@ -575,6 +609,11 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { // 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. + if (!env_init_acquire_and_hook(env)) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Failed to allocate/register env init record"); + return NULL; + } g_teardown_cancelled = true; g_ref_count++; uv_cond_broadcast(&g_teardown_cond); @@ -590,6 +629,11 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { } if (g_initialized) { + if (!env_init_acquire_and_hook(env)) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Failed to allocate/register env init record"); + return NULL; + } g_ref_count++; uv_mutex_unlock(&g_mutex); return NULL; @@ -618,12 +662,26 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { return NULL; } + if (!env_init_acquire_and_hook(env)) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Failed to allocate/register env init record"); + return NULL; + } g_initialized = 1; g_ref_count++; uv_mutex_unlock(&g_mutex); return NULL; } +// TEMPORARY STUB (Task 3, round-13 #5): env_init_cleanup's real body -- the +// env-death hook that releases every reference an abandoned env still holds +// and frees its env_init_rec_t -- lands in the next task (Task 4). This +// no-op placeholder exists ONLY so the addon links for this task; it is +// replaced wholesale by Task 4 and must not be left in place afterward. +static void env_init_cleanup(void* arg) { + (void)arg; +} + // --- Helper: run any GraalVM call on a dedicated thread --- struct script_call_args { From 4f19b22c3f0948304b9c64db1aceec2fe5a1812d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 10:05:12 -0300 Subject: [PATCH 091/216] W-23692110: Add env_init_cleanup env-death hook releasing a dead env's references (round 13 #5) A dead env's outstanding init references are released here, all at once, from a single env-scoped decision point via isolate_ref_release_n_locked. LIFO hook ordering guarantees this runs after every per-engine bridge_env_cleanup, so engine bridges finalize while the isolate is still alive. Paired with the next task, which removes the now-duplicate per-engine ref release. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 57 ++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index ca74b59f..44440c6f 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -258,6 +258,15 @@ static env_init_rec_t* env_init_rec_acquire_locked(napi_env env, bool* is_new) { return r; } +// Sum of live per-env init references. Caller holds g_mutex. Establishes the +// value g_ref_count must equal (invariant g_ref_count == sum of init_refs); used +// to restore g_ref_count coherently when a deferred teardown cannot be spawned. +static int env_init_refs_total_locked(void) { + int total = 0; + for (env_init_rec_t* r = g_env_recs; r != NULL; r = r->next) total += r->init_refs; + return total; +} + // 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 @@ -673,15 +682,6 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { return NULL; } -// TEMPORARY STUB (Task 3, round-13 #5): env_init_cleanup's real body -- the -// env-death hook that releases every reference an abandoned env still holds -// and frees its env_init_rec_t -- lands in the next task (Task 4). This -// no-op placeholder exists ONLY so the addon links for this task; it is -// replaced wholesale by Task 4 and must not be left in place afterward. -static void env_init_cleanup(void* arg) { - (void)arg; -} - // --- Helper: run any GraalVM call on a dedicated thread --- struct script_call_args { @@ -2415,11 +2415,48 @@ static void isolate_ref_release_n_locked(int n) { waiter_opts.stack_size = 2 * 1024 * 1024; int spawn_rc = uv_thread_create_ex(&waiter_tid, &waiter_opts, teardown_waiter_thread_fn, NULL); if (spawn_rc != 0) { + // Deferred teardown could not be spawned: the isolate stays live but no + // waiter will drain it (best-effort degradation, matching the original + // intent). Restore g_ref_count to the true remaining ownership so the + // invariant g_ref_count == sum of init_refs holds -- NOT a hardcoded 1, + // which would resurrect a reference no env owns (wrong for the n>1 + // env-death caller, which has already freed its record before calling). g_teardown_state = TEARDOWN_NONE; - g_ref_count = 1; + g_ref_count = env_init_refs_total_locked(); } } +// Env-death hook for a per-env init record (round-13 #5). Registered once per +// env by initialize()'s first acquire (env_init_acquire_and_hook). Node runs +// env-cleanup hooks LIFO, and this hook is registered BEFORE any engine's +// bridge_env_cleanup for the same env, so it runs AFTER every engine bridge has +// finalized -- each engine's Java registry entry is removed and napi_ref +// deleted while the isolate is still alive, and only then does this hook +// release the isolate reference(s). Releases exactly the references this env +// still holds (n), from a single env-scoped decision point: because +// g_ref_count == sum of init_refs, releasing this env's n reaches zero ONLY if +// no other env holds a reference, so an abandoned env can never tear the +// isolate down under a live env. Runs on the dying env's own thread with the +// env alive; does only g_mutex-guarded integer/list work + free (no env-affine +// napi calls). +static void env_init_cleanup(void* arg) { + env_init_rec_t* rec = (env_init_rec_t*)arg; + if (rec == NULL) return; + uv_mutex_lock(&g_mutex); + // Unlink from g_env_recs if still present. + env_init_rec_t** pp = &g_env_recs; + while (*pp != NULL) { + if (*pp == rec) { *pp = rec->next; break; } + pp = &(*pp)->next; + } + int n = rec->init_refs; + rec->init_refs = 0; + free(rec); + // Release all n references and make the teardown decision at most once. + isolate_ref_release_n_locked(n); + uv_mutex_unlock(&g_mutex); +} + // Promise-less core of an isolate-reference release. Caller holds g_mutex and // this function KEEPS it held (does not unlock). Decrements g_ref_count and, on // the last release, drives teardown WITHOUT binding any napi promise/waiter: From e67a3d6a05ed4912ee7d5c2e50f20706924f498d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 10:12:45 -0300 Subject: [PATCH 092/216] W-23692110: Stop the per-engine cleanup hook from releasing the init reference (round 13 #5) The isolate reference belongs to initialize() (isolate lifetime), not to an engine (Java-registry-entry lifetime). Releasing it per engine let a raw initialize()-once + createEngine()-N consumer's abandoned env fire N releases against a count of 1, tearing the isolate down under still-live engines. The reference is now owned per env (Tasks 1-4) and released only by that env's cleanup() or its env-death hook. Removes deferred_ref_release and the per-engine releases in bridge_env_cleanup/bridge_end_op. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 116 +++++++++++++----------------------- 1 file changed, 43 insertions(+), 73 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 44440c6f..e5ea0210 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -110,15 +110,6 @@ typedef struct engine_bridge { // otherwise a resolver-backed engine's ScriptRuntime is left registered with // a CallbackWeaveResourceResolver whose ctx points at the freed bridge (UAF). bool deferred_registry_remove; - // True when bridge_env_cleanup deferred an ABANDONED-env finalize because - // in_flight > 0. Unlike deferred_registry_remove (which the destroyEngine - // path also sets), this is set ONLY by the env-cleanup hook, and tells the - // draining op (bridge_end_op) to ALSO release this engine's initialize() - // reference (round-12 #2) -- exactly one release per abandoned engine. The - // destroyEngine deferral never sets it (that path is paired with an explicit - // ffi.cleanup() in JS, which releases the ref itself; setting it would - // double-release). - bool deferred_ref_release; struct engine_bridge* next; } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex @@ -189,12 +180,6 @@ typedef struct teardown_waiter { } teardown_waiter_t; static teardown_waiter_t* g_teardown_waiters = NULL; // linked list, guarded by g_mutex -// Forward declaration: defined near release_isolate_ref_locked (after -// cleanup_thread_fn/teardown_waiter_thread_fn, which it spawns), but needed by -// bridge_env_cleanup/bridge_end_op above that point. See the definition for -// full documentation. -static void isolate_ref_release_core_locked(void); - // 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 @@ -380,20 +365,17 @@ static void bridge_env_cleanup(void* arg) { // ScriptRuntime is left registered with a resolver ctx pointing at the // freed bridge. Set the deferred-registry-removal flag here. b->deferred_registry_remove = true; - // round-12 (#2): the draining op must ALSO release this abandoned - // engine's initialize() reference (see engine_bridge_t.deferred_ref_release). - b->deferred_ref_release = true; uv_mutex_unlock(&g_mutex); return; } - // in_flight == 0: finalize now. Release the abandoned engine's init - // reference under the lock first (round-12 #2), THEN unlock and finalize. - // The order matters: isolate_ref_release_core_locked may tear the isolate - // down (or start the waiter), and bridge_finalize_registry inside finalize - // checks teardown state under g_mutex, so a torn-down/TEARING_DOWN isolate - // makes the registry removal a correct no-op (the Java registry died with - // the isolate). - isolate_ref_release_core_locked(); + // in_flight == 0: finalize now. The abandoned engine's init reference is + // NOT released here (round-13 #5) -- it is released by the env-death hook + // (env_init_cleanup) when this env dies, which owns the whole per-env + // balance. There is nothing left to do under the lock before unlocking in + // this branch. bridge_finalize_registry inside finalize checks teardown + // state under g_mutex, so a torn-down/TEARING_DOWN isolate makes the + // registry removal a correct no-op (the Java registry died with the + // isolate). uv_mutex_unlock(&g_mutex); // We are inside Node's invocation of this hook, so we must not (and need not) @@ -452,13 +434,6 @@ static void bridge_end_op(engine_bridge_t* b, bool env_still_alive) { b->in_flight--; bool finalize = (b->destroy_pending && b->in_flight == 0); bool remove_registry = finalize && b->deferred_registry_remove; - bool release_ref = finalize && b->deferred_ref_release; - // round-12 (#2): if the env-cleanup hook deferred this abandoned engine's - // finalize, release its initialize() reference here, under the same lock, as - // the last op drains. Do it BEFORE unlocking so the teardown decision is made - // atomically with the in_flight==0 observation. destroyEngine's deferral does - // NOT set deferred_ref_release (its JS caller releases via ffi.cleanup()). - if (release_ref) isolate_ref_release_core_locked(); uv_mutex_unlock(&g_mutex); // remove_registry is true when either destroyEngine (round-9 #1) or the env // cleanup hook (round-10 #1) deferred the registry removal while this op was @@ -1836,9 +1811,10 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // an env cleanup hook (mirroring napi_create_engine_with_resolver), because // without one a Worker that creates a resolver-less engine and exits without // destroyEngine() would strand this record, the Java registry entry, and the - // native-lib reference. Round-12 (#2) closed the last of those: the hook now - // reclaims all three -- the record and registry entry via bridge_finalize, - // and the native-lib initialize() reference via isolate_ref_release_core_locked. + // native-lib reference. Round-12 (#2) closed the record/registry gap via + // bridge_finalize; round-13 (#5) moved ownership of the native-lib + // initialize() reference to the env itself (env_init_rec), released by the + // env-death hook env_init_cleanup, not per-engine. // owner is recorded for symmetry but is NOT used to restrict destruction based // on resolver state (see the owner guard in napi_destroy_engine, which now // fires for any record). @@ -1864,9 +1840,11 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // engines and blocking isolate teardown across Worker churn. bridge_env_cleanup // + bridge_finalize already handle a resolver-less record (resolver_js == NULL): // skip the napi_ref delete, still unlink, remove the registry entry (round-10 - // do_registry_remove=true), and free. Round-12 (#2) closed the reference leak: - // the hook now also releases the native-lib initialize() reference (directly, - // or via bridge_end_op if an op is draining), so all three are reclaimed. + // do_registry_remove=true), and free. Round-13 (#5) moved ownership of the + // native-lib initialize() reference to the env itself (env_init_rec): this + // per-engine hook no longer touches g_ref_count -- the reference is released + // by the env-death hook env_init_cleanup (or by cleanup()), so an abandoned + // env releases exactly one reference regardless of how many engines it made. // destroyEngine removes this hook before an early free so Node never invokes // it on freed memory. napi_status hook_st = napi_add_env_cleanup_hook(env, bridge_env_cleanup, rec); @@ -2379,7 +2357,9 @@ static napi_value already_resolved_promise(napi_env env) { // the reached-zero teardown/waiter logic runs exactly once (a serial loop would // re-enter the decision on an already-zero count). Used by env_init_cleanup // (round-13 #5) to release all of a dead env's references from one decision -// point, and by the single-release callers via isolate_ref_release_core_locked. +// point. (Previously also used by a single-release wrapper, +// isolate_ref_release_core_locked, retired in round-13 #5 once the per-engine +// finalize path stopped releasing init references directly.) static void isolate_ref_release_n_locked(int n) { if (n <= 0) return; if (g_ref_count >= n) g_ref_count -= n; else g_ref_count = 0; @@ -2428,14 +2408,18 @@ static void isolate_ref_release_n_locked(int n) { // Env-death hook for a per-env init record (round-13 #5). Registered once per // env by initialize()'s first acquire (env_init_acquire_and_hook). Node runs -// env-cleanup hooks LIFO, and this hook is registered BEFORE any engine's -// bridge_env_cleanup for the same env, so it runs AFTER every engine bridge has -// finalized -- each engine's Java registry entry is removed and napi_ref -// deleted while the isolate is still alive, and only then does this hook -// release the isolate reference(s). Releases exactly the references this env -// still holds (n), from a single env-scoped decision point: because -// g_ref_count == sum of init_refs, releasing this env's n reaches zero ONLY if -// no other env holds a reference, so an abandoned env can never tear the +// env-cleanup hooks LIFO. In the normal initialize()-then-createEngine() order +// this hook is registered BEFORE any engine's bridge_env_cleanup for the same +// env, so it runs AFTER every engine bridge has finalized on a live isolate. +// The pathological raw-ffi order (createEngine() on this env -- succeeding +// because another env already initialized -- THEN initialize() here) can +// register this hook after an engine hook, so it may run first; that is still +// safe, because bridge_finalize_registry re-checks teardown state under g_mutex +// (registry removal no-ops on a torn-down isolate) and the napi_ref delete runs +// with env_still_alive=true on this env's own live thread. Releases exactly the +// references this env still holds (n), from a single env-scoped decision point: +// because g_ref_count == sum of init_refs, releasing this env's n reaches zero +// ONLY if no other env holds a reference, so an abandoned env can never tear the // isolate down under a live env. Runs on the dying env's own thread with the // env alive; does only g_mutex-guarded integer/list work + free (no env-affine // napi calls). @@ -2467,8 +2451,8 @@ static void env_init_cleanup(void* arg) { // - a teardown already pending (TEARDOWN_NONE != state) -> nothing to do; the // existing waiter will tear down; this release just // drops the count. -// Used by the abandoned-env path (bridge_env_cleanup / bridge_end_op, round-12 -// #2), which has no live JS caller to hand a promise to. +// Used by env_init_cleanup (round-13 #5), the env-death hook, which has no +// live JS caller to hand a promise to. // // Deliberately does NOT call (or get called by) release_isolate_ref_locked // below: that promise-bearing sibling needs per-caller promise plumbing this @@ -2476,34 +2460,20 @@ static void env_init_cleanup(void* arg) { // thread-affinity hazard). They share the last-release *policy* only; see // release_isolate_ref_locked's header comment for the promise-bearing twin. // -// Assumes the sanctioned 1:1 pairing of one initialize() reference to one -// engine bridge, exactly as the product-facing DataWeave class enforces (one -// initialize() call per engine, released together by one cleanup()). Nothing -// in this file enforces that pairing for a raw-ffi caller: creating multiple -// engines under a single initialize() registers one env-cleanup hook per -// engine, and each abandoned engine's hook would call this function -- so an -// out-of-contract multi-engine-per-initialize() caller could over-release -// g_ref_count under a cross-env teardown race. -// -// Retained as a thin wrapper over isolate_ref_release_n_locked(1) for any -// remaining single-release caller. -static void isolate_ref_release_core_locked(void) { - isolate_ref_release_n_locked(1); -} +// The isolate reference is now owned per env (env_init_rec), not per engine +// bridge (round-13 #5): initialize()'s acquire sites and env_init_cleanup are +// the only callers that mutate g_ref_count via this function, alongside +// release_isolate_ref_locked below for the explicit cleanup() path. The +// per-engine finalize path (bridge_env_cleanup / bridge_end_op) no longer +// touches g_ref_count at all, so a raw multi-engine-per-initialize() caller's +// abandoned env fires exactly one release for the whole balance it holds, +// regardless of how many engines it created. // Releases ONE initialization reference on the shared isolate. Caller MUST // hold g_mutex; this function UNLOCKS g_mutex before returning (the sync and // waiter teardown paths both require dropping the lock). Returns the napi // promise to hand back to the JS caller. This is napi_cleanup's original -// Case 1..5 body, extracted verbatim so the abandoned-env path (round-12 #2) -// can share the exact same "reached zero -> tear down now vs. defer to the -// waiter" decision without duplicating it. -// -// Deliberately does NOT call (or get called by) isolate_ref_release_core_locked -// above: this promise-bearing version needs to bind a napi_deferred/waiter to -// `env` for Cases 3/5, which the promise-less core intentionally cannot do -// (there is no live JS caller in the abandoned-env path). They share the -// last-release policy only, not the promise mechanics. +// Case 1..5 body. static napi_value release_isolate_ref_locked(napi_env env) { // 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 From a07358cc9b08102358224ccffc84a54f0e25c096 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 10:34:03 -0300 Subject: [PATCH 093/216] W-23692110: Gate cleanup() on the calling env's init-reference ownership (round 13 #5) release_isolate_ref_locked now decrements g_ref_count only when the calling env's init record shows an outstanding reference; a cleanup() with no matching initialize() on this env (or a double-cleanup()) is an explicit no-op instead of an unconditional decrement floored at zero. Closes the symmetric UAF where a raw over-cleanup() from one env could tear the isolate down under another. Sanctioned 1:1 usage is unchanged. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index e5ea0210..86467620 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2479,6 +2479,18 @@ static napi_value release_isolate_ref_locked(napi_env env) { // 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. + // Round-13 (#5): an env may release only a reference IT owns. If this env has + // no outstanding init reference (a cleanup() with no matching initialize() on + // this env, or a double-cleanup()), do NOT touch g_ref_count -- releasing here + // would steal another env's reference and could tear the isolate down under a + // live user. No-op: resolve immediately. (g_ref_count == sum of init_refs, so + // this env's zero balance means it contributes nothing to release.) + env_init_rec_t* self = env_init_rec_find_locked(env); + if (self == NULL || self->init_refs == 0) { + uv_mutex_unlock(&g_mutex); + return already_resolved_promise(env); + } + self->init_refs--; if (g_ref_count > 0) { g_ref_count--; } @@ -2584,10 +2596,15 @@ static napi_value release_isolate_ref_locked(napi_env env) { 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; + // Best-effort degradation: the isolate stays live (g_initialized/g_isolate + // untouched) but no waiter will drain it. Restore g_ref_count to the true + // remaining ownership (Σ init_refs) rather than a hardcoded 1: this env just + // decremented its own init_refs above, and reaching Case 5 means g_ref_count + // hit 0, so the sum is 0 (or whatever surviving envs still own). Hardcoding 1 + // here would strand a reference no env owns -- unreleasable by any cleanup() + // or env-death hook -- and would break the invariant g_ref_count == Σ + // init_refs. A later initialize() will re-acquire on the surviving isolate. + g_ref_count = env_init_refs_total_locked(); uv_mutex_unlock(&g_mutex); return promise; From aeb28371dbf3311c50ffed6ecfeb97cfcca7e2fd Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 10:40:38 -0300 Subject: [PATCH 094/216] W-23692110: Add per-env init-reference ownership integration tests (round 13 #5) Raw-ffi tests via the ref-count proxy: (1) one initialize() with multiple engines stays live when a single engine is destroyed -- the isolate reference belongs to initialize(), not to an engine; (2) a second cleanup() on an env that owns no reference is a no-op that does not corrupt the count (proven by a subsequent balanced init/run/cleanup cycle still tearing down to zero). Co-Authored-By: Claude Sonnet 5 --- .../integration/env-init-ownership.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 native-lib/node/tests/integration/env-init-ownership.test.ts diff --git a/native-lib/node/tests/integration/env-init-ownership.test.ts b/native-lib/node/tests/integration/env-init-ownership.test.ts new file mode 100644 index 00000000..0ddc630f --- /dev/null +++ b/native-lib/node/tests/integration/env-init-ownership.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import * as ffi from "../../src/ffi"; +import { findLibrary, buildInputsJson } from "../../src/utils"; + +// W-23692110 round 13 #5: the init reference is owned per napi_env, not per +// engine. These raw-ffi tests (no vi.mock) drive the addon boundary directly -- +// the exact surface the finding is about -- and use the ref-count proxy from +// instance-lifecycle.test.ts: after balancing to zero refs a raw engine call +// throws /not initialized/; while the isolate is live a run succeeds. +// +// IMPORTANT -- these are single-env SMOKE tests, NOT true #5 regression teeth. +// #5 is a CROSS-ENV bug: an abandoned/dying env with N engines under one +// initialize() firing N per-engine releases against the one reference it owns, +// or one env's cleanup()/env-death releasing a reference another env owns. Both +// require either a real dying env or two distinct napi_envs with asymmetric +// init/cleanup. Vitest runs these on the single main-thread env, so they cannot +// distinguish the fixed isolate from the pre-fix (buggy) one -- it was verified +// empirically that both cases below pass unchanged when rebuilt against the +// pre-round-13 addon (destroyEngine() never released the init ref in any +// revision, and the second cleanup() was already a no-op via the long-standing +// `if (g_ref_count > 0)` floor). They guard that the sanctioned single-env path +// still behaves (liveness + no double-decrement corruption); they do NOT prove +// #5 is fixed. The cross-env behavior that #5 is actually about is exercised by +// the Worker-abandonment cases in worker-lifecycle.test.ts (each Worker is its +// own env, released via env_init_cleanup on Worker exit). A dedicated cross-env +// regression test that goes RED on the pre-fix addon remains a known coverage +// gap for this finding. + +const LIB = findLibrary(); + +function runOn(handle: number, expr: string): unknown { + const envelope = JSON.parse( + ffi.runScriptEngine(handle, `%dw 2.0\noutput application/json\n---\n${expr}`, buildInputsJson({})) + ); + expect(envelope.success).toBe(true); + return JSON.parse(Buffer.from(envelope.result, "base64").toString("utf-8")); +} + +describe("per-env init-reference ownership -- single-env smoke tests (round 13 #5)", () => { + // Smoke test (NOT a #5 regression test -- see file header): destroyEngine() + // never released the init reference in any revision, so this held pre-fix too. + it("smoke: one initialize() + multiple engines stays live when a single engine is destroyed", () => { + ffi.initialize(LIB); // ONE init reference for this env + const h1 = ffi.createEngine(); + const h2 = ffi.createEngine(); + expect(runOn(h2, "6 * 7")).toBe(42); + + // Destroy one engine. The isolate reference belongs to initialize(), not to + // an engine, so the isolate must stay alive and h2 must still run. + ffi.destroyEngine(h1); + expect(runOn(h2, "1 + 1")).toBe(2); + + // Balance: destroy the other engine and release the single init reference. + ffi.destroyEngine(h2); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + return ffi.cleanup().then(() => { + expect(() => + ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) + ).toThrow(/not initialized/i); + }); + }); + + // Smoke test (NOT a #5 regression test -- see file header): the second + // cleanup() was already a no-op pre-fix via the `if (g_ref_count > 0)` floor, + // so with one env this passes on the buggy addon too. #5's gate protects the + // CROSS-env case (one env stealing another's reference), not observable here. + it("smoke: a second cleanup() on an env that owns no reference does not corrupt the count", async () => { + ffi.initialize(LIB); // init_refs = 1 + const h = ffi.createEngine(); + expect(runOn(h, "2 + 2")).toBe(4); + ffi.destroyEngine(h); + + // First cleanup releases this env's one reference -> isolate torn down. + await ffi.cleanup(); + // Second cleanup: this env's init_refs is already 0. Must be a no-op -- + // it must NOT drive g_ref_count negative or perturb a later isolate. + await ffi.cleanup(); + + // Prove the count was not corrupted: a fresh, fully-balanced init/run/cleanup + // cycle still nets to zero (a corrupted negative count would leave the next + // isolate un-torn-down and this final probe would NOT report not-initialized). + ffi.initialize(LIB); + const h2 = ffi.createEngine(); + expect(runOn(h2, "3 + 4")).toBe(7); + ffi.destroyEngine(h2); + await ffi.cleanup(); + + expect(() => + ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) + ).toThrow(/not initialized/i); + }); +}); From 714767ac4a60b183dc52d153cfb2f90d73becb2c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 11:53:08 -0300 Subject: [PATCH 095/216] W-23692110: Tear down the just-built isolate on create-path init-record acquire failure (round 13 #5) If env_init_acquire_and_hook() fails after init_thread_fn built the isolate but before g_initialized=1, throwing left g_isolate!=NULL && g_initialized==0 -- which traps the next initialize() forever in the wait loop's uv_cond_wait (nothing broadcasts g_teardown_cond in TEARDOWN_NONE). Tear the isolate back down before throwing, restoring the recoverable g_isolate==NULL state the sibling init error paths already leave. Corrects the design-spec recoverability note. Co-Authored-By: Claude Sonnet 5 --- ...per-env-init-reference-ownership-design.md | 2 +- native-lib/node/src/addon.c | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md b/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md index c0d95619..92563a58 100644 --- a/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md +++ b/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md @@ -62,7 +62,7 @@ Each of the three `g_ref_count++` sites gains a paired `init_refs` acquire on th - **Adoption path (`:530-534`):** currently `g_teardown_cancelled = true; g_ref_count++; broadcast; unlock; return`. Add `env_init_rec_acquire_locked(env, &is_new)` before the `g_ref_count++`. On `calloc` failure: do **not** cancel the teardown, do **not** bump `g_ref_count`; unlock and `napi_throw_error(env, NULL, "Failed to allocate env init record")`, return NULL. (The teardown stays queued; the caller's initialize failed cleanly.) - **Fast path (`:544-548`):** `if (g_initialized) { g_ref_count++; ... }` — add the acquire before the bump, same failure handling (unlock + throw, no bump). -- **Create path (`:573-575`):** after a successful isolate build, before `g_ref_count++`, do the acquire. On `calloc` failure here the isolate was just built with `g_ref_count` still 0 and `g_initialized` about to be set — restore consistency by tearing back down is overkill; instead treat the record as required: set `g_initialized = 1` is **not** reached — unlock and throw before setting anything, having left `g_isolate`/`g_initialized` in the same "freshly built, ref 0" state the existing spawn-failure/`init` error paths already leave recoverable. **Simpler, chosen rule:** perform the `env_init_rec_acquire_locked` **first** (it only allocates a small node); only if it succeeds proceed to `g_initialized = 1; g_ref_count++`. This keeps the create path all-or-nothing without unwinding the isolate. +- **Create path (`:573-575`):** after a successful isolate build, before `g_ref_count++`, do the acquire. Perform the `env_init_rec_acquire_locked` **first** (it only allocates a small node); only if it succeeds proceed to `g_initialized = 1; g_ref_count++`. On acquire failure, `g_isolate` is already non-NULL (the create path's `init_thread_fn` just built it) while `g_initialized` is still 0 — simply unlocking and throwing would leave that combination in place, which the wait loop's `g_isolate != NULL && !g_initialized` clause treats as "a teardown is in flight," permanently hanging every subsequent `initialize()` in `uv_cond_wait` with nothing left to broadcast. So on this failure the just-built isolate is torn down (via the same `cleanup_thread_fn` idiom used elsewhere) before throwing, clearing `g_isolate`/`g_initialized` back to NULL/0 and restoring the same recoverable state the sibling spawn-failure/`init`-error paths already leave (they never built an isolate in the first place). If the teardown itself cannot attach to the isolate, `g_isolate` is left non-NULL as a best-effort degradation — the same posture already accepted for `cleanup_thread_fn`'s attach-failure path elsewhere. **Hook registration for a new record.** When `env_init_rec_acquire_locked` reports `is_new`, register exactly one env-death hook for the init record: `napi_add_env_cleanup_hook(env, env_init_cleanup, rec)`. This is legal in all three paths (they run on the env's own JS thread with the env alive). If the hook registration **fails**, the record cannot guarantee its references are reclaimed on env death — roll back: decrement the just-acquired `init_refs` (freeing the record if it drops to 0), do not bump `g_ref_count`, unlock, throw. This mirrors round-12 #6's all-or-nothing posture for the per-engine hook. diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 86467620..05eee66d 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -559,6 +559,11 @@ static bool env_init_acquire_and_hook(napi_env env) { return true; } +// Forward declaration: tears down g_isolate on a dedicated attached thread. +// Defined below; used here (napi_initialize's create-path acquire-failure +// recovery) and further down by isolate_ref_release_n_locked. +static void cleanup_thread_fn(void* arg); + static napi_value napi_initialize(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value argv[1]; @@ -647,6 +652,46 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { } if (!env_init_acquire_and_hook(env)) { + // init_thread_fn already built the isolate (g_isolate != NULL) but we have + // not yet set g_initialized = 1. If we just unlock and throw here, we leave + // g_isolate != NULL && g_initialized == 0 -- the exact condition the wait + // loop above (`g_isolate != NULL && !g_initialized`) treats as "a teardown + // is in flight". With g_teardown_state == TEARDOWN_NONE that loop cannot + // take the TEARDOWN_PENDING_WAIT adoption branch, so it falls into + // uv_cond_wait(&g_teardown_cond, ...) with nothing left to ever broadcast -- + // every subsequent initialize() on any env hangs forever. Every sibling + // error path (args.result != 0 above, and the spawn-failure path before it) + // leaves g_isolate == NULL instead, which is the recoverable state. Tear + // the just-built isolate back down before throwing so we restore that same + // recoverable g_isolate == NULL state. + // + // g_ref_count is still 0 here (we never got past this check to bump it), + // and env_init_acquire_and_hook leaves no orphan record behind on failure + // (calloc failure never created one; hook-registration failure rolls its + // own record back) -- so the invariant g_ref_count == sum(init_refs) holds + // with both sides at 0 both before and after this block. + uv_thread_t cleanup_tid; + uv_thread_options_t cleanup_opts; + cleanup_opts.flags = UV_THREAD_HAS_STACK_SIZE; + cleanup_opts.stack_size = 2 * 1024 * 1024; + int torn_down = 0; + int cleanup_spawn_rc = uv_thread_create_ex(&cleanup_tid, &cleanup_opts, cleanup_thread_fn, &torn_down); + if (cleanup_spawn_rc == 0) { + uv_thread_join(&cleanup_tid); + } + if (torn_down) { + // Teardown ran (or there was nothing to tear down) -- clear the globals + // so the next initialize() sees a clean slate. g_ref_count is already 0. + g_thread = NULL; + g_isolate = NULL; + g_initialized = 0; + } + // else: spawn failed, or cleanup_thread_fn's attach to the isolate failed. + // The isolate is genuinely still alive -- leave g_isolate/g_thread as-is + // rather than orphaning it. This re-arms the same trap on a subsequent + // initialize(), but that is the pre-existing best-effort degradation + // policy already accepted for cleanup_thread_fn's attach-failure path + // elsewhere in this file; we don't invent new behavior for it here. uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to allocate/register env init record"); return NULL; From 79bf2294a9bfdaae421f18579ae15b8db54fa35a Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 12:39:10 -0300 Subject: [PATCH 096/216] W-23692110: Design spec for review #5 remediation (round 14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine-creation admission race (#1 High), teardown-failure recovery via a g_mutex-guarded retry flag (#2/#3 Medium), cross-env Worker regression test (#4), Worker helper strictness (#5), cleanup() ref-leak on destroyEngine throw (#6), and resolver-example cleanup docs (#7). Preserves g_ref_count == Σ per-env init_refs. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...teardown-and-admission-hardening-design.md | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-21-review5-teardown-and-admission-hardening-design.md diff --git a/docs/superpowers/specs/2026-08-21-review5-teardown-and-admission-hardening-design.md b/docs/superpowers/specs/2026-08-21-review5-teardown-and-admission-hardening-design.md new file mode 100644 index 00000000..c15af492 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-review5-teardown-and-admission-hardening-design.md @@ -0,0 +1,236 @@ +# Review #5 Remediation — Engine-Creation Admission + Teardown-Failure Recovery + Regression-Test Strength + +**Date:** 2026-08-21 +**Branch:** `w-23692110-multi-engine-design` (PR #157) +**Round:** 14 +**Addresses:** `docs/pr-157-follow-up-code-review-5.md` (1 High, 5 Medium, 1 Low) + +## Context + +PR #157 ships the multi-engine Node binding for the DataWeave native library. Round 13 replaced the unsafe per-engine init-reference release with per-`napi_env` init-reference ownership, establishing the invariant **`g_ref_count == Σ (per-env init_refs)`**. Review #5 confirms that fix is correct and turns to three residual risk areas: engine-creation admission (a live concurrency hole), teardown-failure recovery (a live but owner-less isolate can be stranded), and regression-test strength (the round-13 tests do not actually pin the round-12 defect). + +The reviewed head is `bd68c70` — the exact round-13 HEAD. The subsequent master merge (`212424d`) touched only `native-lib/python/**` and a `package-lock.json` dep bump, so every line reference in the review is still accurate against the current tree. + +This round is **Node-binding only**. It does not touch `native-lib/python/**`, 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 unchanged. Handle width stays C `long long`. Errors surface as resolved JSON strings (async) or synchronous `napi_throw_error` / thrown `DataWeaveError` — never `napi_reject_deferred`. `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread; no env-affine napi call is made from the waiter or a wrong thread. + +**Preserved invariant (every g_mutex release):** `g_ref_count == Σ per-env init_refs`. No fix in this round resurrects a reference that no env owns. + +## Findings and Fixes + +### #1 (High) — engine creation can attach to an isolate being torn down + +**Defect.** `napi_create_engine` (addon.c:1837–1923) and `napi_create_engine_with_resolver` (addon.c:1926+) test `g_initialized` **outside** `g_mutex`, then call `fn_attach_thread(g_isolate, …)` and `fn_create_engine(…)` with (a) no requirement that the calling `napi_env` owns an init reference, and (b) no `g_active_ops` reservation pinning the isolate across the attach. An env that never called `initialize()` (or that already released its reference) can observe a still-`g_initialized` isolate while another env drops the final reference and the waiter/cleanup thread begins `graal_tear_down_isolate()`. The create then attaches to / creates an engine on a tearing-down isolate — a use-after-free. + +**Fix.** Mirror the proven admission pattern already used by `bridge_finalize_registry` (addon.c:286–313): perform the lifecycle check and the reservation in **one critical section** under `g_mutex`, at the top of each create function: + +```c +uv_mutex_lock(&g_mutex); +// Admission (one critical section — no teardown can interleave between the +// checks and the reservation, because every teardown transition and the +// g_active_ops==0 fast path also hold g_mutex): +// (1) isolate must be live and NOT past the point of no return, +// (2) the CALLING env must own an init reference (round-13 ownership model: +// an env with no reference must not create engines on the shared isolate), +// (3) pin the live isolate for the duration of the attach/create. +env_init_rec_t* self = env_init_rec_find_locked(env); +if (!g_initialized || g_isolate == NULL || + g_teardown_state == TEARDOWN_TEARING_DOWN || + self == NULL || self->init_refs == 0) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); + return NULL; +} +g_active_ops++; // pins the live isolate against teardown across the attach +uv_mutex_unlock(&g_mutex); +``` + +After this point, the existing attach/create/detach body runs unchanged, and the `g_active_ops` reservation is **released on every path that leaves the function after the reservation was taken** — success and each failure branch — with the verbatim pattern used everywhere else: + +```c +uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); +``` + +The `fn_create_engine`/`fn_create_engine_with_resolver`/`fn_attach_thread` availability checks (`if (!fn_create_engine) …`) move to *before* the lock (they throw without having taken the reservation) or stay after with the release — the implementer picks whichever keeps the diff minimal, provided every post-reservation exit balances `g_active_ops`. + +**Consequence for the record/hook-registration tail.** The existing OOM/hook-failure rollback paths in both create functions (the `calloc`-failure and `napi_add_env_cleanup_hook`-failure branches) must release the `g_active_ops` reservation in addition to their current cleanup (destroy the created engine, unlink, finalize). The reservation is released once, right before the function returns, on both the success path (after `napi_create_int64` produces the return value) and every failure path. + +**Retires a caveat.** Round-13's `env_init_cleanup` header documents a "pathological raw-ffi order" where `createEngine()` on env B succeeds because env A already initialized, *before* B's own `initialize()`. With requirement (2), that call is now correctly **rejected** (B owns no reference), so the caveat's premise no longer holds. Update that comment to note the create path now enforces per-env ownership. + +**Confirm during review:** +- The lifecycle check and `g_active_ops++` are in one `g_mutex` critical section; no teardown transition can split them. +- Every exit after the reservation balances `g_active_ops` exactly once (no double-decrement, no leak). Count the paths: success, invalid-handle, calloc-fail, hook-fail (create-engine); success, invalid-handle, attach-fail, calloc-fail, reference-fail, hook-fail (resolver variant — note attach-fail and the alloc failures *before* the reservation is taken must NOT decrement). +- An env with `init_refs == 0` (never initialized, or already cleaned up) is rejected with `Not initialized`. +- `g_ref_count` is untouched by this fix (creation never mutated it post round-13); the invariant is unaffected. + +### #2 + #3 (Medium) — teardown-failure paths strand a live, owner-less isolate + +**Defect.** On a reached-zero release, three failure modes leave the isolate physically alive with `g_ref_count == 0` and no pending teardown: +- **#2:** `release_isolate_ref_locked` Case 5 (addon.c:2607–2656) — `teardown_waiter_create` fails (promise/tsfn/resource-name N-API allocation) after `g_ref_count` was decremented to 0. Current code returns `NULL` (throws) with `g_teardown_state` reset to `TEARDOWN_NONE`. Also the Case 5 waiter **spawn** failure restores `g_ref_count = env_init_refs_total_locked()` (= 0) and leaves the isolate live. +- **#3:** `isolate_ref_release_n_locked` (addon.c:2399–2452, called by `env_init_cleanup` on env death) — waiter thread spawn fails, or `cleanup_thread_fn` attach fails so `torn_down` stays 0. `g_ref_count` is restored to `env_init_refs_total_locked()` (= 0 when the dying env was the last), isolate stays live. + +In all three, `g_ref_count == 0` and no env record remains that could call `cleanup()` again, and no `g_teardown_state` is set — so nothing ever retries teardown. The isolate is stranded until an unrelated later `initialize()` happens to adopt it (which may never come). The round-13 invariant (`g_ref_count == Σ init_refs`) is correctly *preserved* by these paths, but preserving it is not sufficient: a zero-owner live isolate needs a retry owner. + +**Fix — a `g_mutex`-guarded retry flag, not a phantom reference.** Add: + +```c +// Set under g_mutex when a reached-zero teardown could NOT be carried out +// (waiter alloc/spawn failed, or cleanup_thread_fn attach failed) and the +// isolate was therefore left live with g_ref_count == 0 and no pending +// teardown. This is a RETRY SIGNAL, not an ownership reference: g_ref_count +// stays 0 so the invariant g_ref_count == Σ init_refs is unaffected. It is +// cleared when the isolate is (a) actually torn down, or (b) adopted by a +// later initialize(). While set with g_active_ops > 0, the drain point at op +// completion retries the teardown once ops reach 0. +static bool g_teardown_needed = false; +``` + +Set `g_teardown_needed = true` in each of the three failure branches (#2 Case-5 waiter-create failure and waiter-spawn failure; #3 `isolate_ref_release_n_locked` waiter-spawn failure and `torn_down == 0` after the sync attempt) **only when** the isolate was left live (`g_isolate != NULL && g_ref_count == 0`). + +**Retry trigger at the op-completion drain point.** The natural retry owner is the last active op finishing. Add a helper that runs the reached-zero teardown decision: + +```c +// Caller holds g_mutex, KEEPS it held. If a prior teardown failed and left the +// isolate live with no owners (g_teardown_needed), and ops have now drained +// (g_active_ops == 0) with still no owners (g_ref_count == 0) and no teardown +// in progress, retry the synchronous teardown exactly as Case 4 does. +static void retry_stranded_teardown_locked(void) { + if (!g_teardown_needed) return; + if (g_ref_count > 0) { g_teardown_needed = false; return; } // adopted → no retry + if (g_teardown_state != TEARDOWN_NONE) return; // a teardown drives + if (g_active_ops > 0) return; // wait for drain + if (g_isolate == NULL) { g_teardown_needed = false; return; } + // g_active_ops == 0, g_ref_count == 0, isolate live: same synchronous + // teardown as Case 4 / isolate_ref_release_n_locked's g_active_ops==0 branch. + uv_thread_t tid; uv_thread_options_t opts; + opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; + 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); + if (torn_down) { + g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; + g_teardown_needed = false; + } + // else: spawn/attach failed again — leave g_teardown_needed set to retry on + // the next drain (or a later initialize() adoption clears it). +} +``` + +Call `retry_stranded_teardown_locked()` under `g_mutex` at each op-completion drain point — i.e. immediately after the existing `g_active_ops--; uv_cond_broadcast(...)` blocks in the streaming/transform completion paths (the `bridge_end_op`/`g_active_ops--` sites). Since those sites already hold `g_mutex` for the decrement, fold the retry call into the same critical section (decrement, broadcast, then retry) to avoid re-locking. + +**Adoption clears the flag.** In `napi_initialize`'s adoption path (the `TEARDOWN_PENDING_WAIT` branch and the fast-path ref bump), and anywhere a new reference is acquired on a surviving isolate, set `g_teardown_needed = false` — a new owner means the isolate is wanted again. Concretely: whenever `env_init_acquire_and_hook` succeeds and `g_ref_count` transitions from 0 to 1 on a live isolate, clear the flag. The simplest correct placement is at the acquire sites right after a successful `g_ref_count++` on an already-live isolate. + +**Why a flag and not "restore caller ownership on alloc failure".** Restoring `self->init_refs` and `g_ref_count` on the failing env would (a) violate the caller's contract (the JS `cleanup()` promise resolves as if the reference was dropped, but the count says otherwise), and (b) for the env-death path (#3) the record is already freed — there is no env to restore ownership to. A separate retry signal decoupled from the reference count is the only model that works uniformly for both the live-caller and no-surviving-env cases while keeping `g_ref_count == Σ init_refs` exactly true. + +**Confirm during review:** +- `g_teardown_needed` is read/written only under `g_mutex`. +- The invariant `g_ref_count == Σ init_refs` holds at every g_mutex release — the flag never substitutes for a reference. +- The retry is idempotent and bounded: it makes the reached-zero teardown decision at most once per drain, and a repeated attach failure simply re-arms for the next drain without spinning. +- No env-affine napi call is made from any thread but the env's own (the retry runs on the JS thread at op completion; `cleanup_thread_fn` attaches its own GraalVM thread and makes no napi calls). +- Adoption in `napi_initialize` clears the flag so a re-init does not later tear down a wanted isolate. +- No deadlock: `retry_stranded_teardown_locked` spawns+joins `cleanup_thread_fn` while holding `g_mutex`, exactly as the existing Case-4 / `isolate_ref_release_n_locked` g_active_ops==0 branch does; `cleanup_thread_fn` takes no lock. + +### #4 (Medium) — cross-env regression test that actually pins the round-12 defect + +**Defect.** Round-13's `env-init-ownership.test.ts` are single-env smoke tests whose own header admits they pass on the pre-fix addon. `worker-lifecycle.test.ts`'s N-Worker test creates only **one** engine per Worker init, so it never exercises the round-12 over-release (N per-engine releases against one init reference). + +**Fix.** Add a Worker-based regression test to `worker-lifecycle.test.ts` (reusing its inline-JS-body + built-addon harness) that: +1. On the main thread: `initialize()` and create a live engine (`h_main`), run a script to confirm it works. +2. Spawn a Worker that: `initialize()` once, creates **N ≥ 3** engines (resolver-less is fine), runs a script on one, and exits **without** `cleanup()` and without destroying its engines — so the Worker env dies with N engines under one init reference. +3. After the Worker exits: assert `h_main` **still runs** (`6 * 7 === 42`) — proving the shared isolate was not torn down by the Worker's env death. +4. Balance the main reference (`destroyEngine(h_main)` + `cleanup()`), then assert a raw `runScriptEngine(Number.MAX_SAFE_INTEGER, …)` throws `/not initialized/i` — proving the count reached exactly zero (no leak, no over-release). + +**Determinism note in the test.** On the **round-12** implementation this goes RED: the Worker's env-death hooks fired N per-engine releases against a count of 1, driving `g_ref_count` negative/to-zero and tearing the isolate down under the live `h_main` → step 3's run fails (isolate gone) or the process wedges. On round-13+ each abandoned env releases exactly one reference regardless of engine count, so `h_main` survives. The test must await Worker `exit` (not just `message`) before asserting step 3, so the env-death hooks have run. Use the stricter `runWorker` helper from #5. + +The two existing `env-init-ownership.test.ts` smoke tests stay (they guard the single-env liveness path), but the file header's "known coverage gap … remains a follow-up" paragraph is updated to point at this new cross-env test as the gap's closure. + +**Confirm during review:** +- The test loads the real built addon (no `vi.mock`), spawns a genuine Worker, and creates N ≥ 3 engines in it. +- It awaits Worker exit before the post-exit assertions. +- It balances all references so it does not perturb sibling integration files (main `cleanup()` at the end; the file's `afterAll` already calls `ffi.cleanup()` idempotently). +- The RED-on-round-12 / green-on-round-13 reasoning is documented in a comment. + +### #5 (Medium) — Worker lifecycle helper hides a nonzero exit + +**Defect.** `runWorker` (worker-lifecycle.test.ts:71–83) resolves as soon as the Worker posts a message, and its `exit` handler only rejects `if (code !== 0 && !msg)`. A Worker that posts its success result and *then* exits nonzero (e.g. an env-cleanup-hook failure during teardown) resolves as success — the failure is hidden. + +**Fix.** Rework the promise so that: +- The message is captured but resolution waits for `exit`. +- On `exit`: reject **every** nonzero code (`new Error("Worker exited with code " + code + (msg ? "" : " and posted no message"))`). +- On `exit` code 0 **with** a captured message: resolve with the message. +- On `exit` code 0 **without** a message: reject as a distinct diagnostic (`"Worker exited 0 without posting a result"`). +- Keep the `error` handler rejecting. + +All existing callers already `await` the result and assert `msg.ok`, so tightening resolution to `exit` is compatible; the abandon-variant Workers exit 0 after posting, so they still resolve. + +**Confirm during review:** no caller regresses; the N-Worker abandon test and the new #4 test both still pass; a hypothetical nonzero-exit Worker now rejects. + +### #6 (Medium) — `DataWeave.cleanup()` leaks the init reference if `destroyEngine()` throws + +**Defect.** `DataWeave.doCleanup()` (dataweave.ts:145–159) calls `ffi.destroyEngine(this.engineHandle)` before `await ffi.cleanup()`. If `destroyEngine` throws (a real path: wrong-thread destruction throws synchronously), the `finally` resets `this.state`/`this.engineHandle` but `ffi.cleanup()` never runs — the native init reference for this env is never released, and the engine handle is no longer reachable from the instance. The reference leaks. + +**Fix.** Ensure `ffi.cleanup()` runs even when `destroyEngine()` throws, preserving the primary (destruction) error: + +```ts +private async doCleanup(): Promise { + this.state = "cleaning-up"; + let destroyError: unknown; + try { + if (this.engineHandle !== null) { + try { + ffi.destroyEngine(this.engineHandle); + } catch (e) { + // Preserve the primary error but STILL release the native init + // reference below — otherwise a throwing destroyEngine() (e.g. + // wrong-thread destruction) would strand this env's reference and + // block isolate teardown. The engine handle is cleared regardless so + // a retry does not double-destroy. + destroyError = e; + } finally { + this.engineHandle = null; + } + } + await ffi.cleanup(); + } finally { + this.state = "uninitialized"; + } + if (destroyError !== undefined) throw destroyError; +} +``` + +The `await ffi.cleanup()` now always runs (releasing the reference); a destruction error is re-thrown after cleanup so callers still observe it. If `ffi.cleanup()` itself also throws, its error propagates from the `await` (the destruction error is then suppressed — acceptable: the reference-release failure is the more actionable one, and this matches the "report/suppress secondary" guidance). + +**Test.** Add a unit test (in the existing `dataweave.ts` unit suite, with `ffi` mocked) where `destroyEngine` is mocked to throw: assert (a) `ffi.cleanup()` was still called, (b) the original destruction error propagates from `cleanup()`, (c) `this.state` ends `uninitialized`. + +**Confirm during review:** `ffi.cleanup()` is invoked on the throwing-`destroyEngine` path; the primary error is preserved; `engineHandle` is cleared so a subsequent cleanup does not re-destroy; the coalescing/`cleanupPromise` logic in the public `cleanup()` wrapper is unaffected. + +### #7 (Low) — resolver quick-start examples omit cleanup + +**Defect.** `external-modules.md:7–25` and `README.md:231–253` show resolver-backed `DataWeave` instances with no `await dw.cleanup()`, though later docs state uncleaned instances retain their engine and resolver closure. + +**Fix.** Wrap each complete example's `dw.initialize()`/`dw.run()` in `try { … } finally { await dw.cleanup(); }` and make the surrounding scope `async` (or add a one-line note that the snippet runs inside an async function). Keep the example output comments intact. + +**Confirm during review:** both examples show `await dw.cleanup()` in a `finally`; the snippets remain runnable (async context noted); no other doc claims are altered. + +## Task Ordering + +1. **#1** — engine-creation admission (isolated, High, `addon.c`). +2. **#2 + #3** — teardown-failure retry flag + drain-point retry + adoption clear (`addon.c`; shared machinery, done as one task). +3. **#6** — `doCleanup()` reference-leak fix + unit test (`dataweave.ts`). +4. **#5** — `runWorker` helper strictness (`worker-lifecycle.test.ts`). +5. **#4** — cross-env Worker regression test (`worker-lifecycle.test.ts`; depends on #5's stricter helper). +6. **#7** — docs cleanup (`external-modules.md`, `README.md`). + +Each task ends green on the full Node vitest suite. Baseline before this round: **897 passed / 59 skipped / 0 failed**. Net new tests: #6 (1 unit) + #4 (1 integration) → target **899 passed / 59 skipped / 0 failed** (the helper change in #5 alters no test count). + +## Build & Test + +- Build: `cd native-lib/node && npm run build:addon && npm run build:ts` +- Test: `DATAWEAVE_NATIVE_LIB=/Users/lmariano/dev/mulesoft/data-weave-cli/native-lib/node/native/dwlib.dylib npm test` +- `dwlib.dylib` is unchanged this round (only `addon.c`, `dataweave.ts`, tests, and docs change — no Java). + +## Rejected Alternatives + +- **#1: check `g_initialized` under the lock but skip the `g_active_ops` reservation.** Insufficient: the attach happens after the lock is dropped, so a teardown can still start between the check and `fn_attach_thread`. The reservation is what pins the isolate across the attach, exactly as `bridge_finalize_registry` does. +- **#2/#3: restore the failing caller's ownership (`init_refs`/`g_ref_count`) instead of a flag.** Breaks the JS `cleanup()` contract (promise resolves as released while the count says held) and is impossible for the env-death path (the record is already freed). A retry signal decoupled from the count is the only uniform model. +- **#2/#3: spawn a dedicated retry thread that polls until teardown succeeds.** Adds a background thread and a spin loop for a rare OOM/spawn-failure path; the op-completion drain point is a natural, already-locked retry owner with no new thread. +- **#4: keep documenting the gap (round-13 decision).** The reviewer raised this class twice; the user chose to write the real cross-env test this round. From d29bb3f4232246519a3d19689504e11dcd08f9c0 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 13:07:44 -0300 Subject: [PATCH 097/216] W-23692110: Gate engine creation on ownership + teardown state + op reservation (round 14 #1) napi_create_engine / napi_create_engine_with_resolver now require, in one g_mutex critical section, a live isolate not past the point of no return, that the calling env owns an init reference, and a g_active_ops reservation pinning the isolate across the attach/create. The reservation is balanced on every exit after it is taken (success, invalid-handle, alloc-fail, hook-fail). Closes the race where a non-owning env attaches to an isolate being torn down. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 67 +++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 05eee66d..ac44ccb0 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1836,10 +1836,34 @@ static char* resolve_module_callback(void* thread, void* ctx, const char* module // 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; } + + // Round-14 (#1): admission in ONE g_mutex critical section (mirrors + // bridge_finalize_registry). Require (a) a live isolate not past the point + // of no return, (b) that THIS env owns an init reference (round-13 ownership + // model: an env with no reference must not create engines on the shared + // isolate -- it could otherwise attach to an isolate another env is tearing + // down), and (c) pin the isolate with a g_active_ops reservation so + // graal_tear_down_isolate() cannot run across the attach/create below. The + // check and the g_active_ops++ cannot be split by a teardown because every + // teardown transition and the g_active_ops==0 fast path also hold g_mutex. + uv_mutex_lock(&g_mutex); + env_init_rec_t* self = env_init_rec_find_locked(env); + if (!g_initialized || g_isolate == NULL || + g_teardown_state == TEARDOWN_TEARING_DOWN || + self == NULL || self->init_refs == 0) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); + return NULL; + } + g_active_ops++; // pins the live isolate against teardown across the attach + uv_mutex_unlock(&g_mutex); + void* thread = NULL; - if (fn_attach_thread(g_isolate, &thread) != 0) { 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); + 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 @@ -1847,7 +1871,10 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // 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; } + if (handle <= 0) { + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + 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) @@ -1871,6 +1898,7 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { void* t2 = NULL; if (fn_attach_thread(g_isolate, &t2) == 0) { fn_destroy_engine(t2, handle); fn_detach_thread(t2); } } + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to allocate engine record"); return NULL; } @@ -1915,16 +1943,18 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { uv_mutex_unlock(&g_mutex); bridge_finalize_registry(rec); bridge_finalize_free(rec, /*env_still_alive=*/true); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to register engine cleanup hook"); return NULL; } - napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; + napi_value out; napi_create_int64(env, (int64_t)handle, &out); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + return out; } // 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); @@ -1937,8 +1967,25 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i } bridge->env = env; bridge->owner = uv_thread_self(); bridge->results = NULL; + // Round-14 (#1): same admission block as napi_create_engine. Taken AFTER the + // bridge/resolver-ref allocation (those failures touch no isolate state and + // must not decrement a reservation not yet held) and BEFORE fn_attach_thread. + uv_mutex_lock(&g_mutex); + env_init_rec_t* self = env_init_rec_find_locked(env); + if (!g_initialized || g_isolate == NULL || + g_teardown_state == TEARDOWN_TEARING_DOWN || + self == NULL || self->init_refs == 0) { + uv_mutex_unlock(&g_mutex); + napi_delete_reference(env, bridge->resolver_js); free(bridge); + napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); + return NULL; + } + g_active_ops++; // pins the live isolate against teardown across the attach + 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); napi_delete_reference(env, bridge->resolver_js); free(bridge); napi_throw_error(env, NULL, "Failed to attach thread"); return NULL; } @@ -1956,6 +2003,7 @@ 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) { + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); // Synchronous call on the JS thread -- env is live here. bridge_finalize(bridge, /*env_still_alive=*/true, /*do_registry_remove=*/false); napi_throw_error(env, NULL, "create_engine_with_resolver returned an invalid handle"); @@ -1993,10 +2041,13 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i uv_mutex_unlock(&g_mutex); bridge_finalize_registry(bridge); bridge_finalize_free(bridge, /*env_still_alive=*/true); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to register engine cleanup hook"); return NULL; } - napi_value out; napi_create_int64(env, (int64_t)handle, &out); return out; + napi_value out; napi_create_int64(env, (int64_t)handle, &out); + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + return out; } // destroyEngine(handle) -> void @@ -2468,6 +2519,10 @@ static void isolate_ref_release_n_locked(int n) { // isolate down under a live env. Runs on the dying env's own thread with the // env alive; does only g_mutex-guarded integer/list work + free (no env-affine // napi calls). +// Round-14 (#1): the create path now enforces per-env ownership (an env with +// init_refs == 0 is rejected), so the pathological order below -- createEngine() +// on this env BEFORE its own initialize() -- is now rejected at the create call +// rather than relying on the finalize-time teardown-state re-check. static void env_init_cleanup(void* arg) { env_init_rec_t* rec = (env_init_rec_t*)arg; if (rec == NULL) return; From cec5195ef654d065825a91ab7af1d17766c21994 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 13:45:28 -0300 Subject: [PATCH 098/216] W-23692110: Retry stranded teardown so a failed last-release cannot orphan a live isolate (round 14 #2/#3) Add a g_mutex-guarded retry signal g_teardown_needed (NOT a reference: g_ref_count stays 0, invariant g_ref_count == sum(init_refs) preserved), armed when a reached-zero teardown cannot be carried out (waiter alloc/spawn failure, cleanup_thread_fn attach failure) with the isolate left live and owner-less. retry_stranded_teardown_locked() retries the synchronous teardown at the streaming/transform op-completion drain points; adoption in initialize() clears the flag. Closes the two paths that stranded a live isolate with no owner to retry cleanup. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 89 ++++++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index ac44ccb0..9502910d 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -165,6 +165,16 @@ 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; +// Round-14 (#2/#3): set under g_mutex when a reached-zero teardown could NOT be +// carried out (teardown-waiter alloc/spawn failed, or cleanup_thread_fn attach +// failed) and the isolate was therefore left LIVE with g_ref_count == 0 and no +// pending teardown. This is a RETRY SIGNAL, not an ownership reference: +// g_ref_count stays 0, so the invariant g_ref_count == sum(init_refs) is +// unaffected. It is cleared when the isolate is (a) actually torn down by a +// retry, or (b) adopted by a later initialize() (a new owner wants it kept). +// While set with g_active_ops > 0, the op-completion drain point retries the +// teardown once ops reach 0 (retry_stranded_teardown_locked). +static bool g_teardown_needed = false; static uv_cond_t g_teardown_cond; // One node per cleanup() call that arrived while a teardown was already @@ -564,6 +574,11 @@ static bool env_init_acquire_and_hook(napi_env env) { // recovery) and further down by isolate_ref_release_n_locked. static void cleanup_thread_fn(void* arg); +// Forward declaration: retries a stranded teardown (round-14 #2/#3). Defined +// further below; used by the streaming/transform op-completion drain points, +// which run earlier in this file than the definition. +static void retry_stranded_teardown_locked(void); + static napi_value napi_initialize(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value argv[1]; @@ -605,6 +620,7 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { } g_teardown_cancelled = true; g_ref_count++; + g_teardown_needed = false; // round-14: a new owner wants the isolate kept uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); return NULL; @@ -624,6 +640,7 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { return NULL; } g_ref_count++; + g_teardown_needed = false; // round-14: a new owner wants the isolate kept uv_mutex_unlock(&g_mutex); return NULL; } @@ -698,6 +715,13 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { } g_initialized = 1; g_ref_count++; + // Round-14: defensive clear. A brand-new isolate can never carry a stale + // stranded-teardown signal for itself (a new graal_create_isolate only runs + // when g_isolate == NULL, so this path cannot reuse a surviving stranded + // isolate) -- but clear it here anyway at the single create-path success + // point so no later drain retries a teardown against the isolate this + // initialize() just created and now owns. + g_teardown_needed = false; uv_mutex_unlock(&g_mutex); return NULL; } @@ -939,6 +963,9 @@ static void streaming_thread_fn(void* arg) { uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); + // Round-14 (#2/#3): if a prior last-release could not tear the isolate down + // and left it stranded (g_teardown_needed), retry now that this op has drained. + retry_stranded_teardown_locked(); uv_mutex_unlock(&g_mutex); // Round-9 (#2): if even the sentinel struct cannot be allocated, we cannot @@ -1434,6 +1461,8 @@ static void transform_thread_fn(void* arg) { uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); + // Round-14 (#2/#3): retry a stranded teardown now that this op has drained. + retry_stranded_teardown_locked(); uv_mutex_unlock(&g_mutex); // Round-9 (#2): sentinel malloc NULL -> skip enqueue and run the same native @@ -2456,6 +2485,40 @@ static napi_value already_resolved_promise(napi_env env) { // point. (Previously also used by a single-release wrapper, // isolate_ref_release_core_locked, retired in round-13 #5 once the per-engine // finalize path stopped releasing init references directly.) +// Round-14 (#2/#3): retry a teardown that a prior last-release could not carry +// out. Caller holds g_mutex and this KEEPS it held. No-op unless a stranded +// live isolate is waiting (g_teardown_needed) with no owners and no teardown in +// progress and ops drained. Makes the reached-zero teardown decision at most +// once per call (same synchronous cleanup_thread_fn path as Case 4); on repeated +// failure it leaves g_teardown_needed set to retry on the next drain. Spawns+joins +// cleanup_thread_fn while holding g_mutex, exactly as the Case-4 / +// isolate_ref_release_n_locked g_active_ops==0 branch does; cleanup_thread_fn +// takes no lock and makes no napi call, so this is deadlock-free and thread-safe +// from any drain site. +static void retry_stranded_teardown_locked(void) { + if (!g_teardown_needed) return; + if (g_ref_count > 0) { g_teardown_needed = false; return; } // adopted -> keep + if (g_teardown_state != TEARDOWN_NONE) return; // a teardown drives + if (g_active_ops > 0) return; // wait for drain + if (g_isolate == NULL) { g_teardown_needed = false; return; } // nothing to do + uv_thread_t tid; + uv_thread_options_t opts; + opts.flags = UV_THREAD_HAS_STACK_SIZE; + opts.stack_size = 2 * 1024 * 1024; + 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); + if (torn_down) { + g_thread = NULL; + g_isolate = NULL; + g_initialized = 0; + g_ref_count = 0; + g_teardown_needed = false; + } + // else: spawn/attach failed again -- leave g_teardown_needed set so the next + // drain (or a later initialize() adoption) retries. +} + static void isolate_ref_release_n_locked(int n) { if (n <= 0) return; if (g_ref_count >= n) g_ref_count -= n; else g_ref_count = 0; @@ -2477,6 +2540,12 @@ static void isolate_ref_release_n_locked(int n) { g_isolate = NULL; g_initialized = 0; g_ref_count = 0; + } else if (g_isolate != NULL && g_ref_count == 0) { + // Sync teardown failed (spawn or cleanup_thread_fn attach) with the isolate + // still live and no owners: arm the retry signal (round-14 #3). g_active_ops + // is already 0 here, but a later op could still re-pin; the flag is cleared + // on adoption and retried on drain. + g_teardown_needed = true; } return; } @@ -2491,14 +2560,14 @@ static void isolate_ref_release_n_locked(int n) { waiter_opts.stack_size = 2 * 1024 * 1024; int spawn_rc = uv_thread_create_ex(&waiter_tid, &waiter_opts, teardown_waiter_thread_fn, NULL); if (spawn_rc != 0) { - // Deferred teardown could not be spawned: the isolate stays live but no - // waiter will drain it (best-effort degradation, matching the original - // intent). Restore g_ref_count to the true remaining ownership so the - // invariant g_ref_count == sum of init_refs holds -- NOT a hardcoded 1, - // which would resurrect a reference no env owns (wrong for the n>1 - // env-death caller, which has already freed its record before calling). + // Best-effort degradation: the waiter thread never started, so nothing will + // drain the isolate. Restore g_ref_count to the true remaining ownership + // (Σ init_refs, = 0 here) to keep the invariant, and ARM the retry signal so + // the next op-completion drain retries teardown -- otherwise this live + // isolate has zero owners and nothing would ever tear it down (round-14 #3). g_teardown_state = TEARDOWN_NONE; g_ref_count = env_init_refs_total_locked(); + if (g_isolate != NULL && g_ref_count == 0) g_teardown_needed = true; } } @@ -2664,7 +2733,12 @@ static napi_value release_isolate_ref_locked(napi_env env) { napi_value promise; teardown_waiter_t* waiter = teardown_waiter_create(env, &promise); if (waiter == NULL) { + // The last reference was already dropped (g_ref_count == 0) but we cannot + // build the waiter to drain the isolate. Arm the retry signal so the op + // drain retries teardown -- without it this live isolate would have zero + // owners and nothing to tear it down (round-14 #2). g_teardown_state = TEARDOWN_NONE; + if (g_isolate != NULL && g_ref_count == 0) g_teardown_needed = true; uv_mutex_unlock(&g_mutex); return NULL; // teardown_waiter_create already threw } @@ -2705,6 +2779,9 @@ static napi_value release_isolate_ref_locked(napi_env env) { // or env-death hook -- and would break the invariant g_ref_count == Σ // init_refs. A later initialize() will re-acquire on the surviving isolate. g_ref_count = env_init_refs_total_locked(); + // Arm the retry signal: the isolate stays live with no owners and no waiter, + // so the op-completion drain must retry teardown (round-14 #2). + if (g_isolate != NULL && g_ref_count == 0) g_teardown_needed = true; uv_mutex_unlock(&g_mutex); return promise; From 7d6829447e99a5783ff28e1ed81c2f40cfde6447 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 13:56:41 -0300 Subject: [PATCH 099/216] W-23692110: Arm the retry signal in release_isolate_ref_locked Case 4 (round 14 follow-up) Case 4's synchronous g_active_ops==0 teardown path left cleanup_thread_fn spawn/attach failure un-armed, stranding a live isolate with zero owners and no retry signal -- the exact defect this task closes, just missed in its structural twin. Mirror isolate_ref_release_n_locked's sync-failure arm exactly: same guard (g_isolate != NULL && g_ref_count == 0), same else-if chaining onto the existing torn_down check. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 9502910d..670542f0 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2717,6 +2717,12 @@ static napi_value release_isolate_ref_locked(napi_env env) { g_isolate = NULL; g_initialized = 0; g_ref_count = 0; + } else if (g_isolate != NULL && g_ref_count == 0) { + // cleanup_thread_fn spawn/attach failed: the isolate is still live with + // zero owners. Arm the retry signal so a later op-completion drain (or a + // fresh initialize() adoption) tears it down instead of stranding it — + // mirrors the twin arm in isolate_ref_release_n_locked. + g_teardown_needed = true; } uv_mutex_unlock(&g_mutex); return already_resolved_promise(env); From 0b028d3eca0acf7772e779a91ee2c67203eea42c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 14:02:36 -0300 Subject: [PATCH 100/216] W-23692110: Release the native init reference even when destroyEngine() throws (round 14 #6) DataWeave.doCleanup() called ffi.destroyEngine() before ffi.cleanup(); a throwing destroyEngine (e.g. wrong-thread destruction) skipped ffi.cleanup() and leaked this env's native init reference. Now capture the primary error, clear the handle, always run ffi.cleanup() to release the reference, and re-throw the primary error. Unit test with a mocked throwing destroyEngine. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 20 ++++++++++++-- .../tests/unit/dataweave-initialize.test.ts | 27 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 4a18f58b..5f8fe057 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -150,15 +150,31 @@ export class DataWeave { // 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"; + let destroyError: unknown; try { if (this.engineHandle !== null) { - ffi.destroyEngine(this.engineHandle); - this.engineHandle = null; + try { + ffi.destroyEngine(this.engineHandle); + } catch (e) { + // Round-14 (#6): a throwing destroyEngine() (e.g. wrong-thread + // destruction) must NOT skip ffi.cleanup() -- that would strand this + // env's native init reference and block isolate teardown. Capture the + // primary error, clear the handle so a retry does not double-destroy, + // and fall through to release the reference below. + destroyError = e; + } finally { + this.engineHandle = null; + } } await ffi.cleanup(); } finally { this.state = "uninitialized"; } + // Surface the primary destruction error after the reference was released. If + // ffi.cleanup() itself rejected, its error already propagated from the await + // (the more actionable reference-release failure wins; the destroy error is + // then suppressed). + if (destroyError !== undefined) throw destroyError; } /** diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 80ff84ae..d2782770 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -224,4 +224,31 @@ describe("DataWeave.initialize() native ref-count safety", () => { else process.env.DATAWEAVE_NATIVE_LIB = prevEnvLib; } }); + + it("still calls ffi.cleanup() (releasing the native init reference) when destroyEngine() throws", async () => { + // Real path: wrong-thread destroyEngine() throws synchronously. If cleanup() + // skipped ffi.cleanup() on that throw, the native init reference for this env + // would leak and block isolate teardown. cleanup() must release it anyway and + // still surface the primary destruction error. + vi.mocked(ffi.initialize).mockImplementation(() => {}); + vi.mocked(ffi.createEngine).mockReturnValue(7); + vi.mocked(ffi.destroyEngine).mockImplementation(() => { + throw new Error("wrong-thread destroy boom"); + }); + vi.mocked(ffi.cleanup).mockResolvedValue(undefined); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + + await expect(dw.cleanup()).rejects.toThrow("wrong-thread destroy boom"); + + // The native init reference was still released despite the destroy throw. + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + + // The instance is not stranded "ready": a later initialize() works. + vi.mocked(ffi.destroyEngine).mockReset(); + vi.mocked(ffi.createEngine).mockReturnValue(9); + dw.initialize(); + expect(ffi.createEngine).toHaveBeenLastCalledWith(); + }); }); From 6cc6fd58425e83c953e9083e39407ba98407602f Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 14:07:34 -0300 Subject: [PATCH 101/216] W-23692110: Reject every nonzero Worker exit in the test helper (round 14 #5) runWorker resolved as soon as the Worker posted a message, hiding a later nonzero exit (e.g. an env-cleanup-hook failure after the success result was posted). Now wait for exit: reject every nonzero code, treat a zero exit with no posted result as a distinct failure, and resolve only on a clean exit that posted a message. Co-Authored-By: Claude Sonnet 5 --- .../tests/integration/worker-lifecycle.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/native-lib/node/tests/integration/worker-lifecycle.test.ts b/native-lib/node/tests/integration/worker-lifecycle.test.ts index ad43adf8..1ea042ef 100644 --- a/native-lib/node/tests/integration/worker-lifecycle.test.ts +++ b/native-lib/node/tests/integration/worker-lifecycle.test.ts @@ -73,12 +73,22 @@ function runWorker(opts: { eval: true, workerData: { addonPath: ADDON_PATH, libPath: LIB_PATH, mode: opts.mode, cleanup: opts.cleanup, script: opts.script }, }); - let msg: any; + let msg: { ok: boolean; output?: string; error?: string } | undefined; w.once("message", (m) => { msg = m; }); w.once("error", reject); + // Resolve only on a CLEAN exit that posted a result. A Worker can post a + // success message and THEN exit nonzero (e.g. an env-cleanup-hook failure + // during teardown) -- resolving on the message alone would hide that. So + // wait for exit: reject every nonzero code, and treat a zero exit with no + // posted message as its own diagnosable failure (round-14 #5). w.once("exit", (code) => { - if (code !== 0 && !msg) reject(new Error("Worker exited " + code)); - else resolve(msg); + if (code !== 0) { + reject(new Error("Worker exited with code " + code + (msg ? "" : " and posted no message"))); + } else if (msg === undefined) { + reject(new Error("Worker exited 0 without posting a result")); + } else { + resolve(msg); + } }); }); } From f76eb3901f449e03c044c032540e744acbe7d7f5 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 14:13:29 -0300 Subject: [PATCH 102/216] W-23692110: Add cross-env Worker regression pinning the round-12 over-release (round 14 #4) A Worker initializes once, creates N engines, and exits without cleanup(); the main-thread engine must still run afterward (round-13 releases exactly one reference per abandoned env regardless of engine count). Goes RED on round-12 (N per-engine releases tore the isolate down under the live main engine) and passes at round 13+. Updates the env-init-ownership.test.ts coverage-gap note to point at this test. Co-Authored-By: Claude Sonnet 5 --- .../integration/env-init-ownership.test.ts | 12 +-- .../integration/worker-lifecycle.test.ts | 76 +++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/native-lib/node/tests/integration/env-init-ownership.test.ts b/native-lib/node/tests/integration/env-init-ownership.test.ts index 0ddc630f..fe30aa90 100644 --- a/native-lib/node/tests/integration/env-init-ownership.test.ts +++ b/native-lib/node/tests/integration/env-init-ownership.test.ts @@ -20,11 +20,13 @@ import { findLibrary, buildInputsJson } from "../../src/utils"; // revision, and the second cleanup() was already a no-op via the long-standing // `if (g_ref_count > 0)` floor). They guard that the sanctioned single-env path // still behaves (liveness + no double-decrement corruption); they do NOT prove -// #5 is fixed. The cross-env behavior that #5 is actually about is exercised by -// the Worker-abandonment cases in worker-lifecycle.test.ts (each Worker is its -// own env, released via env_init_cleanup on Worker exit). A dedicated cross-env -// regression test that goes RED on the pre-fix addon remains a known coverage -// gap for this finding. +// #5 is fixed. The cross-env behavior that #5 is actually about -- an abandoned env with N +// engines under one initialize() -- is now pinned by the dedicated cross-env +// regression test in worker-lifecycle.test.ts ("a Worker that inits once + +// creates N engines + exits without cleanup() does NOT tear down the isolate +// under a live main engine"), which fails RED on the round-12 implementation +// and passes at round 13+. These single-env smoke tests remain as a fast guard +// on the sanctioned single-env liveness path. const LIB = findLibrary(); diff --git a/native-lib/node/tests/integration/worker-lifecycle.test.ts b/native-lib/node/tests/integration/worker-lifecycle.test.ts index 1ea042ef..4d5b887c 100644 --- a/native-lib/node/tests/integration/worker-lifecycle.test.ts +++ b/native-lib/node/tests/integration/worker-lifecycle.test.ts @@ -194,4 +194,80 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { ffi.destroyEngine(h); await ffi.cleanup(); }); + + it("a Worker that inits once + creates N engines + exits without cleanup() does NOT tear down the isolate under a live main engine (round 13 #5)", async () => { + // This is the cross-env regression the round-13 smoke tests could not pin + // (env-init-ownership.test.ts is single-env). It fails RED on the round-12 + // implementation: the Worker's env death fired N per-engine init-reference + // releases against the ONE reference the Worker owned, driving g_ref_count to + // zero and tearing the shared isolate down under the live main engine -> the + // main engine's run below would fail (isolate gone) or the process wedges. On + // round-13+ each abandoned env releases exactly one reference regardless of + // engine count, so the main engine survives. + const N = 3; + + // 1. Main thread: initialize and keep a live engine. + ffi.initialize(LIB_PATH); + const hMain = ffi.createEngine(); + const first = JSON.parse( + ffi.runScriptEngine(hMain, "%dw 2.0\noutput application/json\n---\n6 * 7", buildInputsJson({})) + ); + expect(first.success).toBe(true); + expect(JSON.parse(Buffer.from(first.result, "base64").toString("utf-8"))).toBe(42); + + // 2. Worker: initialize ONCE, create N engines, run one, exit WITHOUT cleanup. + const workerBody = ` + const { parentPort, workerData } = require('node:worker_threads'); + (async () => { + const addon = require(workerData.addonPath); + addon.initialize(workerData.libPath); // ONE init reference for this env + const handles = []; + for (let i = 0; i < workerData.n; i++) handles.push(addon.createEngine()); + const raw = addon.runScriptEngine(handles[0], workerData.script, '{}'); + const parsed = JSON.parse(raw); + parentPort.postMessage({ ok: parsed.success !== false, count: handles.length }); + // Return WITHOUT destroyEngine/cleanup: the env dies with N engines under + // one init reference -> env_init_cleanup releases exactly ONE reference. + })().catch((e) => { parentPort.postMessage({ ok: false, error: String(e) }); }); + `; + const workerMsg = await new Promise<{ ok: boolean; count?: number; error?: string }>((resolve, reject) => { + const w = new Worker(workerBody, { + eval: true, + workerData: { + addonPath: ADDON_PATH, + libPath: LIB_PATH, + n: N, + script: "%dw 2.0\noutput application/json\n---\n1 + 1", + }, + }); + let msg: { ok: boolean; count?: number; error?: string } | undefined; + w.once("message", (m) => { msg = m; }); + w.once("error", reject); + // Wait for EXIT (not just message) so the Worker env's death hooks + // (env_init_cleanup) have run before we assert the main engine survived. + w.once("exit", (code) => { + if (code !== 0) reject(new Error("Worker exited with code " + code + (msg ? "" : " and posted no message"))); + else if (msg === undefined) reject(new Error("Worker exited 0 without posting a result")); + else resolve(msg); + }); + }); + expect(workerMsg.ok).toBe(true); + expect(workerMsg.count).toBe(N); + + // 3. The Worker abandoned N engines under one init reference and its env + // died. The main engine's reference must be intact and the isolate live. + const second = JSON.parse( + ffi.runScriptEngine(hMain, "%dw 2.0\noutput application/json\n---\n1 + 1", buildInputsJson({})) + ); + expect(second.success).toBe(true); + expect(JSON.parse(Buffer.from(second.result, "base64").toString("utf-8"))).toBe(2); + + // 4. Balance the main reference and prove the count reached exactly zero + // (no leak, no over-release): a raw op now throws "not initialized". + ffi.destroyEngine(hMain); + await ffi.cleanup(); + expect(() => + ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) + ).toThrow(/not initialized/i); + }, 20000); }); From d67ae939ccbc4edb6547560db6b712127f18bb39 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 14:27:04 -0300 Subject: [PATCH 103/216] W-23692110: Add await dw.cleanup() to resolver quick-start docs (round 14 #7) Both resolver-backed quick-start examples now wrap run() in try/finally with await dw.cleanup(), matching the documented requirement that uncleaned instances retain their engine and resolver closure. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/README.md | 27 ++++++++++++++---------- native-lib/node/docs/external-modules.md | 24 +++++++++++++-------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 851932e5..0ddea7b1 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -240,6 +240,7 @@ DataWeave scripts can import external modules using the `resolveModule` option. ```typescript import { DataWeave, composeResolvers, modulesFromDirectory, modulesFromJars } from 'dataweave-native'; +// Inside an async function (uses `await` for modulesFromJars and cleanup()). const dw = new DataWeave({ resolveModule: composeResolvers( modulesFromDirectory('./my-modules'), @@ -247,17 +248,21 @@ const dw = new DataWeave({ ) }); dw.initialize(); - -const result = dw.run(` - %dw 2.0 - import org::company::utils - output application/json - --- - utils::doSomething() -`); - -if (result.success) { - console.log(result.getString()); +try { + const result = dw.run(` + %dw 2.0 + import org::company::utils + output application/json + --- + utils::doSomething() + `); + + if (result.success) { + console.log(result.getString()); + } +} finally { + // Release the engine and resolver closure when done. + await dw.cleanup(); } ``` diff --git a/native-lib/node/docs/external-modules.md b/native-lib/node/docs/external-modules.md index 1549415f..05a08a93 100644 --- a/native-lib/node/docs/external-modules.md +++ b/native-lib/node/docs/external-modules.md @@ -7,21 +7,27 @@ DataWeave scripts can import external modules using the `resolveModule` option. ```typescript import { DataWeave, modulesFromMap } from 'dataweave-native'; +// Inside an async function so `await dw.cleanup()` is available. const dw = new DataWeave({ resolveModule: modulesFromMap({ 'org/company/lib.dwl': '%dw 2.0\nfun greet(n) = "Hello " ++ n', }), }); dw.initialize(); - -const result = dw.run(` - %dw 2.0 - import org::company::lib - output application/json - --- - lib::greet("World") -`); -console.log(result.getString()); // "Hello World" +try { + const result = dw.run(` + %dw 2.0 + import org::company::lib + output application/json + --- + lib::greet("World") + `); + console.log(result.getString()); // "Hello World" +} finally { + // Release the engine and the resolver closure; an uncleaned instance retains + // both (see the lifecycle notes below). + await dw.cleanup(); +} ``` **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. From 586e411a4a1acf068f306a401d2bc57c3a667813 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 15:39:47 -0300 Subject: [PATCH 104/216] docs: add review #6 remediation design spec (round 15) Covers all 8 code findings (#1 poisoned singleton, #2 hung stream, #3 unchecked teardown return, #4 waiter attach-failure strand, #5 zero-op drain via init-driven teardown completion, #6/#7/#8 test hardening). #5 uses the chosen init-driven-completion approach with documented lingering-until-process-exit residual; #9 (Python scope) left as-is with a PR note. Preserves the g_ref_count invariant. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...gleton-stream-teardown-hardening-design.md | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-21-review6-singleton-stream-teardown-hardening-design.md diff --git a/docs/superpowers/specs/2026-08-21-review6-singleton-stream-teardown-hardening-design.md b/docs/superpowers/specs/2026-08-21-review6-singleton-stream-teardown-hardening-design.md new file mode 100644 index 00000000..4bcd5bf9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-review6-singleton-stream-teardown-hardening-design.md @@ -0,0 +1,334 @@ +# Review #6 Remediation — Singleton, Stream, and Teardown Hardening (Round 15) + +**Status:** Design approved. Ready for implementation plan. + +**Scope decision (user):** Fix all 8 code findings (#1–#8). Finding #9 (Python-binding scope) is left as-is with a PR note, not a code change. Full pipeline (spec → plan → SDD). Standing finish: push + update PR #157. + +**Reviewed head:** `7017ded` (round-14 HEAD). All 8 code findings validated against live source before this design. + +--- + +## Context + +`docs/pr-157-follow-up-code-review-6.md` raised 9 findings against PR #157 head `7017ded`. Eight are code fixes; #9 is a scope/process observation (the PR carries broad Python-binding modernization beyond the Node multi-engine change) handled by a PR comment, not code. + +The findings fall into three clusters plus one process note: + +- **Cluster A (TypeScript, 2 High):** a real user-facing singleton-poisoning bug (#1) and a real stream-hang bug (#2). +- **Cluster B (C teardown, 2 Medium):** two hardening gaps (#3, #4) in the round-14 teardown machinery. +- **Cluster C (C teardown design, 1 Medium):** the drain-reachability gap (#5) that round 14's own final reviewer flagged as a non-blocking observation. +- **Cluster D (tests, 2 Medium + 1 Low):** test-hygiene fixes (#6, #7, #8) that keep the suite honest. + +**Preserved invariant (unchanged from round 14, binding on every C change here):** +`g_ref_count == Σ per-env init_refs` at every `g_mutex` release. `g_teardown_needed` is a retry SIGNAL, not a reference — set only when `g_ref_count == 0` and the isolate is still live; never added to any count; read/written only under `g_mutex`. + +**Global constraints (carried from prior rounds):** +- Node-binding only. Never touch `native-lib/python/**`, the Java side, or the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`, `dw_napi_run_script`, `ScriptRuntime.getInstance()`). +- Handle width stays C `long long`. +- Errors surface as resolved JSON strings (async) or synchronous `napi_throw_error` / thrown `DataWeaveError` — never `napi_reject_deferred`. +- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread; no env-affine napi call from the wrong thread. + +--- + +## Cluster A — TypeScript user-facing bugs + +### #1 (High): a failed first module-level initialization permanently poisons the singleton + +**Defect:** `getGlobalInstance()` (`native-lib/node/src/dataweave.ts:366-372`) assigns `globalInstance` *before* `initialize()` succeeds: + +```ts +function getGlobalInstance(): DataWeave { + if (!globalInstance) { + globalInstance = new DataWeave(); + globalInstance.initialize(); // if this throws, globalInstance stays set-but-uninitialized + registerExitHooksOnce(); + } + return globalInstance; +} +``` + +If `initialize()` throws (bad `DATAWEAVE_NATIVE_LIB` path, transient native failure), the singleton remains a non-null, uninitialized `DataWeave`. Every later `run*()` reuses it and fails only with "not initialized" — even after the underlying cause is fixed. + +**Fix:** construct and initialize a *local candidate*; assign `globalInstance` only after `initialize()` returns; register exit hooks after the successful assignment. + +```ts +function getGlobalInstance(): DataWeave { + if (!globalInstance) { + // Initialize a LOCAL candidate first; publish the singleton only after + // initialize() succeeds. A failed first init (bad lib path / transient + // native failure) must NOT leave a poisoned, uninitialized singleton that + // makes every later run*() fail "not initialized" even after the fault is + // fixed (review #6 #1). On throw, globalInstance stays null and the next + // call retries cleanly. + const candidate = new DataWeave(); + candidate.initialize(); + globalInstance = candidate; + registerExitHooksOnce(); + } + return globalInstance; +} +``` + +**Regression:** fail singleton init once (mock `ffi.initialize` to throw), assert the call rejects/throws and `globalInstance` was not published; then correct the fault (mock initialize to succeed) and assert the next `run()` builds a fresh working singleton. + +### #2 (High): a rejected native streaming promise can hang the consumer forever + +**Defect:** `streamFromNative()` (`native-lib/node/src/stream.ts:39-47`) wires only the fulfilled branch: + +```ts +const metaPromise = start(chunkCb).then((raw) => { + metaRaw = raw; + done = true; + while (pendingResolves.length > 0) { + const resolve = pendingResolves.shift(); + if (resolve) resolve(); + } +}); +``` + +If `start()` rejects, `done` never becomes `true` and parked `next()` consumers (waiting on a `pendingResolves` promise, stream.ts:55) are never woken → the generator hangs forever. The rejection is also unhandled. + +**Fix:** handle both settlement branches — on rejection, record the error, set completion, wake all waiters. After the drain loop, if a start error was recorded, throw it (so the consumer sees a rejection, not a silent empty completion). Buffered chunks that arrived before the rejection still drain first. + +```ts + let startError: unknown; + const wakeAll = () => { + while (pendingResolves.length > 0) { + const resolve = pendingResolves.shift(); + if (resolve) resolve(); + } + }; + const metaPromise = start(chunkCb).then( + (raw) => { metaRaw = raw; done = true; wakeAll(); }, + (err) => { + // Native start() rejected. Without this branch, `done` stays false and a + // consumer parked in next() is never woken -> the generator hangs forever, + // and the rejection is unhandled (review #6 #2). Record the failure, mark + // completion, and wake every waiter; the error is re-thrown after draining + // any chunks that arrived before the rejection. + startError = err; + done = true; + wakeAll(); + } + ); + + while (true) { + if (chunks.length > 0) { yield chunks.shift()!; continue; } + if (done) break; + await new Promise((resolve) => { pendingResolves.push(resolve); }); + } + + while (chunks.length > 0) { yield chunks.shift()!; } + + await metaPromise; // settles (fulfilled) since we handled rejection above + if (startError !== undefined) throw startError; + return parseStreamingResult(metaRaw ?? ""); +``` + +Note: because the `.then(onFulfilled, onRejected)` form handles rejection, `metaPromise` itself always fulfills, so `await metaPromise` never throws and there is no unhandled rejection. The consumer-visible error is the explicit `throw startError`. + +**Regression:** `start: () => Promise.reject(new Error("native start boom"))` with a consumer that is already parked in `next()` before the rejection settles — assert `next()` (or the `for await`) rejects with the error and does not hang. A second test: chunks buffered then rejection — assert buffered chunks yield first, then it throws. + +--- + +## Cluster B — C teardown hardening + +### #3 (Medium): isolate teardown reports success even when Graal teardown fails + +**Defect:** both teardown sites treat calling `graal_tear_down_isolate()` as success without checking its `int` return (`typedef int (*graal_tear_down_isolate_fn)(void*)`, addon.c:12): + +- `cleanup_thread_fn` (addon.c:2332): `fn_tear_down_isolate(local_thread); *out_torn_down = 1;` +- `teardown_waiter_thread_fn` (addon.c:2366-2368): `fn_tear_down_isolate(local_thread); torn_down = true;` (comment at 2360 literally says "Ignore the return code, matching today's behavior.") + +If teardown returns nonzero, the callers still clear `g_isolate`/`g_initialized`/`g_ref_count` as if the isolate is gone — orphaning a live isolate and allowing a *second* `graal_create_isolate` in the same process (unsupported). + +**Fix:** set `torn_down` only when the call returns 0. + +- `cleanup_thread_fn`: + ```c + *out_torn_down = (fn_tear_down_isolate(local_thread) == 0) ? 1 : 0; + // Nonzero: teardown failed, isolate still live -- leave *out_torn_down = 0 so + // the caller retains g_isolate/g_initialized and arms the retry (review #6 #3). + ``` +- `teardown_waiter_thread_fn`: + ```c + torn_down = (fn_tear_down_isolate(local_thread) == 0); + ``` + Update the stale comment at 2360. + +The existing "attach failed → leave torn_down 0" paths already handle the retained-live-isolate case correctly; #3 just extends that to the "attach succeeded but teardown returned nonzero" case. Arming the retry on a nonzero teardown is handled together with #4 below (both are in `teardown_waiter_thread_fn`'s post-teardown block). + +### #4 (Medium): async teardown-waiter attach failure leaves an ownerless isolate without retry + +**Defect:** in `teardown_waiter_thread_fn`'s post-teardown lock (addon.c:2379-2389), when `!cancelled && !torn_down` (attach failed, or — after #3 — teardown returned nonzero), the code leaves `g_ref_count == 0`, no owner, no pending waiter, `g_teardown_state = TEARDOWN_NONE`, and does *not* arm `g_teardown_needed`. The comment claims "retried on the next last release" — but this async waiter path IS the last-release path (`isolate_ref_release_n_locked`'s `g_active_ops > 0` branch spawned it). There is no future last-release; the isolate is stranded with no retry signal. + +**Fix:** in that post-teardown block, when teardown did not happen and the isolate is still live with zero owners, arm the retry signal: + +```c + uv_mutex_lock(&g_mutex); + if (!cancelled && torn_down) { + g_thread = NULL; + g_isolate = NULL; + g_initialized = 0; + g_ref_count = 0; + } else if (!cancelled && g_isolate != NULL && g_ref_count == 0) { + // Teardown did not happen (attach failed, or graal_tear_down_isolate + // returned nonzero -- review #6 #3) and this async-waiter path IS the + // last release: g_ref_count is already 0 with no owner and no pending + // waiter. Arm the retry signal so a later op-completion drain or an + // initialize() retries teardown -- otherwise the live isolate is stranded + // with nothing to reclaim it (review #6 #4). + g_teardown_needed = true; + } + g_teardown_state = TEARDOWN_NONE; + g_teardown_cancelled = false; + ... +``` + +This mirrors the arm already present in `isolate_ref_release_n_locked`'s waiter-spawn-failure path (addon.c:2570) and Case-4 sync-failure path. + +--- + +## Cluster C — the #5 drain-reachability gap + +### #5 (Medium): stranded-teardown retry is not guaranteed to run when no operation remains + +**Defect:** the zero-active-op synchronous failure paths arm `g_teardown_needed`: +- `isolate_ref_release_n_locked` sync branch (addon.c:2543-2548) +- `release_isolate_ref_locked` Case-4 (addon.c:2725) + +But `retry_stranded_teardown_locked()` is called ONLY from the streaming (addon.c:967) and transform op-completion drains. In the zero-op state there is no pending operation to drain, so the retry never fires. Worse, a later `initialize()` currently *adopts* the isolate and clears the flag (the fast-path / adoption clears at addon.c:623/643/724) instead of completing the pending teardown. `cleanup()` has already resolved, so from the caller's view the reference was released — but the isolate the retry was meant to reclaim is silently kept alive and its retry intent discarded. + +**Chosen fix (user decision): make the next `initialize()` complete the pending teardown before adopting — no new async infrastructure.** + +At the top of `napi_initialize`, under `g_mutex`, before the existing adoption / fast-path / create-path logic: if `g_teardown_needed` is set (a prior teardown failed and the isolate is stranded with zero owners), call `retry_stranded_teardown_locked()` first. + +- If the retry succeeds, `g_isolate` becomes `NULL` and `g_initialized` becomes 0 → `napi_initialize` falls through to the create path and builds a fresh isolate. The pending teardown is honored, not discarded. +- If the retry fails again (spawn/attach/teardown still failing), the isolate is still live; `napi_initialize` proceeds to adopt it via the existing fast path (which clears the now-still-set flag). Adopting a live isolate whose teardown was merely resource-reclamation (not a malfunction) is safe and functionally identical to normal adoption. + +```c + uv_mutex_lock(&g_mutex); + // A prior last-release could not tear the isolate down and armed the retry + // signal (review #6 #3/#4). Because retries otherwise fire only at op + // completion, a zero-op stranded isolate would never be reclaimed and a naive + // adoption below would silently discard the pending teardown (review #6 #5). + // Drive the pending teardown to completion here first: on success g_isolate is + // cleared and we build a fresh isolate below; on repeated failure the live + // isolate is adopted by the fast path (safe -- teardown was reclamation, not a + // malfunction). + retry_stranded_teardown_locked(); + // ... existing TEARDOWN_PENDING_WAIT adoption / fast-path / create-path logic ... +``` + +`retry_stranded_teardown_locked()` already no-ops safely when `g_teardown_needed` is false, when `g_active_ops > 0`, or when a teardown is in progress — so this call is a cheap guard on the common path (flag clear → immediate return). + +**Documented residual degradation (accepted):** if a teardown fails AND no later `initialize()` or streaming/transform op ever occurs, the stranded isolate lingers until process exit, where the OS reclaims it. This is benign (a single process-lifetime isolate, no correctness or reference-count violation) and is the deliberate tradeoff for avoiding event-loop-affine async retry infrastructure on this concurrency-sensitive code. This residual is documented in a comment at the arming sites and in the spec's Rejected Alternatives. + +--- + +## Cluster D — test hardening + +### #6 (Medium): Worker clean-lifecycle scenarios suppress explicit engine-destruction errors + +**Defect:** in `runWorker`'s worker body (`native-lib/node/tests/integration/worker-lifecycle.test.ts:62-65`), the `cleanup: true` path swallows `destroyEngine` errors: + +```js +if (workerData.cleanup) { + try { addon.destroyEngine(handle); } catch (_) {} + await addon.cleanup(); +} +``` + +A broken explicit-destruction path can be masked by the subsequent `addon.cleanup()`, so a "clean lifecycle" test still passes. + +**Fix:** capture the destruction error, still run `addon.cleanup()` in a `finally`, then fold the original error into the posted message (so the stricter `runWorker` exit handling and the caller's `msg.ok` assertion surface it): + +```js +if (workerData.cleanup) { + let destroyErr; + try { + addon.destroyEngine(handle); + } catch (e) { + destroyErr = e; // preserve; do NOT let cleanup() mask a broken destroy path + } finally { + await addon.cleanup(); + } + if (destroyErr) msg = { ok: false, error: "destroyEngine failed: " + String(destroyErr) }; +} +``` + +This keeps all existing clean-path Workers green (destroy succeeds → `destroyErr` undefined → `msg` unchanged) while surfacing a real destruction failure as `ok: false`. + +### #7 (Medium): the cross-env regression can contaminate later tests on failure + +**Defect:** the round-14 cross-env test (`worker-lifecycle.test.ts:209-272`) acquires `hMain` and a main-thread init reference with no `try/finally`. Any Worker or assertion failure before the final `destroyEngine(hMain)` + `cleanup()` leaves global native state (live isolate, held reference) for subsequent tests. + +**Fix:** wrap the test body in `try/finally`. In `finally`, destroy `hMain` if it was acquired and balance the main init reference (`await ffi.cleanup()`), guarded so the balancing does not throw over and mask an original assertion failure: + +```ts + let hMain: number | null = null; + try { + ffi.initialize(LIB_PATH); + hMain = ffi.createEngine(); + // ... existing test body, using hMain ... + } finally { + // Balance global native state even if a Worker/assertion failed above, so + // this test cannot strand a live isolate + held reference for sibling + // integration tests (review #6 #7). Do not let cleanup errors mask the + // original failure. + try { + if (hMain !== null) ffi.destroyEngine(hMain); + await ffi.cleanup(); + } catch { /* balancing best-effort; original failure (if any) propagates */ } + } +``` + +The final positive assertions (main engine survives; raw op throws `/not initialized/i` after balancing) stay in the `try` so the test still proves what it did before; only the reference balancing moves to `finally`. Because the `finally` now always balances, the `/not initialized/i` probe must run inside `try` *before* the finally's cleanup (it already does — it is the last positive step of the body). The RED-on-round-12 behavior is unchanged: the main-engine survival assertion still fails on round-12. + +### #8 (Low): the initialization unit test can falsely pass when reinitialization is a no-op + +**Defect:** `dataweave-initialize.test.ts:248-252` asserts a second `initialize()` via `toHaveBeenLastCalledWith()`, but the *first* `initialize()` already called `createEngine()` with the same (no) arguments — so the assertion passes even if the second init created no engine. + +**Fix:** clear the `createEngine` mock before the re-initialization (`vi.mocked(ffi.createEngine).mockClear()`), or assert the call count went from 1 to 2. The design uses `mockClear()` before the second `initialize()` plus `expect(ffi.createEngine).toHaveBeenCalledTimes(1)` after, proving the re-init genuinely created a fresh engine. + +--- + +## File / task structure + +Each task ends with an independently testable deliverable and a fresh reviewer gate. + +| Task | Finding(s) | Files | Test | +|------|-----------|-------|------| +| 1 | #1 | `src/dataweave.ts` (`getGlobalInstance`) | `tests/unit/dataweave-initialize.test.ts` (+1) | +| 2 | #2 | `src/stream.ts` (`streamFromNative`) | `tests/unit/stream.test.ts` (+2) | +| 3 | #3, #4 | `src/addon.c` (`cleanup_thread_fn`, `teardown_waiter_thread_fn`) | error-path C hardening; suite unchanged | +| 4 | #5 | `src/addon.c` (`napi_initialize`) | error-path C hardening; suite unchanged | +| 5 | #6, #7 | `tests/integration/worker-lifecycle.test.ts` | suite unchanged (all green paths still pass) | +| 6 | #8 | `tests/unit/dataweave-initialize.test.ts` | tightened assertion; suite unchanged | + +**Task ordering rationale:** +- Tasks 1 and 6 both touch `dataweave-initialize.test.ts`; Task 1 *appends* a new test, Task 6 *tightens an existing* test — no overlap, but Task 6 runs after Task 1 to avoid a stale line-anchor. +- Tasks 3 and 4 both touch `addon.c` teardown machinery; 3 (thread-fn return codes + arm) precedes 4 (`napi_initialize` drives the retry), since 4's fix relies on 3's arming being correct. +- Task 5's two changes (#6, #7) are in one file and reviewed together. + +**Expected suite deltas:** Task 1 +1 unit, Task 2 +2 unit; Tasks 3–6 no count change (error-path C hardening + test tightening). Round-14 baseline 899/59/0 → **902/59/0** after this round. + +--- + +## Verification (end-to-end) + +1. `cd native-lib/node && npm run build:addon` — clean, no new warnings in `cleanup_thread_fn`, `teardown_waiter_thread_fn`, or `napi_initialize`. `npm run build:ts` clean. +2. `npm test` (with `DATAWEAVE_NATIVE_LIB` set) — **902 passed / 59 skipped / 0 failed**. +3. **Invariant audit (review gate):** every `g_ref_count` mutation still paired with an `init_refs` mutation or a rollback to `env_init_refs_total_locked()`/0; `g_teardown_needed` set only when `g_ref_count == 0`, never added to a count; all new shared-state access under `g_mutex`. The #3 return-code check must never clear `g_isolate`/`g_initialized`/`g_ref_count` on a nonzero teardown. +4. #1/#2 regressions genuinely reproduce the bug (fail on the pre-fix code): singleton stays poisoned; stream hangs/rejects unhandled. +5. `git diff --check` — no whitespace errors. + +--- + +## Rejected Alternatives + +- **#1: reset `globalInstance = null` in a `catch` inside `getGlobalInstance` instead of a local candidate.** Works, but the local-candidate pattern is clearer (the singleton is never observably set to a bad value, even transiently across an `await` boundary elsewhere) and matches the "construct-then-publish" idiom the reviewer requested. +- **#2: reject via `napi_reject_deferred` / a rejected returned promise from the generator.** The generator contract is to throw from `next()`; the codebase deliberately surfaces errors as thrown values, not rejected deferreds (global constraint). An explicit `throw startError` after draining is the idiomatic fit. +- **#5: dedicated async retry owner (uv_async / uv_timer).** Fully closes the no-future-init-or-op residual, but adds event-loop-affine async infrastructure and new concurrency surface to the most sensitive code in the binding. The user chose init-driven completion + documented degradation as the lower-risk option; the residual (isolate lingers to process exit if nothing else ever happens) is benign. +- **#5: block `napi_initialize` until the pending teardown physically completes on a helper thread even when it keeps failing.** Could deadlock or spin on a persistently failing `graal_tear_down_isolate`; adopting the live isolate after one retry attempt is safe and bounded. +- **#9: split the Python-binding work into its own PR now.** User chose to leave the PR as-is and note the bundling in a PR comment; no git surgery this round. From ceeb66fbdb8bc694729e3cf7b87cc9495e87d6a0 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 16:10:50 -0300 Subject: [PATCH 105/216] chore: stop tracking local scratch docs (review notes + ga-cleanup backlog) PR follow-up code-review notes and the GA cleanup backlog are local working notes, not deliverables. Add gitignore rules and untrack ga-cleanup-backlog.md (local copy retained). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 +++ docs/ga-cleanup-backlog.md | 73 -------------------------------------- 2 files changed, 5 insertions(+), 73 deletions(-) delete mode 100644 docs/ga-cleanup-backlog.md diff --git a/.gitignore b/.gitignore index d80e42e8..5b69bc8e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ grimoires/ # Superpowers implementation plans are local scratch artifacts, never commit them. /docs/superpowers/plans/ /docs/superpowers/plans/**/*.md + +# PR follow-up code-review notes are local scratch, keep them untracked. +/docs/pr-*-follow-up-*code-review*.md +# GA cleanup backlog is a local working note, keep it untracked. +/docs/ga-cleanup-backlog.md diff --git a/docs/ga-cleanup-backlog.md b/docs/ga-cleanup-backlog.md deleted file mode 100644 index 9322c0de..00000000 --- a/docs/ga-cleanup-backlog.md +++ /dev/null @@ -1,73 +0,0 @@ -# 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 18e82fa4fea81b2fb544d7e882d5febcbf6ef49a Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 16:35:08 -0300 Subject: [PATCH 106/216] fix(node): construct-then-publish module singleton (review #6 #1) A failed first getGlobalInstance() init previously left a poisoned, uninitialized singleton that made every later run*() fail. Build+init a local candidate and publish only on success. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 11 +++++-- .../tests/unit/dataweave-initialize.test.ts | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 5f8fe057..3475f769 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -368,8 +368,15 @@ function registerExitHooksOnce(): void { */ function getGlobalInstance(): DataWeave { if (!globalInstance) { - globalInstance = new DataWeave(); - globalInstance.initialize(); + // Initialize a LOCAL candidate first; publish the singleton only after + // initialize() succeeds. A failed first init (bad DATAWEAVE_NATIVE_LIB + // path / transient native failure) must NOT leave a poisoned, uninitialized + // singleton that makes every later run*() fail "not initialized" even after + // the fault is fixed (review #6 #1). On throw, globalInstance stays null and + // the next call retries cleanly with a fresh instance. + const candidate = new DataWeave(); + candidate.initialize(); + globalInstance = candidate; registerExitHooksOnce(); } return globalInstance; diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index d2782770..d56944ed 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -251,4 +251,35 @@ describe("DataWeave.initialize() native ref-count safety", () => { dw.initialize(); expect(ffi.createEngine).toHaveBeenLastCalledWith(); }); + + it("does not publish a poisoned singleton when the first module-level init fails", async () => { + // Isolate module state: a fresh import gives a null globalInstance so this + // test controls the very first getGlobalInstance() call. + vi.resetModules(); + const ffiMod = await import("../../src/ffi"); + const dwMod = await import("../../src/dataweave"); + + // First module-level run(): ffi.initialize() throws (e.g. bad lib path). + vi.mocked(ffiMod.initialize).mockImplementationOnce(() => { + throw new Error("library not found"); + }); + expect(() => dwMod.run("%dw 2.0\noutput application/json\n---\n1")).toThrow(); + + // The fault is corrected; the NEXT module-level run() must build a fresh, + // working singleton -- not reuse a poisoned, uninitialized one that fails + // "not initialized" forever (review #6 #1). + vi.mocked(ffiMod.initialize).mockImplementation(() => {}); + vi.mocked(ffiMod.createEngine).mockReturnValue(1); + vi.mocked(ffiMod.runScriptEngine).mockReturnValue( + JSON.stringify({ + success: true, + result: Buffer.from("1").toString("base64"), + mimeType: "application/json", + charset: "utf-8", + binary: false, + }) + ); + const result = dwMod.run("%dw 2.0\noutput application/json\n---\n1"); + expect(result.success).toBe(true); + }); }); From d85a266b227b3f3db4bd7eab96e3f7bcfd3df313 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 16:43:25 -0300 Subject: [PATCH 107/216] fix(node): surface rejected native stream start instead of hanging (review #6 #2) streamFromNative only wired the fulfilled branch of start(); a rejection left done=false so parked consumers hung forever and the rejection was unhandled. Handle both branches: wake all waiters, drain buffered chunks, then throw the start error. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/stream.ts | 23 +++++++++++---- native-lib/node/tests/unit/stream.test.ts | 36 +++++++++++++++++++++-- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/native-lib/node/src/stream.ts b/native-lib/node/src/stream.ts index 855807f6..5f4dadaa 100644 --- a/native-lib/node/src/stream.ts +++ b/native-lib/node/src/stream.ts @@ -36,15 +36,25 @@ export async function* streamFromNative( } }; - const metaPromise = start(chunkCb).then((raw) => { - metaRaw = raw; - done = true; - // Wake all waiting consumers + let startError: unknown; + const wakeAll = () => { while (pendingResolves.length > 0) { const resolve = pendingResolves.shift(); if (resolve) resolve(); } - }); + }; + + // Handle BOTH settlement branches. Without the rejection handler, a rejected + // start() leaves `done` false forever: a consumer parked in next() below is + // never woken and the generator hangs, and the rejection is unhandled + // (review #6 #2). On rejection we record the error, mark completion, and wake + // every waiter; the error is re-thrown after draining any chunks that arrived + // before the rejection. Because we handle rejection here, metaPromise itself + // always fulfills -- `await metaPromise` below never throws. + const metaPromise = start(chunkCb).then( + (raw) => { metaRaw = raw; done = true; wakeAll(); }, + (err) => { startError = err; done = true; wakeAll(); } + ); while (true) { if (chunks.length > 0) { @@ -55,11 +65,12 @@ export async function* streamFromNative( await new Promise((resolve) => { pendingResolves.push(resolve); }); } - // Drain remaining chunks + // Drain remaining chunks buffered before completion/rejection. while (chunks.length > 0) { yield chunks.shift()!; } await metaPromise; + if (startError !== undefined) throw startError; return parseStreamingResult(metaRaw ?? ""); } \ No newline at end of file diff --git a/native-lib/node/tests/unit/stream.test.ts b/native-lib/node/tests/unit/stream.test.ts index 46ca0e4a..6e71c677 100644 --- a/native-lib/node/tests/unit/stream.test.ts +++ b/native-lib/node/tests/unit/stream.test.ts @@ -20,8 +20,9 @@ async function collect( function deferred() { let resolve!: (v: T) => void; - const promise = new Promise((res) => { resolve = res; }); - return { promise, resolve }; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; } describe("streamFromNative", () => { @@ -95,4 +96,35 @@ describe("streamFromNative", () => { expect(result.success).toBe(false); expect(result.error).toBe("Empty response"); }); + + it("rejects a parked consumer when native start() rejects (no hang)", async () => { + const startGate = deferred(); + const gen = streamFromNative(() => startGate.promise); + + // Park a consumer in next() BEFORE the start promise settles: no chunk is + // ready and done is false, so next() awaits on pendingResolves. + const pending = gen.next(); + + // Now reject the native start. The parked consumer must be woken and see a + // rejection -- on the pre-fix code done never flips and this hangs forever. + startGate.reject(new Error("native start boom")); + + await expect(pending).rejects.toThrow("native start boom"); + }); + + it("drains buffered chunks, then throws, when start() rejects after pushing chunks", async () => { + const gen = streamFromNative((cb) => { + cb(Buffer.from("x")); + cb(Buffer.from("y")); + return Promise.reject(new Error("late boom")); + }); + + // Buffered chunks yield first... + const a = await gen.next(); + const b = await gen.next(); + expect([a.value?.toString(), b.value?.toString()]).toEqual(["x", "y"]); + + // ...then the drained generator surfaces the start error. + await expect(gen.next()).rejects.toThrow("late boom"); + }); }); \ No newline at end of file From 827fce15918ba8d12174c0cf0b88aa4ded4fdfc7 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 16:51:25 -0300 Subject: [PATCH 108/216] fix(node): honor graal teardown return code + arm async-waiter strand (review #6 #3/#4) cleanup_thread_fn and teardown_waiter_thread_fn treated a nonzero graal_tear_down_isolate as success, orphaning a live isolate. Set torn_down only on a 0 return. In the async-waiter last-release path, arm g_teardown_needed when teardown did not happen and the isolate is stranded with zero owners, instead of leaving it with no retry. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 670542f0..25c07e38 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2329,8 +2329,11 @@ static void cleanup_thread_fn(void* arg) { // or it becomes unreachable and can never be torn down. return; } - fn_tear_down_isolate(local_thread); - *out_torn_down = 1; + // Check the teardown return code (0 == success). On nonzero the isolate is + // still live: leave *out_torn_down at 0 so the caller retains + // g_isolate/g_initialized/g_ref_count and (per its own logic) arms the retry, + // rather than orphaning a live isolate (review #6 #3). + *out_torn_down = (fn_tear_down_isolate(local_thread) == 0) ? 1 : 0; } // Spawned only when napi_cleanup finds g_active_ops > 0 on the last release @@ -2357,15 +2360,18 @@ static void teardown_waiter_thread_fn(void* arg) { // 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. Skipped entirely + // down. Honor the return code (0 == success); a nonzero teardown leaves the + // isolate live (review #6 #3). Skipped entirely // when an initialize() call adopted the live isolate instead (see // napi_initialize's TEARDOWN_PENDING_WAIT branch). bool torn_down = false; 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); - torn_down = true; + // Check the teardown return code (0 == success). On nonzero the isolate is + // still live -- leave torn_down false so the post-teardown block below + // retains the isolate globals and arms the retry (review #6 #3). + torn_down = (fn_tear_down_isolate(local_thread) == 0); } // else: attach failed -- the isolate is still alive. Do NOT clear g_isolate, // or it becomes unreachable and can never be torn down. @@ -2382,11 +2388,19 @@ static void teardown_waiter_thread_fn(void* arg) { g_isolate = NULL; g_initialized = 0; g_ref_count = 0; + } else if (!cancelled && g_isolate != NULL && g_ref_count == 0) { + // Teardown did not happen (attach failed, or graal_tear_down_isolate + // returned nonzero -- review #6 #3) and this async-waiter path IS the last + // release: g_ref_count is already 0 with no owner and no pending waiter. + // Arm the retry signal so a later op-completion drain or a fresh + // initialize() retries teardown -- otherwise the live isolate is stranded + // with nothing to reclaim it (review #6 #4). Mirrors the twin arm in + // isolate_ref_release_n_locked's waiter-spawn-failure path. + g_teardown_needed = true; } // 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. + // isolate). g_teardown_state = TEARDOWN_NONE; g_teardown_cancelled = false; // Release any initialize() call blocked waiting for teardown to finish From ee92f27c451a10df38d1be3258966814bc8b4d31 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 17:01:22 -0300 Subject: [PATCH 109/216] fix(node): complete a stranded teardown from the next initialize() (review #6 #5) Zero-op teardown-failure paths armed g_teardown_needed, but retries fire only at op completion -- with no pending op the isolate was never reclaimed and a naive adoption discarded the pending teardown. Call retry_stranded_teardown_locked() at the top of napi_initialize so a pending teardown is completed (or retried) before adopt/create. Document the residual: with no later op or init, the isolate lingers to process exit (OS reclaims it). Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 25c07e38..57135032 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -595,6 +595,17 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { uv_mutex_lock(&g_mutex); + // A prior last-release could not tear the isolate down and armed the retry + // signal (review #6 #3/#4). Because retries otherwise fire only at op + // completion (the streaming/transform drains), a zero-op stranded isolate + // would never be reclaimed and the adoption/fast paths below would silently + // discard the pending teardown (review #6 #5). Drive the pending teardown to + // completion here first: on success g_isolate/g_initialized are cleared and we + // build a fresh isolate below; on repeated failure the live isolate is adopted + // by the fast path (safe -- the teardown was resource reclamation, not a + // malfunction). No-ops cheaply when nothing is stranded (flag clear -> return). + retry_stranded_teardown_locked(); + // 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 @@ -2558,7 +2569,10 @@ static void isolate_ref_release_n_locked(int n) { // Sync teardown failed (spawn or cleanup_thread_fn attach) with the isolate // still live and no owners: arm the retry signal (round-14 #3). g_active_ops // is already 0 here, but a later op could still re-pin; the flag is cleared - // on adoption and retried on drain. + // on adoption and retried on drain or by the next initialize() (review #6 + // #5). Documented residual: if NO later op or initialize() ever occurs, the + // isolate lingers until process exit, where the OS reclaims it -- benign + // (single process-lifetime isolate, no ref-count violation). g_teardown_needed = true; } return; @@ -2733,9 +2747,11 @@ static napi_value release_isolate_ref_locked(napi_env env) { g_ref_count = 0; } else if (g_isolate != NULL && g_ref_count == 0) { // cleanup_thread_fn spawn/attach failed: the isolate is still live with - // zero owners. Arm the retry signal so a later op-completion drain (or a - // fresh initialize() adoption) tears it down instead of stranding it — - // mirrors the twin arm in isolate_ref_release_n_locked. + // zero owners. Arm the retry signal so a later op-completion drain or the + // next initialize() (review #6 #5) tears it down instead of stranding it — + // mirrors the twin arm in isolate_ref_release_n_locked. Documented residual: + // if no later op or initialize() ever runs, the isolate lingers to process + // exit (OS reclaims it) -- benign, no ref-count violation. g_teardown_needed = true; } uv_mutex_unlock(&g_mutex); From 3e81b488d66e79e2fb87bcbbd3f7b449539b2696 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 17:07:55 -0300 Subject: [PATCH 110/216] test(node): surface worker destroy errors + isolate cross-env test state (review #6 #6/#7) The cleanup:true worker path swallowed destroyEngine errors, letting a broken destroy pass as a clean lifecycle; fold the error into the posted message with cleanup() still in finally. Wrap the cross-env test in try/finally so a mid-test failure cannot strand a live isolate + held reference for sibling tests. Co-Authored-By: Claude Sonnet 5 --- .../integration/worker-lifecycle.test.ts | 142 ++++++++++-------- 1 file changed, 81 insertions(+), 61 deletions(-) diff --git a/native-lib/node/tests/integration/worker-lifecycle.test.ts b/native-lib/node/tests/integration/worker-lifecycle.test.ts index 4d5b887c..b5c757e4 100644 --- a/native-lib/node/tests/integration/worker-lifecycle.test.ts +++ b/native-lib/node/tests/integration/worker-lifecycle.test.ts @@ -60,8 +60,15 @@ function runWorker(opts: { msg = { ok: false, error: String(e) }; } if (workerData.cleanup) { - try { addon.destroyEngine(handle); } catch (_) {} - await addon.cleanup(); + let destroyErr; + try { + addon.destroyEngine(handle); + } catch (e) { + destroyErr = e; // preserve; do NOT let cleanup() mask a broken destroy + } finally { + await addon.cleanup(); + } + if (destroyErr) msg = { ok: false, error: 'destroyEngine failed: ' + String(destroyErr) }; } parentPort.postMessage(msg); // For the abandon variant we deliberately return WITHOUT cleanup so the @@ -206,68 +213,81 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { // engine count, so the main engine survives. const N = 3; - // 1. Main thread: initialize and keep a live engine. - ffi.initialize(LIB_PATH); - const hMain = ffi.createEngine(); - const first = JSON.parse( - ffi.runScriptEngine(hMain, "%dw 2.0\noutput application/json\n---\n6 * 7", buildInputsJson({})) - ); - expect(first.success).toBe(true); - expect(JSON.parse(Buffer.from(first.result, "base64").toString("utf-8"))).toBe(42); + let hMain: number | null = null; + try { + // 1. Main thread: initialize and keep a live engine. + ffi.initialize(LIB_PATH); + hMain = ffi.createEngine(); + const first = JSON.parse( + ffi.runScriptEngine(hMain, "%dw 2.0\noutput application/json\n---\n6 * 7", buildInputsJson({})) + ); + expect(first.success).toBe(true); + expect(JSON.parse(Buffer.from(first.result, "base64").toString("utf-8"))).toBe(42); - // 2. Worker: initialize ONCE, create N engines, run one, exit WITHOUT cleanup. - const workerBody = ` - const { parentPort, workerData } = require('node:worker_threads'); - (async () => { - const addon = require(workerData.addonPath); - addon.initialize(workerData.libPath); // ONE init reference for this env - const handles = []; - for (let i = 0; i < workerData.n; i++) handles.push(addon.createEngine()); - const raw = addon.runScriptEngine(handles[0], workerData.script, '{}'); - const parsed = JSON.parse(raw); - parentPort.postMessage({ ok: parsed.success !== false, count: handles.length }); - // Return WITHOUT destroyEngine/cleanup: the env dies with N engines under - // one init reference -> env_init_cleanup releases exactly ONE reference. - })().catch((e) => { parentPort.postMessage({ ok: false, error: String(e) }); }); - `; - const workerMsg = await new Promise<{ ok: boolean; count?: number; error?: string }>((resolve, reject) => { - const w = new Worker(workerBody, { - eval: true, - workerData: { - addonPath: ADDON_PATH, - libPath: LIB_PATH, - n: N, - script: "%dw 2.0\noutput application/json\n---\n1 + 1", - }, + // 2. Worker: initialize ONCE, create N engines, run one, exit WITHOUT cleanup. + const workerBody = ` + const { parentPort, workerData } = require('node:worker_threads'); + (async () => { + const addon = require(workerData.addonPath); + addon.initialize(workerData.libPath); // ONE init reference for this env + const handles = []; + for (let i = 0; i < workerData.n; i++) handles.push(addon.createEngine()); + const raw = addon.runScriptEngine(handles[0], workerData.script, '{}'); + const parsed = JSON.parse(raw); + parentPort.postMessage({ ok: parsed.success !== false, count: handles.length }); + // Return WITHOUT destroyEngine/cleanup: the env dies with N engines under + // one init reference -> env_init_cleanup releases exactly ONE reference. + })().catch((e) => { parentPort.postMessage({ ok: false, error: String(e) }); }); + `; + const workerMsg = await new Promise<{ ok: boolean; count?: number; error?: string }>((resolve, reject) => { + const w = new Worker(workerBody, { + eval: true, + workerData: { + addonPath: ADDON_PATH, + libPath: LIB_PATH, + n: N, + script: "%dw 2.0\noutput application/json\n---\n1 + 1", + }, + }); + let msg: { ok: boolean; count?: number; error?: string } | undefined; + w.once("message", (m) => { msg = m; }); + w.once("error", reject); + // Wait for EXIT (not just message) so the Worker env's death hooks + // (env_init_cleanup) have run before we assert the main engine survived. + w.once("exit", (code) => { + if (code !== 0) reject(new Error("Worker exited with code " + code + (msg ? "" : " and posted no message"))); + else if (msg === undefined) reject(new Error("Worker exited 0 without posting a result")); + else resolve(msg); + }); }); - let msg: { ok: boolean; count?: number; error?: string } | undefined; - w.once("message", (m) => { msg = m; }); - w.once("error", reject); - // Wait for EXIT (not just message) so the Worker env's death hooks - // (env_init_cleanup) have run before we assert the main engine survived. - w.once("exit", (code) => { - if (code !== 0) reject(new Error("Worker exited with code " + code + (msg ? "" : " and posted no message"))); - else if (msg === undefined) reject(new Error("Worker exited 0 without posting a result")); - else resolve(msg); - }); - }); - expect(workerMsg.ok).toBe(true); - expect(workerMsg.count).toBe(N); + expect(workerMsg.ok).toBe(true); + expect(workerMsg.count).toBe(N); - // 3. The Worker abandoned N engines under one init reference and its env - // died. The main engine's reference must be intact and the isolate live. - const second = JSON.parse( - ffi.runScriptEngine(hMain, "%dw 2.0\noutput application/json\n---\n1 + 1", buildInputsJson({})) - ); - expect(second.success).toBe(true); - expect(JSON.parse(Buffer.from(second.result, "base64").toString("utf-8"))).toBe(2); + // 3. The Worker abandoned N engines under one init reference and its env + // died. The main engine's reference must be intact and the isolate live. + const second = JSON.parse( + ffi.runScriptEngine(hMain, "%dw 2.0\noutput application/json\n---\n1 + 1", buildInputsJson({})) + ); + expect(second.success).toBe(true); + expect(JSON.parse(Buffer.from(second.result, "base64").toString("utf-8"))).toBe(2); - // 4. Balance the main reference and prove the count reached exactly zero - // (no leak, no over-release): a raw op now throws "not initialized". - ffi.destroyEngine(hMain); - await ffi.cleanup(); - expect(() => - ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) - ).toThrow(/not initialized/i); + // 4. Balance the main reference and prove the count reached exactly zero + // (no leak, no over-release): a raw op now throws "not initialized". + ffi.destroyEngine(hMain); + hMain = null; // destroyed; finally must not double-destroy + await ffi.cleanup(); + expect(() => + ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) + ).toThrow(/not initialized/i); + } finally { + // Balance global native state even if a Worker/assertion above threw, so + // this test cannot strand a live isolate + held reference for sibling + // integration tests (review #6 #7). Best-effort: do not let a cleanup + // error mask the original failure. + try { + if (hMain !== null) ffi.destroyEngine(hMain); + await ffi.cleanup(); + } catch { /* original failure (if any) propagates from the try */ } + } }, 20000); }); From 15268e1799c33da61f00bbd3992c0f79694e82f8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 21 Aug 2026 17:26:33 -0300 Subject: [PATCH 111/216] test(node): assert reinitialization actually re-creates the engine (review #6 #8) toHaveBeenLastCalledWith() false-passed because the first init already called createEngine() with the same args. Clear the mock before the second init and assert it was called exactly once. Co-Authored-By: Claude Sonnet 5 --- native-lib/node/tests/unit/dataweave-initialize.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index d56944ed..eabc9538 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -248,8 +248,12 @@ describe("DataWeave.initialize() native ref-count safety", () => { // The instance is not stranded "ready": a later initialize() works. vi.mocked(ffi.destroyEngine).mockReset(); vi.mocked(ffi.createEngine).mockReturnValue(9); + vi.mocked(ffi.createEngine).mockClear(); // ignore the first init's call dw.initialize(); - expect(ffi.createEngine).toHaveBeenLastCalledWith(); + // Prove the re-init genuinely created a fresh engine (not a no-op that + // false-passes toHaveBeenLastCalledWith because the FIRST init already + // called createEngine() with the same args -- review #6 #8). + expect(ffi.createEngine).toHaveBeenCalledTimes(1); }); it("does not publish a poisoned singleton when the first module-level init fails", async () => { From c68bd3fd9241c3707fa9fd2232dec37cf959c0e0 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 14:41:33 -0300 Subject: [PATCH 112/216] docs: review #7 remediation design spec (W-23692110) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...wn-detach-rollback-doc-hardening-design.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-review7-teardown-detach-rollback-doc-hardening-design.md diff --git a/docs/superpowers/specs/2026-08-24-review7-teardown-detach-rollback-doc-hardening-design.md b/docs/superpowers/specs/2026-08-24-review7-teardown-detach-rollback-doc-hardening-design.md new file mode 100644 index 00000000..e649b370 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-review7-teardown-detach-rollback-doc-hardening-design.md @@ -0,0 +1,108 @@ +# Review #7 — Teardown-detach, init-rollback, and lifecycle-doc hardening (W-23692110) + +**Status:** Approved design. Reviewed head at review time: `aaeafb9`; live head at design time: `6fb5603` (post-rebase onto master). All findings re-verified against `6fb5603`; line numbers below are the live ones. + +**Reviewer:** `docs/pr-157-follow-up-code-review-7.md` (the "code-review" series, round 7). Eight findings. Seven are in scope this round (#1–#7); #8 (Python-scope split) is kept as the standing "leave-as-is, note in PR" decision and answered to the reviewer, not actioned in code. + +## Goal + +Close the seven code/documentation findings from review #7 without regressing the multi-engine lifecycle invariants established in rounds 1–15. Two are genuine native-lifecycle defects (a phantom attached GraalVM thread on failed teardown; an unsignaled init-wait after a failed init-hook rollback), one is a TypeScript rollback-observability defect, one is a low-severity stream mis-report, one is a test-hygiene gap, and two are documentation corrections. + +## Invariant (unchanged, preserved by every fix) + +`g_ref_count == Σ per-env init_refs` at every `g_mutex` release. `g_teardown_needed` is a **retry signal, not a reference** — set only when `g_ref_count == 0` and the isolate is still live; never added to any count; read/written only under `g_mutex`. Teardown state machine: `TEARDOWN_NONE` / `TEARDOWN_PENDING_WAIT` / `TEARDOWN_TEARING_DOWN`, all transitions under `g_mutex`. Errors surface as resolved JSON strings (async) or synchronous `napi_throw_error` / thrown `DataWeaveError` — never `napi_reject_deferred`. + +## Global Constraints (binding on every task) + +- Node-binding-only scope. 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()`. Java side not modified this round. +- Handle width stays C `long long` everywhere. +- Errors surface as resolved JSON strings (async) or synchronous `napi_throw_error` / thrown `DataWeaveError` — NEVER `napi_reject_deferred`. +- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread; no env-affine napi call from the waiter/wrong thread. +- All new shared state read/written only under `g_mutex`. +- `initialize()` and `run()` stay **synchronous** (returning `void` / `ExecutionResult`, not Promises) — an async signature is an API break and is a rejected alternative. +- `docs/superpowers/plans/` is git-ignored; only specs are tracked. Do not `git add -A` — untracked scratch docs under `docs/` must stay untracked; stage only named files. + +## Findings and chosen fixes + +### #1 (High) — failed Graal teardown leaves the cleanup thread attached to the live isolate + +**Where:** `native-lib/node/src/addon.c` — `cleanup_thread_fn` (~2337–2347) and `teardown_waiter_thread_fn` (~2379–2388). + +**Defect:** Both paths attach a local IsolateThread (`fn_attach_thread`), call `fn_tear_down_isolate(local_thread)`, and treat a nonzero return as failure (correctly, since review #6 #3) by leaving the isolate live and arming the retry. But on that failure branch they exit the helper thread **without detaching** `local_thread`. `graal_tear_down_isolate` does not tear down on a nonzero return, so the attachment is still live; exiting the OS thread while attached leaves a phantom attached thread in the isolate, which can make a later retry teardown block or fail indefinitely. + +**Fix:** On the nonzero-teardown branch **only**, call `fn_detach_thread(local_thread)` before leaving `torn_down` / `*out_torn_down` at 0. The success branch (return 0) is untouched — the isolate is gone and the thread must NOT be detached against a torn-down isolate. The attach-failure branch is untouched — no thread was attached. + +- `cleanup_thread_fn`: change `*out_torn_down = (fn_tear_down_isolate(local_thread) == 0) ? 1 : 0;` to capture the result, and on nonzero call `fn_detach_thread(local_thread)` before leaving `*out_torn_down` at 0. +- `teardown_waiter_thread_fn`: same shape around `torn_down = (fn_tear_down_isolate(local_thread) == 0);`. + +This does not change any state-machine transition, ref count, or the retry arming — it only reclaims the thread attachment on the already-existing failure path. + +### #2 (Medium) — init-hook failure can wedge all future initialization if compensating teardown fails + +**Where:** `native-lib/node/src/addon.c` `napi_initialize` (~682–726), the `if (!env_init_acquire_and_hook(env))` rollback block. + +**Defect:** When `env_init_acquire_and_hook` fails after the isolate was built, the code spawns `cleanup_thread_fn` to tear the just-built isolate back down. On success (`torn_down`) it clears `g_isolate`/`g_thread`/`g_initialized` — recoverable. But on the `else` branch (spawn failed, or attach/teardown failed) it leaves `g_isolate != NULL, g_initialized == 0, g_teardown_state == TEARDOWN_NONE`, and **no retry signal armed**. The next `initialize()` on any env reaches the wait loop condition `g_isolate != NULL && !g_initialized`, and with `TEARDOWN_NONE` it cannot take the adoption branch, so it falls into `uv_cond_wait(&g_teardown_cond, ...)` that nothing will ever broadcast → every future `initialize()` hangs forever. + +**Fix:** In that `else` branch, arm the retry signal: `g_teardown_needed = true;`. `retry_stranded_teardown_locked()` already runs at the very top of `napi_initialize` (~607, under `g_mutex`), so the next `initialize()` retries the stranded teardown before reaching the wait loop — either clearing the isolate (then building fresh) or, on repeated failure, adopting the still-live isolate via the fast path. This mirrors the identical arm already present in `teardown_waiter_thread_fn` (~2402–2410) and `isolate_ref_release_n_locked`'s waiter-spawn-failure path. Ref-count reasoning is unchanged: `g_ref_count` is still 0 here (we never bumped it), so arming `g_teardown_needed` (a signal, not a reference) does not perturb the invariant. + +### #3 (Medium) — module/instance initialization rollback starts async cleanup without observing it + +**Where:** `native-lib/node/src/dataweave.ts` `initialize()` (~92–105), the `catch` block calling `ffi.cleanup()`. + +**Defect:** When engine creation throws after `ffi.initialize()` succeeded, the catch calls `ffi.cleanup()` (which returns `Promise`) to release the native library ref, but neither awaits nor attaches a handler. A rollback-teardown rejection becomes an unhandledRejection, and a caller can immediately retry `initialize()`/`run()` while that rollback is still in flight — racing a fresh `graal_create_isolate` against the in-flight release. + +**Fix:** Model the rollback as pending state using the existing `state`/`cleanupPromise` machinery, keeping `initialize()` synchronous: +- Before throwing, set `this.state = "cleaning-up"` and assign `this.cleanupPromise` to the rollback promise: `ffi.cleanup()` wrapped so that when it settles the state returns to `"uninitialized"` and `cleanupPromise` clears (in a `.finally`), mirroring `doCleanup()`/`cleanup()`. +- Attach a `.catch(() => {})` to the stored promise so an un-awaited rollback never surfaces as an unhandledRejection. +- Because `state` is `"cleaning-up"` until the rollback settles, a concurrent `initialize()` hits the existing `"cleaning-up"` guard and throws "Cannot initialize while cleanup is in progress; await cleanup() first." — deterministic rejection instead of a race. A concurrent `cleanup()` coalesces onto the same `cleanupPromise` (existing behavior). +- The synchronous `throw new DataWeaveError(...)` to the *current* caller is preserved (the initialize attempt failed); the difference is the rollback is now observable and re-initialization is gated until it settles. + +This reuses the exact coalescing/observability contract the codebase already documents for `cleanup()`; no new field is required beyond reusing `cleanupPromise`. + +### #4 (Medium) — root native-lib README documents stale synchronous Node cleanup + +**Where:** `native-lib/README.md` §4 "Explicit instance lifecycle" (~460, `dw.cleanup()` with no await, no try/finally) and §9 "Cleanup" (~638–644, describes only a `process.on('exit')` hook and shows bare `cleanup()`). + +**Fix:** Update both examples to `await dw.cleanup()` / `await cleanup()` inside `try/finally`, and align the hook/lifecycle prose with the accurate package README (`native-lib/node/README.md`): the async `Promise` return, the `beforeExit` (awaits/drains) + `exit` (sync fallback) hook pair, that signals are not covered, and the last-reference teardown condition. Documentation-only; no code. + +### #5 (Medium) — class-level and package-README cleanup docs overstate teardown completion + +**Where:** `native-lib/node/src/dataweave.ts` `cleanup()` JSDoc (~109–119) and `native-lib/node/README.md:222`. + +**Defect:** Both say cleanup "resolves once the underlying native isolate has actually finished tearing down" unconditionally. That holds only when the call releases the **final** shared native reference; otherwise it resolves after releasing this instance's engine while the isolate stays live for other instances. + +**Fix:** State the final-reference condition explicitly, matching the wording already correct in the module-level `cleanup` doc (README.md:193): resolves after isolate teardown only when releasing the last initialized instance; otherwise resolves as soon as this instance is released. Documentation/JSDoc only. + +### #6 (Low) — native stream rejection of `undefined` is misreported as an empty response + +**Where:** `native-lib/node/src/stream.ts` (~39 `let startError: unknown;`, ~54–57 the two-arg `.then`, ~74 `if (startError !== undefined) throw startError;`). + +**Defect:** `startError !== undefined` is the rejection sentinel. `Promise.reject(undefined)` is valid JS, so a native `start()` that rejects with literal `undefined` is indistinguishable from "never rejected" — the generator swallows it and returns the normal empty-metadata result instead of throwing. Previously triaged as an unreachable non-blocking Minor; flagged again in review #7, so close it properly. + +**Fix:** Replace the value sentinel with a dedicated `let startRejected = false;` boolean, set to `true` in the rejection handler (alongside recording `startError`), and gate the re-throw on `if (startRejected) throw startError;`. This tracks rejection by settlement state, not by the rejected value, so `Promise.reject(undefined)` propagates correctly. The chunk-draining/wake logic is unchanged. + +### #7 (Low) — test cleanup can hide a regression when the test body otherwise passes + +**Where:** `native-lib/node/tests/integration/worker-lifecycle.test.ts` balancing `finally` (~282–291) of the "inits once + creates N engines + exits without cleanup" test. + +**Defect:** The `finally` swallows `destroyEngine()`/`ffi.cleanup()` failures unconditionally (`catch { }`). Suppression is correct only to avoid masking an already-propagating body failure; if the body **succeeded**, a cleanup failure (a real lifecycle regression) is silently discarded and the test still passes. + +**Fix:** Track whether the try body completed successfully (e.g. set `bodySucceeded = true` as the last statement inside `try`, after the final assertion). In the `finally`'s catch, `throw` the cleanup error when `bodySucceeded` is true (surface the regression); only suppress when the body was already failing (a body throw means `bodySucceeded` stayed false and the original error is already propagating). Preserve the round-12/round-6 property that the survival assertions stay inside `try` and best-effort balancing still runs. + +### #8 (Medium, NOT actioned) — PR scope includes Python-binding modernization + +**Decision:** Kept as the standing "leave-as-is, note in PR" decision. No git surgery to split the Python work this round. Answered to the reviewer in the PR: the Python modernization split is acknowledged and deferred to its own follow-up PR; doing the split now would rewrite history mid-review-cycle. This matches the decision recorded in every prior round. + +## Rejected alternatives + +- **Make `initialize()`/`run()` async to await the #3 rollback.** API break — `run()` returns `ExecutionResult`, `initialize()` returns `void`. The pending-state model (reusing `cleanupPromise` + the `"cleaning-up"` guard) gives observability and re-init gating without changing the sync signatures. Same reasoning as the round-5 deadlock fix. +- **#1: detach on every path (including success).** Wrong — on a successful teardown the isolate is destroyed; `fn_detach_thread` against a torn-down isolate is a use-after-free. Detach only on the nonzero-return failure branch, where the isolate is provably still live. +- **#2: block/spin in the rollback path until teardown succeeds.** Reintroduces a potential hang on the init thread. Arming `g_teardown_needed` and letting the existing top-of-`napi_initialize` retry reclaim it is the established, bounded pattern. +- **#6: keep the value sentinel but special-case `undefined`.** Fragile; a dedicated boolean is the direct fix and matches "track rejection with a separate settlement flag" from the review. + +## 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 on the rebased tree: **26 files, 943 passed / 32 skipped / 0 failed** (includes master's rebased-in TCK infrastructure). Doc-only findings (#4, #5) add no tests; #3, #6, #7 each may add/adjust a targeted regression. Target: **0 failures**, with the new/adjusted regressions passing and the full 729-case TCK conformance run still 0-failed. +- Whole-branch final review on the most capable model, tracing the #1/#2 native-lifecycle changes against the teardown state machine and retry-signal invariant. From 789a93f5125e38c17554c68a6fa6c901537047e6 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 14:45:06 -0300 Subject: [PATCH 113/216] fix(node): detach the phantom GraalVM thread on a failed teardown (review #7 #1) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 57135032..6737f127 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2343,8 +2343,17 @@ static void cleanup_thread_fn(void* arg) { // Check the teardown return code (0 == success). On nonzero the isolate is // still live: leave *out_torn_down at 0 so the caller retains // g_isolate/g_initialized/g_ref_count and (per its own logic) arms the retry, - // rather than orphaning a live isolate (review #6 #3). - *out_torn_down = (fn_tear_down_isolate(local_thread) == 0) ? 1 : 0; + // rather than orphaning a live isolate (review #6 #3). On that failure the + // isolate was NOT destroyed, so this thread is still attached to it -- detach + // before the helper thread exits, or the live isolate keeps a phantom + // attached thread that can make a later retry teardown block or fail (review + // #7 #1). On success the isolate is gone: do NOT detach (would be a UAF). + if (fn_tear_down_isolate(local_thread) == 0) { + *out_torn_down = 1; + } else { + fn_detach_thread(local_thread); + *out_torn_down = 0; + } } // Spawned only when napi_cleanup finds g_active_ops > 0 on the last release @@ -2381,8 +2390,17 @@ static void teardown_waiter_thread_fn(void* arg) { if (fn_attach_thread(g_isolate, &local_thread) == 0 && local_thread != NULL) { // Check the teardown return code (0 == success). On nonzero the isolate is // still live -- leave torn_down false so the post-teardown block below - // retains the isolate globals and arms the retry (review #6 #3). - torn_down = (fn_tear_down_isolate(local_thread) == 0); + // retains the isolate globals and arms the retry (review #6 #3). On that + // failure the isolate was NOT destroyed, so this thread is still attached + // to it -- detach before exiting or the live isolate keeps a phantom + // attached thread that can block/fail a later retry teardown (review #7 + // #1). On success the isolate is gone: do NOT detach (would be a UAF). + if (fn_tear_down_isolate(local_thread) == 0) { + torn_down = true; + } else { + fn_detach_thread(local_thread); + torn_down = false; + } } // else: attach failed -- the isolate is still alive. Do NOT clear g_isolate, // or it becomes unreachable and can never be torn down. From e147c8b62176aed307efc968a5457668c3864b29 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 15:01:19 -0300 Subject: [PATCH 114/216] fix(node): arm retry after a failed init-hook rollback so reinit cannot wedge (review #7 #2) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 6737f127..f856ba78 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -713,13 +713,21 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { g_thread = NULL; g_isolate = NULL; g_initialized = 0; + } else { + // Spawn failed, or cleanup_thread_fn's attach/teardown to the isolate + // failed. The isolate is genuinely still alive with g_initialized == 0. + // Without a retry signal the next initialize() would reach the wait loop's + // `g_isolate != NULL && !g_initialized` condition with TEARDOWN_NONE (so no + // adoption branch) and block on uv_cond_wait forever -- nothing left to + // broadcast (review #7 #2). Arm the stranded-teardown retry so the + // retry_stranded_teardown_locked() at the top of the next napi_initialize + // reclaims the isolate (teardown succeeds -> fresh build; repeated failure + // -> the live isolate is adopted by the fast path). g_ref_count is still 0 + // here, so g_teardown_needed (a retry SIGNAL, not a reference) keeps the + // invariant g_ref_count == sum(init_refs) intact. Mirrors the twin arm in + // teardown_waiter_thread_fn and isolate_ref_release_n_locked. + g_teardown_needed = true; } - // else: spawn failed, or cleanup_thread_fn's attach to the isolate failed. - // The isolate is genuinely still alive -- leave g_isolate/g_thread as-is - // rather than orphaning it. This re-arms the same trap on a subsequent - // initialize(), but that is the pre-existing best-effort degradation - // policy already accepted for cleanup_thread_fn's attach-failure path - // elsewhere in this file; we don't invent new behavior for it here. uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to allocate/register env init record"); return NULL; From b7582f370980ed656a0df5fb1dc79a83ce2a6dbf Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 15:20:30 -0300 Subject: [PATCH 115/216] fix(node): observe the initialize() rollback release and gate reinit on it (review #7 #3) Model the async rollback ffi.cleanup() as pending state (state='cleaning-up' + cleanupPromise) so an un-awaited rejection cannot become an unhandledRejection and a concurrent initialize() is rejected deterministically instead of racing the in-flight release. initialize() stays synchronous. Adapt the pre-existing clean-reinit test to await the rollback settling, per the new gating contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/dataweave.ts | 35 ++++++++--- .../tests/unit/dataweave-initialize.test.ts | 61 ++++++++++++++++++- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 3475f769..ce7f7fdb 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -93,17 +93,34 @@ export class DataWeave { ? 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.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. + // If ffi.initialize() already succeeded but engine creation then threw, we + // already hold an increment of the native library's ref-counted handle and + // must release it (ffi.cleanup()), or it leaks for the process lifetime. + // ffi.cleanup() is async, so model the rollback as PENDING state instead of + // firing-and-forgetting it (review #7 #3): (1) an un-awaited rejection must + // not become an unhandledRejection, and (2) a concurrent initialize()/run() + // must not race a fresh graal_create_isolate against the in-flight release. + // Reuse the same cleanupPromise/"cleaning-up" machinery cleanup() uses: + // hold state "cleaning-up" until the release settles (so initialize()'s own + // "cleaning-up" guard rejects a concurrent retry deterministically, and a + // concurrent cleanup() coalesces onto this same promise), then return to + // "uninitialized". The synchronous throw to THIS caller is preserved. + this.engineHandle = null; if (libRefAcquired) { - ffi.cleanup(); + this.state = "cleaning-up"; + // Promise.resolve() normalizes the release: ffi.cleanup() returns + // Promise, but wrapping keeps the .finally() chain robust and lets + // a rejected release settle through the same path. + this.cleanupPromise = Promise.resolve(ffi.cleanup()).finally(() => { + this.state = "uninitialized"; + this.cleanupPromise = null; + }); + // Never let an un-awaited rollback surface as an unhandledRejection. A + // caller that awaits cleanup() (which coalesces onto cleanupPromise) + // still observes the rejection; this handler only covers the un-awaited + // path. + this.cleanupPromise.catch(() => {}); } - this.engineHandle = null; throw new DataWeaveError(`Failed to initialize: ${e instanceof Error ? e.message : e}`); } this.state = "ready"; diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index eabc9538..5d10b066 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -60,7 +60,7 @@ describe("DataWeave.initialize() native ref-count safety", () => { expect(ffi.cleanup).not.toHaveBeenCalled(); }); - it("leaves engineHandle unset and the instance cleanly re-initializable after a failed attempt", () => { + it("leaves engineHandle unset and the instance cleanly re-initializable after the failed attempt's rollback settles", async () => { vi.mocked(ffi.initialize).mockImplementation(() => {}); vi.mocked(ffi.createEngine) .mockImplementationOnce(() => { @@ -73,6 +73,11 @@ describe("DataWeave.initialize() native ref-count safety", () => { expect(() => dw.initialize()).toThrow(DataWeaveError); expect(ffi.cleanup).toHaveBeenCalledTimes(1); + // The rollback release is modeled as pending state (review #7 #3): until it + // settles the instance is "cleaning-up" and re-init is deliberately rejected + // rather than racing the in-flight release. Let the rollback settle first. + await new Promise((r) => setImmediate(r)); + // 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 @@ -81,7 +86,7 @@ describe("DataWeave.initialize() native ref-count safety", () => { dw.initialize(); expect(ffi.createEngine).toHaveBeenCalledTimes(2); - dw.cleanup(); + await dw.cleanup(); expect(ffi.destroyEngine).toHaveBeenCalledWith(42); expect(ffi.cleanup).toHaveBeenCalledTimes(1); }); @@ -286,4 +291,56 @@ describe("DataWeave.initialize() native ref-count safety", () => { const result = dwMod.run("%dw 2.0\noutput application/json\n---\n1"); expect(result.success).toBe(true); }); + + it("gates re-initialization on the in-flight rollback when engine creation fails", async () => { + // Engine creation fails after ffi.initialize() succeeded. The rollback + // ffi.cleanup() is async; until it settles the instance must be in the + // "cleaning-up" state so a concurrent initialize() is rejected deterministically + // rather than racing a fresh isolate against the in-flight release (review #7 #3). + vi.mocked(ffi.initialize).mockImplementation(() => {}); + vi.mocked(ffi.createEngine).mockImplementation(() => { + throw new Error("native engine creation boom"); + }); + let resolveRollback!: () => void; + vi.mocked(ffi.cleanup).mockReturnValue( + new Promise((resolve) => { resolveRollback = resolve; }) + ); + + const dw = new DataWeave("/fake/lib"); + expect(() => dw.initialize()).toThrow(DataWeaveError); + + // Rollback is still in flight: a concurrent initialize() must be rejected, + // not allowed to race a fresh isolate against the pending native release. + expect(() => dw.initialize()).toThrow(/cleanup is in progress/i); + + // Once the rollback settles, the instance is cleanly re-initializable. + resolveRollback(); + await new Promise((r) => setImmediate(r)); // let the .finally run + vi.mocked(ffi.createEngine).mockReturnValue(5); + dw.initialize(); + dw.cleanup(); + expect(ffi.destroyEngine).toHaveBeenCalledWith(5); + }); + + it("does not emit an unhandled rejection when the rollback ffi.cleanup() rejects", async () => { + // The rollback release can itself reject; initialize() must observe it (via + // the stored cleanupPromise) so it never becomes an unhandledRejection, while + // still surfacing the ORIGINAL engine-creation error synchronously (review #7 #3). + vi.mocked(ffi.initialize).mockImplementation(() => {}); + vi.mocked(ffi.createEngine).mockImplementation(() => { + throw new Error("native engine creation boom"); + }); + vi.mocked(ffi.cleanup).mockRejectedValueOnce(new Error("rollback release boom")); + + const dw = new DataWeave("/fake/lib"); + expect(() => dw.initialize()).toThrow(/native engine creation boom/); + + // Give the rejected rollback promise a tick to settle; the .catch() attached + // in initialize() must have consumed it (no unhandledRejection), and the + // instance must be re-initializable afterward. + await new Promise((r) => setImmediate(r)); + vi.mocked(ffi.createEngine).mockReturnValue(6); + dw.initialize(); + expect(ffi.createEngine).toHaveBeenLastCalledWith(); + }); }); From 40e046b0c8b0a1e3e8d6db48587f0dfc17b256e1 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 15:44:53 -0300 Subject: [PATCH 116/216] fix(node): track native stream rejection by settlement state, not value (review #7 #6) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/stream.ts | 13 +++++++++---- native-lib/node/tests/unit/stream.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/stream.ts b/native-lib/node/src/stream.ts index 5f4dadaa..032d5a68 100644 --- a/native-lib/node/src/stream.ts +++ b/native-lib/node/src/stream.ts @@ -37,6 +37,7 @@ export async function* streamFromNative( }; let startError: unknown; + let startRejected = false; const wakeAll = () => { while (pendingResolves.length > 0) { const resolve = pendingResolves.shift(); @@ -47,13 +48,14 @@ export async function* streamFromNative( // Handle BOTH settlement branches. Without the rejection handler, a rejected // start() leaves `done` false forever: a consumer parked in next() below is // never woken and the generator hangs, and the rejection is unhandled - // (review #6 #2). On rejection we record the error, mark completion, and wake - // every waiter; the error is re-thrown after draining any chunks that arrived + // (review #6 #2). On rejection we record the error, flip startRejected, mark + // completion, and wake every waiter; the error is re-thrown (by settlement + // state, not by value -- see below) after draining any chunks that arrived // before the rejection. Because we handle rejection here, metaPromise itself // always fulfills -- `await metaPromise` below never throws. const metaPromise = start(chunkCb).then( (raw) => { metaRaw = raw; done = true; wakeAll(); }, - (err) => { startError = err; done = true; wakeAll(); } + (err) => { startError = err; startRejected = true; done = true; wakeAll(); } ); while (true) { @@ -71,6 +73,9 @@ export async function* streamFromNative( } await metaPromise; - if (startError !== undefined) throw startError; + // Track rejection by settlement STATE, not by the rejected value: Promise.reject(undefined) + // is valid JS, so a value sentinel (startError !== undefined) would swallow it as an empty + // result. startRejected is only ever set in the rejection handler above (review #7 #6). + if (startRejected) throw startError; return parseStreamingResult(metaRaw ?? ""); } \ No newline at end of file diff --git a/native-lib/node/tests/unit/stream.test.ts b/native-lib/node/tests/unit/stream.test.ts index 6e71c677..ab6e3580 100644 --- a/native-lib/node/tests/unit/stream.test.ts +++ b/native-lib/node/tests/unit/stream.test.ts @@ -127,4 +127,17 @@ describe("streamFromNative", () => { // ...then the drained generator surfaces the start error. await expect(gen.next()).rejects.toThrow("late boom"); }); + + it("propagates a native start() rejection of undefined instead of returning empty metadata", async () => { + // Promise.reject(undefined) is valid JS. The old value-sentinel + // (startError !== undefined) treated it as 'never rejected' and returned the + // normal empty-metadata result; a settlement-state flag must propagate it (review #7 #6). + const gen = streamFromNative(() => Promise.reject(undefined)); + await expect( + (async () => { + // Drain fully: iterate to completion so the post-drain re-throw runs. + for await (const _ of gen) { /* no chunks */ } + })() + ).rejects.toBeUndefined(); + }); }); \ No newline at end of file From c6b56b338c133d4bf078cd7de481cf679c61c197 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 15:51:39 -0300 Subject: [PATCH 117/216] test(node): fail worker-lifecycle test when balancing cleanup fails on a passing body (review #7 #7) Co-Authored-By: Claude Sonnet 5 --- .../tests/integration/worker-lifecycle.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/native-lib/node/tests/integration/worker-lifecycle.test.ts b/native-lib/node/tests/integration/worker-lifecycle.test.ts index b5c757e4..3b0e9848 100644 --- a/native-lib/node/tests/integration/worker-lifecycle.test.ts +++ b/native-lib/node/tests/integration/worker-lifecycle.test.ts @@ -214,6 +214,7 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { const N = 3; let hMain: number | null = null; + let bodySucceeded = false; try { // 1. Main thread: initialize and keep a live engine. ffi.initialize(LIB_PATH); @@ -279,15 +280,22 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { expect(() => ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) ).toThrow(/not initialized/i); + bodySucceeded = true; } finally { // Balance global native state even if a Worker/assertion above threw, so // this test cannot strand a live isolate + held reference for sibling - // integration tests (review #6 #7). Best-effort: do not let a cleanup - // error mask the original failure. + // integration tests (review #6 #7). Suppress a balancing-cleanup error + // ONLY when the body already failed (so the original, more actionable + // failure keeps propagating). When the body SUCCEEDED, a cleanup failure + // is itself a real lifecycle regression and must fail the test rather than + // be silently discarded (review #7 #7). try { if (hMain !== null) ffi.destroyEngine(hMain); await ffi.cleanup(); - } catch { /* original failure (if any) propagates from the try */ } + } catch (cleanupErr) { + if (bodySucceeded) throw cleanupErr; + // else: the body is already throwing -- let that original error propagate. + } } }, 20000); }); From 29ef4b96f638fa59954d54087666ee01a4e94993 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 16:06:48 -0300 Subject: [PATCH 118/216] docs(node): state the final-reference condition in instance cleanup() docs (review #7 #5) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/README.md | 2 +- native-lib/node/src/dataweave.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 0ddea7b1..5e314562 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -228,7 +228,7 @@ try { **Methods:** - `initialize()`: Initialize the native library -- `cleanup(): Promise`: Release native resources; resolves once native teardown finishes +- `cleanup(): Promise`: Release this instance's native resources. When it releases the last initialized instance in the process, it resolves once the shared isolate has finished tearing down (draining any in-flight streaming/transform op first); otherwise it resolves as soon as this instance is released, leaving the isolate live for other instances. - `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()` diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index ce7f7fdb..b2922104 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -130,12 +130,15 @@ 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. + * Resolution depends on whether this call releases the FINAL shared native + * reference in the process. When it does, it 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 at that point, + * 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. When other + * initialized instances remain, it resolves as soon as this instance's engine + * is released, leaving the shared isolate live for them. */ async cleanup(): Promise { // Coalesce first: doCleanup() flips `state` to "cleaning-up" synchronously From 4507caf1ef6b528ba2f6df19162b513d93aaef9d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 16:06:48 -0300 Subject: [PATCH 119/216] docs: update root README Node cleanup examples to await + accurate hooks (review #7 #4) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/README.md | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/native-lib/README.md b/native-lib/README.md index 1c0c3442..1256d4be 100644 --- a/native-lib/README.md +++ b/native-lib/README.md @@ -458,14 +458,17 @@ import { DataWeave } from "dataweave-native"; const dw = new DataWeave(); dw.initialize(); - -const r1 = dw.run("2 + 2"); -const r2 = dw.run("x + y", { x: 10, y: 32 }); - -console.log(r1.getString()); // "4" -console.log(r2.getString()); // "42" - -dw.cleanup(); +try { + const r1 = dw.run("2 + 2"); + const r2 = dw.run("x + y", { x: 10, y: 32 }); + + console.log(r1.getString()); // "4" + console.log(r2.getString()); // "42" +} finally { + // cleanup() returns a Promise; await it so an in-flight streaming/transform op + // drains and a subsequent initialize() does not race a still-tearing-down isolate. + await dw.cleanup(); +} ``` ### 5) Error handling @@ -643,11 +646,18 @@ for await (const chunk of gen) { ### 9) Cleanup -The module registers a `process.on('exit')` handler to clean up automatically. For explicit control: +The module registers two process hooks to clean up automatically: `beforeExit` +(async — it awaits cleanup so an in-flight streaming/transform op drains before +the process exits normally) and `exit` (a synchronous best-effort fallback for +`process.exit()` and uncaught exceptions, which cannot await the drain). Neither +hook fires on `SIGTERM`/`SIGINT`/`SIGKILL`, so install your own signal handler +that awaits `cleanup()` if you need a graceful drain on termination. For explicit +control: ```typescript import { cleanup } from "dataweave-native"; -// When done with all DataWeave operations -cleanup(); +// When done with all DataWeave operations. cleanup() returns a Promise; await it +// so any in-flight streaming/transform op drains before the isolate tears down. +await cleanup(); ``` From ceae7c83cddc060d50f2d939d551fbc3229facd6 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 16:37:40 -0300 Subject: [PATCH 120/216] =?UTF-8?q?docs(node):=20correct=20the=20#2=20retr?= =?UTF-8?q?y=20comment=20=E2=80=94=20transient-only=20recovery,=20no=20fas?= =?UTF-8?q?t-path=20adoption=20(review=20#7=20final)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The napi_initialize acquire-failure arm leaves g_initialized==0, so unlike the isolate_ref_release_n_locked twin (g_initialized==1, adopted by the fast path) the retry only recovers a TRANSIENT teardown failure; a persistent graal_tear_down_isolate failure strands the isolate to process exit. The prior comment's 'adopted by the fast path' clause overstated recovery. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index f856ba78..d584f458 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -721,11 +721,17 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { // adoption branch) and block on uv_cond_wait forever -- nothing left to // broadcast (review #7 #2). Arm the stranded-teardown retry so the // retry_stranded_teardown_locked() at the top of the next napi_initialize - // reclaims the isolate (teardown succeeds -> fresh build; repeated failure - // -> the live isolate is adopted by the fast path). g_ref_count is still 0 - // here, so g_teardown_needed (a retry SIGNAL, not a reference) keeps the - // invariant g_ref_count == sum(init_refs) intact. Mirrors the twin arm in - // teardown_waiter_thread_fn and isolate_ref_release_n_locked. + // reclaims the isolate (teardown succeeds -> fresh build). This path leaves + // g_initialized == 0, so -- unlike the release-path twin in + // isolate_ref_release_n_locked, which leaves g_initialized == 1 and is + // adopted by the g_initialized-gated fast path -- recovery here relies on + // the retry actually tearing down: it recovers the realistic TRANSIENT + // failure, but a truly PERSISTENT graal_tear_down_isolate failure would + // re-arm and retry each time and ultimately leave the isolate stranded + // until process exit (best-effort degradation, not a wedge of new work). + // g_ref_count is still 0 here, so g_teardown_needed (a retry SIGNAL, not a + // reference) keeps the invariant g_ref_count == sum(init_refs) intact. + // Mirrors the twin arm in teardown_waiter_thread_fn. g_teardown_needed = true; } uv_mutex_unlock(&g_mutex); From c3e803b1cf4a98c901080391a4633f7ce8e25754 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 18:29:48 -0300 Subject: [PATCH 121/216] fix(node): fail deterministically instead of deadlocking after a stranded-teardown retry (review #8 #1) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index d584f458..80d662dc 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -606,6 +606,24 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { // malfunction). No-ops cheaply when nothing is stranded (flag clear -> return). retry_stranded_teardown_locked(); + // After the retry above, a PERSISTENTLY failing teardown leaves the isolate + // live but unusable: g_isolate != NULL, g_initialized == 0, and + // g_teardown_state == TEARDOWN_NONE (no teardown thread exists). The wait loop + // below would treat `g_isolate != NULL && !g_initialized` as "a teardown is in + // flight" and block on uv_cond_wait -- but nothing remains to broadcast + // g_teardown_cond, so it would hang forever holding g_mutex and freeze every + // future initialize()/cleanup() (review #8 #1). This state is not recoverable + // by waiting; fail deterministically instead. g_teardown_needed stays armed so + // a later op-completion drain can still reclaim the isolate; we neither clear + // it nor touch g_ref_count (still 0 == sum(init_refs), invariant intact). + if (g_isolate != NULL && !g_initialized && g_teardown_state == TEARDOWN_NONE) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, + "DataWeave native runtime is stranded: a prior isolate " + "teardown failed and could not be reclaimed"); + return NULL; + } + // 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 From 13f636e747e5b0390a9fdc7b05f9f73bb04a09e7 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 18:40:09 -0300 Subject: [PATCH 122/216] fix(node): survive a synchronous rollback ffi.cleanup() throw in initialize() (review #8 #2) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/dataweave.ts | 20 ++++++++++--- .../tests/unit/dataweave-initialize.test.ts | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index b2922104..d6235dde 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -108,10 +108,22 @@ export class DataWeave { this.engineHandle = null; if (libRefAcquired) { this.state = "cleaning-up"; - // Promise.resolve() normalizes the release: ffi.cleanup() returns - // Promise, but wrapping keeps the .finally() chain robust and lets - // a rejected release settle through the same path. - this.cleanupPromise = Promise.resolve(ffi.cleanup()).finally(() => { + // ffi.cleanup() can fail synchronously (throw) as well as asynchronously + // (reject a returned promise). Calling it inside a try/catch -- rather + // than eagerly as the argument to Promise.resolve(ffi.cleanup()) -- lets + // a synchronous throw be caught and normalized into a rejected promise + // BEFORE cleanupPromise is assigned, so it still flows through the same + // .finally() state reset instead of escaping here and stranding this + // instance in "cleaning-up" forever (review #8 #2). Existing callers + // still observe ffi.cleanup() invoked synchronously, in the same tick as + // this catch block, exactly as before this fix. + let releaseResult: Promise | void; + try { + releaseResult = ffi.cleanup(); + } catch (cleanupError) { + releaseResult = Promise.reject(cleanupError); + } + this.cleanupPromise = Promise.resolve(releaseResult).finally(() => { this.state = "uninitialized"; this.cleanupPromise = null; }); diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 5d10b066..83fe611b 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -343,4 +343,33 @@ describe("DataWeave.initialize() native ref-count safety", () => { dw.initialize(); expect(ffi.createEngine).toHaveBeenLastCalledWith(); }); + + it("does not strand the instance in cleaning-up when the rollback ffi.cleanup() throws synchronously", async () => { + // ffi.cleanup() can fail SYNCHRONOUSLY (throw) rather than returning a + // rejected promise. The rollback must still settle its pending state and the + // instance must stay re-initializable; the ORIGINAL engine-creation error is + // what surfaces synchronously to this caller (review #8 #2). + vi.mocked(ffi.initialize).mockImplementation(() => {}); + vi.mocked(ffi.createEngine).mockImplementationOnce(() => { + throw new Error("native engine creation boom"); + }); + vi.mocked(ffi.cleanup).mockImplementationOnce(() => { + throw new Error("synchronous cleanup boom"); + }); + + const dw = new DataWeave("/fake/lib"); + // The synchronous throw to THIS caller is the ORIGINAL engine-creation error, + // not the cleanup throw. + expect(() => dw.initialize()).toThrow(/native engine creation boom/); + + // Let the deferred rollback settle; state must return to "uninitialized" so a + // later initialize() is not permanently rejected with "cleanup is in progress". + await new Promise((r) => setImmediate(r)); + vi.mocked(ffi.createEngine).mockReturnValue(11); + dw.initialize(); + expect(ffi.createEngine).toHaveBeenLastCalledWith(); + + await dw.cleanup(); + expect(ffi.destroyEngine).toHaveBeenCalledWith(11); + }); }); From 377d478930491a1793b609250c3be18a05bf743e Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 18:53:38 -0300 Subject: [PATCH 123/216] test(node): always release the native init reference when destroyEngine throws in the worker-lifecycle balance (review #8 #3) Co-Authored-By: Claude Sonnet 5 --- .../tests/integration/worker-lifecycle.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/native-lib/node/tests/integration/worker-lifecycle.test.ts b/native-lib/node/tests/integration/worker-lifecycle.test.ts index 3b0e9848..f7912ad9 100644 --- a/native-lib/node/tests/integration/worker-lifecycle.test.ts +++ b/native-lib/node/tests/integration/worker-lifecycle.test.ts @@ -289,13 +289,22 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { // failure keeps propagating). When the body SUCCEEDED, a cleanup failure // is itself a real lifecycle regression and must fail the test rather than // be silently discarded (review #7 #7). + let destroyErr: unknown; try { if (hMain !== null) ffi.destroyEngine(hMain); + } catch (e) { + // Capture but do not early-exit: the global init reference must still be + // released below, or it contaminates sibling integration tests (review #8 + // #3), matching the production cleanup path that releases even when + // destroyEngine() throws. + destroyErr = e; + } finally { await ffi.cleanup(); - } catch (cleanupErr) { - if (bodySucceeded) throw cleanupErr; - // else: the body is already throwing -- let that original error propagate. } + // Surface a balancing-cleanup failure only when the body succeeded (an + // already-throwing body keeps its more actionable original error) -- same + // policy as review #7 #7, now covering the destroyEngine() throw too. + if (destroyErr !== undefined && bodySucceeded) throw destroyErr; } }, 20000); }); From c9bbd1242d986f423333513fbc3d530275ece65c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 24 Aug 2026 18:58:24 -0300 Subject: [PATCH 124/216] docs(node): capture streaming terminal metadata via manual next() iteration, not generator.return() (review #8 #4) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/README.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 5e314562..5851a6ee 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -137,13 +137,17 @@ const generator = runStreaming( '%dw 2.0\noutput application/json\n---\n[1, 2, 3, 4, 5]' ); -for await (const chunk of generator) { - console.log('Chunk:', chunk.toString()); +// Iterate manually with next() to capture the terminal return value. A +// `for await` loop consumes the generator's return value internally, so a later +// generator.return() would yield { value: undefined } -- drive next() yourself +// and read the metadata off the terminal { done: true, value: StreamingResult }. +let meta; +while (true) { + const { value, done } = await generator.next(); + if (done) { meta = value; break; } + console.log('Chunk:', value.toString()); } - -// Generator return value contains metadata: -const meta = await generator.return(); -console.log('MIME type:', meta.value.mimeType); +console.log('MIME type:', meta.mimeType); ``` **Parameters:** @@ -436,12 +440,17 @@ try { ```javascript try { const generator = runStreaming('invalid syntax'); - for await (const chunk of generator) { - // Process chunks + // Drive next() manually so the terminal { done: true, value: StreamingResult } + // is captured; a `for await` loop would consume it and a later + // generator.return() would give { value: undefined }. + let meta; + while (true) { + const { value, done } = await generator.next(); + if (done) { meta = value; break; } + // Process chunk `value` } - const meta = await generator.return(); - if (!meta.value.success) { - console.error('Streaming error:', meta.value.error); + if (!meta.success) { + console.error('Streaming error:', meta.error); } } catch (err) { console.error('Native error:', err); From 76acdf16ddb4c876d312963d62c1778d7bf61e06 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 09:54:20 -0300 Subject: [PATCH 125/216] docs(node): correct runTransform input-streaming memory claims (async input is pre-buffered) (review #8 #5) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/README.md | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 5851a6ee..62ef8071 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -169,12 +169,19 @@ Execute a DataWeave script with streaming input and output (bidirectional stream ```javascript import { runTransform } from 'dataweave-native'; -import { createReadStream } from 'fs'; +import { readFileSync } from 'fs'; + +// The native read callback is synchronous, so an ASYNC input iterable (e.g. +// fs.createReadStream) is fully pre-buffered into memory before the transform +// starts. For bounded memory, feed a SYNCHRONOUS iterable, which is consumed +// on demand -- one chunk at a time. (See "Sync vs async input and memory" below.) +function* chunked(buf, size = 65536) { + for (let i = 0; i < buf.length; i += size) yield buf.subarray(i, i + size); +} -// Transform a large CSV file to JSON without loading it all into memory const generator = runTransform( '%dw 2.0\noutput application/json\n---\npayload', - createReadStream('large-file.csv'), + chunked(readFileSync('large-file.csv')), { inputName: 'payload', mimeType: 'application/csv', @@ -188,6 +195,14 @@ for await (const chunk of generator) { } ``` +> **Sync vs async input and memory.** The native read callback runs synchronously +> on the JS thread. **Synchronous** iterables (arrays, generators) are consumed +> on demand — only one chunk is held at a time, giving constant-memory streaming. +> **Async** iterables (e.g. `fs.createReadStream()`) are **fully pre-buffered** +> into memory before the transform starts, because their `.next()` returns a +> Promise that cannot be awaited inside the synchronous callback. For large inputs, +> prefer a synchronous generator to keep memory bounded. + **Parameters:** - `script` (string): DataWeave script - `input` (AsyncIterable | Iterable): Streaming input data @@ -384,7 +399,7 @@ console.log(result.getString()); // "300" ```javascript import { runTransform } from 'dataweave-native'; -import { createReadStream, createWriteStream } from 'fs'; +import { readFileSync, createWriteStream } from 'fs'; const script = ` %dw 2.0 @@ -393,9 +408,15 @@ output application/json payload filter $.amount > 1000 `; +// A synchronous generator is consumed on demand, so input memory stays bounded. +// An async stream (createReadStream) would be pre-buffered in full first. +function* chunked(buf, size = 65536) { + for (let i = 0; i < buf.length; i += size) yield buf.subarray(i, i + size); +} + const generator = runTransform( script, - createReadStream('large-transactions.csv'), + chunked(readFileSync('large-transactions.csv')), { mimeType: 'application/csv' } ); @@ -566,12 +587,12 @@ Tests use **Vitest** and cover: - **Buffered execution** (`run`): Best for small scripts with sub-MB outputs - **Streaming execution** (`runStreaming`): Best for large outputs (MB+), reduces memory footprint -- **Bidirectional streaming** (`runTransform`): Best for large inputs and outputs, constant memory usage +- **Bidirectional streaming** (`runTransform`): Best for large outputs; input memory is bounded only with a **synchronous** input iterable (async streams are pre-buffered — see the `runTransform` memory note above) Benchmark (1MB JSON transformation): - `run()`: ~50ms, 2MB peak memory - `runStreaming()`: ~55ms, 500KB peak memory -- `runTransform()`: ~60ms, 256KB peak memory (streaming input) +- `runTransform()`: ~60ms, 256KB peak memory (synchronous input iterable; an async stream is pre-buffered, so peak memory scales with input size) ## See Also From e406fe293ed311f39e59ad7711d05e5833377f1b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 10:03:45 -0300 Subject: [PATCH 126/216] docs(node): show required cleanup() in resolver examples (review #8 #6) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/docs/external-modules.md | 76 ++++++++++++++++-------- 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/native-lib/node/docs/external-modules.md b/native-lib/node/docs/external-modules.md index 05a08a93..2df67c04 100644 --- a/native-lib/node/docs/external-modules.md +++ b/native-lib/node/docs/external-modules.md @@ -43,13 +43,18 @@ In-memory map of module paths to source code: ```typescript import { DataWeave, modulesFromMap } from 'dataweave-native'; +// Inside an async function so `await dw.cleanup()` is available. const dw = new DataWeave({ resolveModule: modulesFromMap({ 'org/test/lib.dwl': '%dw 2.0\nfun foo() = 42', }), }); dw.initialize(); -const result = dw.run('import org::test::lib\n%dw 2.0\n---\nlib::foo()'); +try { + const result = dw.run('import org::test::lib\n%dw 2.0\n---\nlib::foo()'); +} finally { + await dw.cleanup(); +} ``` Best for: Small, in-memory module sets; testing and development. @@ -61,13 +66,18 @@ Read modules from a directory tree on disk: ```typescript import { DataWeave, modulesFromDirectory } from 'dataweave-native'; +// Inside an async function so `await dw.cleanup()` is available. const dw = new DataWeave({ resolveModule: modulesFromDirectory('./my-modules'), }); dw.initialize(); -// Resolves "org/test/lib.dwl" → reads "./my-modules/org/test/lib.dwl" -const result = dw.run('import org::test::lib\n%dw 2.0\n---\nlib::foo()'); +try { + // Resolves "org/test/lib.dwl" → reads "./my-modules/org/test/lib.dwl" + const result = dw.run('import org::test::lib\n%dw 2.0\n---\nlib::foo()'); +} finally { + await dw.cleanup(); +} ``` Best for: Development and file-based module repositories. @@ -89,7 +99,11 @@ const dw = new DataWeave({ resolveModule: resolver, }); dw.initialize(); -const result = dw.run('import org::mule::weave::core::Strings\n%dw 2.0\n---\nStrings::capitalize("hello")'); +try { + const result = dw.run('import org::mule::weave::core::Strings\n%dw 2.0\n---\nStrings::capitalize("hello")'); +} finally { + await dw.cleanup(); +} ``` **Note:** `modulesFromJars()` returns a `Promise` because JAR extraction must complete first. The returned resolver itself is synchronous and can be used repeatedly. @@ -100,6 +114,8 @@ Best for: Packaged dependencies and distributed libraries. Combine multiple resolvers with fallback chain (tries each in order, returns first match): +*Abbreviated fragment — see the first example for the required `try/finally { await dw.cleanup() }` lifecycle.* + ```typescript import { DataWeave, composeResolvers, modulesFromMap, modulesFromDirectory, modulesFromJars } from 'dataweave-native'; @@ -131,6 +147,7 @@ Best for: Layered resolution with fallbacks (overrides, shared libraries, vendor When a module cannot be resolved: ```typescript +// Inside an async function so `await dw.cleanup()` is available. const dw = new DataWeave({ resolveModule: modulesFromMap({ // Only 'org/test/lib.dwl' is available @@ -138,15 +155,19 @@ const dw = new DataWeave({ }); dw.initialize(); -const result = dw.run(` - %dw 2.0 - import org::missing::module // Not found - --- - missing::something() -`); +try { + const result = dw.run(` + %dw 2.0 + import org::missing::module // Not found + --- + missing::something() + `); -if (!result.success) { - console.error(result.error); // "Unable to resolve module with identifier ..." + if (!result.success) { + console.error(result.error); // "Unable to resolve module with identifier ..." + } +} finally { + await dw.cleanup(); } ``` @@ -157,23 +178,28 @@ The resolver returns `null`, and the engine reports a compile-time error. When the resolver encounters file system errors (unreadable files, permission denied, etc.), the resolver throws an error. This error is caught internally by the native layer and the callback returns `null` — indistinguishable from "module not found" to the DataWeave compiler: ```typescript +// Inside an async function so `await dw.cleanup()` is available. const dw = new DataWeave({ resolveModule: modulesFromDirectory('./my-modules'), }); dw.initialize(); -const result = dw.run(` - %dw 2.0 - import org::test::lib - --- - lib::foo() -`); - -if (!result.success) { - // result.error is the same generic message as "module not found": - console.error(result.error); // "Unable to resolve module with identifier ..." - // The actual error details (permissions, encoding, etc.) are not available - // in the result object; see "Debugging" below for how to surface them. +try { + const result = dw.run(` + %dw 2.0 + import org::test::lib + --- + lib::foo() + `); + + if (!result.success) { + // result.error is the same generic message as "module not found": + console.error(result.error); // "Unable to resolve module with identifier ..." + // The actual error details (permissions, encoding, etc.) are not available + // in the result object; see "Debugging" below for how to surface them. + } +} finally { + await dw.cleanup(); } ``` @@ -266,6 +292,8 @@ Future releases may add `npm run dw-deps` for automatic resolution. Check your p Then pass JAR paths to `modulesFromJars()`: +*Abbreviated fragment — see the first example for the required `try/finally { await dw.cleanup() }` lifecycle.* + ```typescript const resolver = await modulesFromJars([ './libs/dw-lib-1.0.jar', From 8c8a91099483d5eebc449f1f680cc83e45930caf Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 10:32:44 -0300 Subject: [PATCH 127/216] docs: qualify root README cleanup drain as final-reference-only (review #8 #7) Co-Authored-By: Claude Sonnet 5 --- native-lib/README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/native-lib/README.md b/native-lib/README.md index 1256d4be..3c1cee57 100644 --- a/native-lib/README.md +++ b/native-lib/README.md @@ -465,8 +465,12 @@ try { console.log(r1.getString()); // "4" console.log(r2.getString()); // "42" } finally { - // cleanup() returns a Promise; await it so an in-flight streaming/transform op - // drains and a subsequent initialize() does not race a still-tearing-down isolate. + // cleanup() returns a Promise; await it. When this releases the FINAL shared + // native reference in the process, it drains any in-flight streaming/transform + // op and completes isolate teardown before resolving (so a subsequent + // initialize() does not race a still-tearing-down isolate). When other + // initialized instances remain, it resolves as soon as this instance is + // released, leaving the shared isolate live for them. await dw.cleanup(); } ``` @@ -657,7 +661,9 @@ control: ```typescript import { cleanup } from "dataweave-native"; -// When done with all DataWeave operations. cleanup() returns a Promise; await it -// so any in-flight streaming/transform op drains before the isolate tears down. +// When done with all DataWeave operations. cleanup() returns a Promise; await it. +// Draining in-flight streaming/transform work and tearing down the isolate happen +// only when this releases the final shared native reference; if other initialized +// instances remain, it resolves as soon as this instance is released. await cleanup(); ``` From a52b433abebfe5add8ed9850dafe5ce37b564ebc Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 10:49:53 -0300 Subject: [PATCH 128/216] test(node): suppress balancing cleanup error too when the worker-lifecycle body already failed (review #8 final) Co-Authored-By: Claude Sonnet 5 --- .../integration/worker-lifecycle.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/native-lib/node/tests/integration/worker-lifecycle.test.ts b/native-lib/node/tests/integration/worker-lifecycle.test.ts index f7912ad9..3441583d 100644 --- a/native-lib/node/tests/integration/worker-lifecycle.test.ts +++ b/native-lib/node/tests/integration/worker-lifecycle.test.ts @@ -298,13 +298,24 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { // #3), matching the production cleanup path that releases even when // destroyEngine() throws. destroyErr = e; - } finally { + } + let cleanupErr: unknown; + try { await ffi.cleanup(); + } catch (e) { + // Always attempt cleanup (never skipped by a destroyEngine throw), but + // capture its failure rather than letting it propagate unconditionally -- + // an already-failing body must keep its original, more actionable error. + cleanupErr = e; + } + // Surface a balancing failure (destroy or cleanup) ONLY when the body + // succeeded; when the body already failed, both are suppressed so the + // original failure keeps propagating (review #7 #7, extended to the + // destroyEngine() throw + cleanup() throw double-fault case). + if (bodySucceeded) { + if (destroyErr !== undefined) throw destroyErr; + if (cleanupErr !== undefined) throw cleanupErr; } - // Surface a balancing-cleanup failure only when the body succeeded (an - // already-throwing body keeps its more actionable original error) -- same - // policy as review #7 #7, now covering the destroyEngine() throw too. - if (destroyErr !== undefined && bodySucceeded) throw destroyErr; } }, 20000); }); From 924cedbac0efc381b5dd873ccd4344802730a0e0 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 10:50:01 -0300 Subject: [PATCH 129/216] docs(node): soften Streaming Large Files bounded-memory claim (readFileSync holds full file) (review #8 final) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 62ef8071..6600fa87 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -408,8 +408,11 @@ output application/json payload filter $.amount > 1000 `; -// A synchronous generator is consumed on demand, so input memory stays bounded. -// An async stream (createReadStream) would be pre-buffered in full first. +// A synchronous generator is consumed on demand: the transform does not make a +// second full copy of the input. Note readFileSync still holds the whole file in +// memory, so this bounds the transform's *added* memory, not total memory -- the +// native read callback is synchronous, so there is no fully-streaming-from-disk +// path (an async createReadStream would instead be pre-buffered in full first). function* chunked(buf, size = 65536) { for (let i = 0; i < buf.length; i += size) yield buf.subarray(i, i + size); } From 9bda0da2fd9b83c85a41f385c76b5cf00e35c7a4 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 15:59:00 -0300 Subject: [PATCH 130/216] test(node): assert re-init actually re-invokes createEngine, not the failed attempt's stale call (review #9 #1) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/tests/unit/dataweave-initialize.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 83fe611b..913ffcec 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -339,8 +339,12 @@ describe("DataWeave.initialize() native ref-count safety", () => { // in initialize() must have consumed it (no unhandledRejection), and the // instance must be re-initializable afterward. await new Promise((r) => setImmediate(r)); + // Clear so the assertion below proves the RETRY re-invoked createEngine, + // not the earlier failed attempt's stale no-arg call (review #9 #1). + vi.mocked(ffi.createEngine).mockClear(); vi.mocked(ffi.createEngine).mockReturnValue(6); dw.initialize(); + expect(ffi.createEngine).toHaveBeenCalledTimes(1); expect(ffi.createEngine).toHaveBeenLastCalledWith(); }); @@ -365,8 +369,12 @@ describe("DataWeave.initialize() native ref-count safety", () => { // Let the deferred rollback settle; state must return to "uninitialized" so a // later initialize() is not permanently rejected with "cleanup is in progress". await new Promise((r) => setImmediate(r)); + // Clear so the assertion proves the retry actually re-invoked createEngine + // rather than passing on the failed attempt's stale call (review #9 #1). + vi.mocked(ffi.createEngine).mockClear(); vi.mocked(ffi.createEngine).mockReturnValue(11); dw.initialize(); + expect(ffi.createEngine).toHaveBeenCalledTimes(1); expect(ffi.createEngine).toHaveBeenLastCalledWith(); await dw.cleanup(); From fea19cf9e0fcba66f8c6cbe8b1a32c96fa6df229 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 16:03:05 -0300 Subject: [PATCH 131/216] test(node): retain and await the resolver cleanup-race promise in try/finally (review #9 #3) Co-Authored-By: Claude Sonnet 5 --- .../integration/dataweave-resolver.test.ts | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/native-lib/node/tests/integration/dataweave-resolver.test.ts b/native-lib/node/tests/integration/dataweave-resolver.test.ts index 6f5b5eb0..a3963d24 100644 --- a/native-lib/node/tests/integration/dataweave-resolver.test.ts +++ b/native-lib/node/tests/integration/dataweave-resolver.test.ts @@ -236,32 +236,42 @@ describe('DataWeave with resolver', () => { // 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); + // Retain the cleanup promise so its rejection cannot escape as an unhandled + // rejection and so native teardown is actually awaited before the test ends + // (review #9 #3). It is awaited in the finally below. + const cleanupPromise = dw.cleanup(); - // 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(); + // 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. } - } catch { - // Draining after a mid-stream cleanup may itself reject; that's fine. + } finally { + // Always await the retained cleanup so native teardown finishes before the + // test returns; a cleanup rejection here surfaces rather than dangling, but + // it does not mask a primary assertion failure thrown from the try above. + await cleanupPromise; } }); From 277446643b0e28ac529ae7410c7d7c0bf4f74385 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 16:09:11 -0300 Subject: [PATCH 132/216] test(node): surface instance-lifecycle cleanup() failures when the test body passed (review #9 #4) Co-Authored-By: Claude Sonnet 5 --- .../integration/instance-lifecycle.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/native-lib/node/tests/integration/instance-lifecycle.test.ts b/native-lib/node/tests/integration/instance-lifecycle.test.ts index ee1a1675..e644662e 100644 --- a/native-lib/node/tests/integration/instance-lifecycle.test.ts +++ b/native-lib/node/tests/integration/instance-lifecycle.test.ts @@ -9,12 +9,25 @@ import { findLibrary, buildInputsJson } from "../../src/utils"; // what findings #1 and #3 exploit. Real addon, no mocking. describe("instance lifecycle during cleanup (round 6)", () => { let dw: DataWeave | undefined; - afterEach(async () => { + afterEach(async (ctx) => { // 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 */ } + const inst = dw; dw = undefined; + let cleanupErr: unknown; + try { + await inst.cleanup(); + } catch (e) { + cleanupErr = e; + } + // A cleanup() failure is itself a real lifecycle regression: surface it + // when the test body PASSED. Suppress it only when the body already FAILED, + // so the original, more actionable assertion failure keeps propagating + // (review #9 #4; mirrors the worker-lifecycle balancing pattern). + if (cleanupErr !== undefined && ctx.task.result?.state !== "fail") { + throw cleanupErr; + } } }); From 81c18421ce87f8dd1eb14cf530bec519cace692d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 16:15:25 -0300 Subject: [PATCH 133/216] test(node): assert Worker references were released via the not-initialized ref-count proxy in both Worker cleanup tests (review #9 #2) Co-Authored-By: Claude Sonnet 5 --- .../tests/integration/worker-lifecycle.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/native-lib/node/tests/integration/worker-lifecycle.test.ts b/native-lib/node/tests/integration/worker-lifecycle.test.ts index 3441583d..75772ca0 100644 --- a/native-lib/node/tests/integration/worker-lifecycle.test.ts +++ b/native-lib/node/tests/integration/worker-lifecycle.test.ts @@ -152,6 +152,14 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { expect(JSON.parse(Buffer.from(envelope.result, "base64").toString("utf-8"))).toBe(2); ffi.destroyEngine(h); await ffi.cleanup(); + // Prove this test INDEPENDENTLY that no abandoned Worker leaked its init + // reference: after the main thread balances its own reference to zero, a raw + // op must observe "not initialized". A leaked Worker reference would keep + // g_ref_count >= 1 here, so the isolate would still be live and this would + // NOT throw (review #9 #2). + expect(() => + ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) + ).toThrow(/not initialized/i); }); it("Worker.terminate() mid-life leaves the main thread able to initialize and run", async () => { @@ -200,6 +208,14 @@ describe("worker_threads engine lifecycle (round 12 #9)", () => { expect(JSON.parse(Buffer.from(envelope.result, "base64").toString("utf-8"))).toBe(7); ffi.destroyEngine(h); await ffi.cleanup(); + // Prove INDEPENDENTLY that the terminated Worker's engine reference was + // released: after the main thread balances its own reference, a raw op must + // observe "not initialized" (g_ref_count == 0). A leaked reference from the + // terminated Worker would leave the isolate live and this would NOT throw + // (review #9 #2). + expect(() => + ffi.runScriptEngine(Number.MAX_SAFE_INTEGER, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) + ).toThrow(/not initialized/i); }); it("a Worker that inits once + creates N engines + exits without cleanup() does NOT tear down the isolate under a live main engine (round 13 #5)", async () => { From b2c3d3f534e6b4165d97fe81636b86bb68ef6b5d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 16:57:53 -0300 Subject: [PATCH 134/216] fix(node): reject non-null non-string inputCharset at the transform boundary; cover malformed streaming/transform raw-ffi args (review #9 #6) The transform entrypoint silently coerced any non-string inputCharset (objects, numbers, booleans) to NULL, unlike the four non-nullable string args which throw. inputCharset is nullable, so null/undefined still map to "no charset", but any other type is now a caller error that fails closed via TRANSFORM_FAIL. The JS binding only ever sends string|null (dataweave.ts: opts?.charset ?? null), so the production path is unaffected. Also completes the round-7 #2 malformed-arg sweep: adds coverage for streaming inputsJson and transform inputsJson/inputName/inputMimeType/charset. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 10 ++++- .../integration/malformed-inputs.test.ts | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 80d662dc..2861a740 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -1674,8 +1674,16 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i 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 { + } else if (type == napi_null || type == napi_undefined) { + // inputCharset is nullable: null/undefined mean "no charset". This is the + // only non-string form the JS binding ever sends (dataweave.ts normalizes + // opts?.charset ?? null). w->input_charset = NULL; + } else { + // Any other type (object, number, boolean, ...) is a caller error, not + // "no charset". Fail closed like the four non-nullable string args above + // rather than silently coercing to NULL (review #9 #6). + TRANSFORM_FAIL("runScriptTransformEngine: inputCharset must be a string, null, or undefined"); } #undef TRANSFORM_FAIL diff --git a/native-lib/node/tests/integration/malformed-inputs.test.ts b/native-lib/node/tests/integration/malformed-inputs.test.ts index 3deae4c7..2a6194b0 100644 --- a/native-lib/node/tests/integration/malformed-inputs.test.ts +++ b/native-lib/node/tests/integration/malformed-inputs.test.ts @@ -50,6 +50,20 @@ describe("malformed raw-ffi inputs throw (round 7 #2)", () => { ffi.destroyEngine(handle); }); + it("runScriptStreamingEngine throws on non-string inputsJson", () => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + expect(() => + ffi.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1,2,3]", + {} as unknown as string, + () => {} + ) + ).toThrow(); + ffi.destroyEngine(handle); + }); + it("runScriptTransformEngine throws on non-string script", () => { ffi.initialize(findLibrary()); const handle = ffi.createEngine(); @@ -67,4 +81,30 @@ describe("malformed raw-ffi inputs throw (round 7 #2)", () => { ).toThrow(); ffi.destroyEngine(handle); }); + + // The transform entrypoint converts four string args (script already covered + // above): inputsJson, inputName, inputMimeType, and a non-null inputCharset. + // A dropped napi_get_value_string check on any of them must throw (review #9 #6). + it.each([ + { name: "inputsJson", script: "%dw 2.0\noutput application/json\n---\npayload", inputsJson: {} as unknown as string, inputName: "payload", mimeType: "application/json", charset: null as string | null }, + { name: "inputName", script: "%dw 2.0\noutput application/json\n---\npayload", inputsJson: "{}", inputName: {} as unknown as string, mimeType: "application/json", charset: null as string | null }, + { name: "inputMimeType", script: "%dw 2.0\noutput application/json\n---\npayload", inputsJson: "{}", inputName: "payload", mimeType: {} as unknown as string, charset: null as string | null }, + { name: "non-null inputCharset", script: "%dw 2.0\noutput application/json\n---\npayload", inputsJson: "{}", inputName: "payload", mimeType: "application/json", charset: {} as unknown as string }, + ])("runScriptTransformEngine throws on non-string $name", ({ script, inputsJson, inputName, mimeType, charset }) => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + expect(() => + ffi.runScriptTransformEngine( + handle, + script, + inputsJson, + inputName, + mimeType, + charset, + () => null, + () => {} + ) + ).toThrow(); + ffi.destroyEngine(handle); + }); }); From 3203d93e77e57e30dec721b662e32879ccd75fc0 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 17:10:47 -0300 Subject: [PATCH 135/216] docs(node): fix invalid import-before-%dw headers, sync-run blocking claim, transform memory qualification, and charset type (review #9 #5) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/README.md | 23 ++++++++++++++--------- native-lib/node/docs/external-modules.md | 6 +++--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 6600fa87..afd90999 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -173,8 +173,10 @@ import { readFileSync } from 'fs'; // The native read callback is synchronous, so an ASYNC input iterable (e.g. // fs.createReadStream) is fully pre-buffered into memory before the transform -// starts. For bounded memory, feed a SYNCHRONOUS iterable, which is consumed -// on demand -- one chunk at a time. (See "Sync vs async input and memory" below.) +// starts. A SYNCHRONOUS iterable is instead consumed on demand -- one chunk at a +// time -- so the transform makes no extra full copy of the input (it does NOT by +// itself bound total memory: a source like readFileSync still holds the whole +// input). (See "Sync vs async input and memory" below.) function* chunked(buf, size = 65536) { for (let i = 0; i < buf.length; i += size) yield buf.subarray(i, i + size); } @@ -197,11 +199,14 @@ for await (const chunk of generator) { > **Sync vs async input and memory.** The native read callback runs synchronously > on the JS thread. **Synchronous** iterables (arrays, generators) are consumed -> on demand — only one chunk is held at a time, giving constant-memory streaming. -> **Async** iterables (e.g. `fs.createReadStream()`) are **fully pre-buffered** -> into memory before the transform starts, because their `.next()` returns a -> Promise that cannot be awaited inside the synchronous callback. For large inputs, -> prefer a synchronous generator to keep memory bounded. +> on demand — the transform holds only one chunk at a time and makes no extra +> full copy of the input. This bounds the transform's *added* memory, not total +> memory: if the source itself already holds the whole input (e.g. `readFileSync`), +> that memory is still resident. **Async** iterables (e.g. `fs.createReadStream()`) +> are **fully pre-buffered** into memory before the transform starts, because their +> `.next()` returns a Promise that cannot be awaited inside the synchronous +> callback. For large inputs, prefer a synchronous generator so the transform adds +> no second copy. **Parameters:** - `script` (string): DataWeave script @@ -209,7 +214,7 @@ for await (const chunk of generator) { - `opts` (object, optional): Options - `inputName` (string): Name of input variable (default: "payload") - `mimeType` (string): Input MIME type (default: "application/json") - - `charset` (string | null): Input character encoding + - `charset` (string, optional): Input character encoding - `inputs` (object): Additional input variables **Yields:** `Buffer` chunks as they're produced @@ -487,7 +492,7 @@ The Node.js binding uses **N-API** (Node-API) for C addon integration: - **Thread-safe**: N-API calls are serialized on the Node.js event loop - **Async operations**: Streaming operations yield control to the event loop between chunks -- **No blocking**: Long-running scripts execute on the native side without blocking the event loop +- **No event-loop blocking for streaming**: `runStreaming`/`runTransform` execute on a background worker and yield to the event loop between chunks. Note the **synchronous** `run()` runs native work directly on the calling JS thread and *does* block it until the script completes — use the streaming methods for long-running work you cannot block on. **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. diff --git a/native-lib/node/docs/external-modules.md b/native-lib/node/docs/external-modules.md index 2df67c04..9c2a95e4 100644 --- a/native-lib/node/docs/external-modules.md +++ b/native-lib/node/docs/external-modules.md @@ -51,7 +51,7 @@ const dw = new DataWeave({ }); dw.initialize(); try { - const result = dw.run('import org::test::lib\n%dw 2.0\n---\nlib::foo()'); + const result = dw.run('%dw 2.0\nimport org::test::lib\n---\nlib::foo()'); } finally { await dw.cleanup(); } @@ -74,7 +74,7 @@ dw.initialize(); try { // Resolves "org/test/lib.dwl" → reads "./my-modules/org/test/lib.dwl" - const result = dw.run('import org::test::lib\n%dw 2.0\n---\nlib::foo()'); + const result = dw.run('%dw 2.0\nimport org::test::lib\n---\nlib::foo()'); } finally { await dw.cleanup(); } @@ -100,7 +100,7 @@ const dw = new DataWeave({ }); dw.initialize(); try { - const result = dw.run('import org::mule::weave::core::Strings\n%dw 2.0\n---\nStrings::capitalize("hello")'); + const result = dw.run('%dw 2.0\nimport org::mule::weave::core::Strings\n---\nStrings::capitalize("hello")'); } finally { await dw.cleanup(); } From 19a3ea6074f8691eff0149fa0107ec4bd1902572 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 25 Aug 2026 17:55:43 -0300 Subject: [PATCH 136/216] docs: consolidate multi-engine design into single final-state doc Rewrite 2026-08-07-native-lib-multi-engine-design.md to reflect the converged final state, folding all review-round hardening decisions into the relevant sections (concurrency & lifecycle model, architecture, ABI break, testing posture). Delete the 12 per-round hardening docs; a provenance appendix maps each round to the section it was folded into. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-08-07-native-lib-multi-engine-design.md | 595 ++++++++++++++---- ...11-cleanup-teardown-deadlock-fix-design.md | 101 --- ...-14-instance-lifecycle-state-fix-design.md | 111 ---- ...fecycle-and-worker-oom-hardening-design.md | 116 ---- ...i-admission-and-conversion-sweep-design.md | 120 ---- ...m-safe-streaming-transform-setup-design.md | 127 ---- ...e-pin-and-cleanup-hook-hardening-design.md | 147 ----- ...leak-and-teardown-race-hardening-design.md | 203 ------ ...r-teardown-dangling-resolver-ctx-design.md | 104 --- ...per-env-init-reference-ownership-design.md | 184 ------ ...teardown-and-admission-hardening-design.md | 236 ------- ...gleton-stream-teardown-hardening-design.md | 334 ---------- ...wn-detach-rollback-doc-hardening-design.md | 108 ---- 13 files changed, 465 insertions(+), 2021 deletions(-) delete mode 100644 docs/superpowers/specs/2026-08-11-cleanup-teardown-deadlock-fix-design.md delete mode 100644 docs/superpowers/specs/2026-08-14-instance-lifecycle-state-fix-design.md delete mode 100644 docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md delete mode 100644 docs/superpowers/specs/2026-08-18-ffi-admission-and-conversion-sweep-design.md delete mode 100644 docs/superpowers/specs/2026-08-18-oom-safe-streaming-transform-setup-design.md delete mode 100644 docs/superpowers/specs/2026-08-19-engine-pin-and-cleanup-hook-hardening-design.md delete mode 100644 docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md delete mode 100644 docs/superpowers/specs/2026-08-19-worker-teardown-dangling-resolver-ctx-design.md delete mode 100644 docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md delete mode 100644 docs/superpowers/specs/2026-08-21-review5-teardown-and-admission-hardening-design.md delete mode 100644 docs/superpowers/specs/2026-08-21-review6-singleton-stream-teardown-hardening-design.md delete mode 100644 docs/superpowers/specs/2026-08-24-review7-teardown-detach-rollback-doc-hardening-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 index 5a491acc..5c031910 100644 --- 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 @@ -1,169 +1,504 @@ # Design: Multiple Isolated DataWeave Engines per Process (native-lib, Node) -**Date:** 2026-08-07 -**Status:** Approved for implementation +**Date:** 2026-08-07 (consolidated 2026-08-25) +**Status:** Approved and implemented on `w-23692110-multi-engine-design` (PR #157) **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 +> **About this document.** This is the single, consolidated design for the multi-engine Node +> binding. It describes the **final state** of the feature as shipped on PR #157. The core +> feature (object-level engines behind opaque handles) is unchanged from the original design; +> the substantial addition is the **concurrency & lifecycle model** (§6), which was hardened +> across a long series of code reviews. Those hardening decisions are folded into the relevant +> sections here rather than kept as separate per-round documents; a provenance map for git +> archaeology lives in the [Appendix](#appendix-hardening-provenance). The product-facing +> `DataWeave` class is pre-GA, so several internal contracts (async `cleanup()`, the removed +> `*_with_resolver` C ABI) changed during hardening without a compatibility ceremony. + +## 1. 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. Before this change the +second `new DataWeave({ resolveModule })` in a process silently kept the first instance's +resolver. The isolation must hold with instances living in different Worker threads and being +created, run, and torn down concurrently, without leaking native resources or wedging the +shared GraalVM isolate. + +## 2. Background + +`native-lib`'s `ScriptRuntime` (`native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java`) +was a `static final` singleton holding one `engine` and a **write-once** `static volatile resolver`. +`setResolver` refused to run a second time per process (logged a warning and returned). Every +`@CEntryPoint` in `NativeLib.java` routed through `ScriptRuntime.getInstance()`. So two +`DataWeave` instances in one process could not have independent module sets — whichever called a +resolver-backed `run()` first won. + +**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." The limitation was specific to `native-lib`'s deliberate Java static singleton plus +the Node C addon's global resolver bridge. + +## 3. 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. +- `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 (`native-lib/node/src/addon.c`): per-handle resolver bridge state instead of one + process-global bridge, plus the concurrency & lifecycle machinery in §6. +- Node TypeScript layer (`ffi.ts`, `dataweave.ts`, `stream.ts`, `reader.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 +- Python binding changes. Python already achieves isolation via one isolate per instance; + unifying it onto the same handle-based API is a follow-up. +- Separate GraalVM isolates per engine — rejected as the isolation mechanism (see §5). +- Solving streaming/transform + **custom-module** resolution across the background-thread + boundary. Streaming against a resolver-backed engine still fails closed (returns "not found") + for custom modules reached from a background worker thread; built-in modules continue to + resolve normally in all cases. This is a pre-existing, documented hazard, not introduced here. + +## 4. Definitions + +- **Isolate** — the single process-wide GraalVM isolate. All engines share it. Its lifetime is + governed by `g_ref_count` (§6.1). +- **Engine** — a `ScriptRuntime` Java object (own resolver + compiled-script cache) addressed by + an opaque `long long` handle. Many engines per isolate. +- **Init reference** — the `g_ref_count` unit an env acquires on each `initialize()` and releases + on the matching `cleanup()` (or on env death). Distinct from an engine handle. +- **Op** — one in-flight `run()`/`runStreaming()`/`runTransform()` native call. +- **Owner env / owner thread** — the `napi_env` (and its JS thread) that created a given engine + or init reference. `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are + thread-affine; env-affine calls only ever happen on the owner thread. + +## 5. Alternatives Considered (isolation mechanism) + +**Separate GraalVM isolates per engine (rejected).** The most complete isolation (own heap, own +JIT, own Java statics), and what Python does per-instance. Rejected for Node because `addon.c` +assumed exactly one isolate as global state; supporting N isolates means restructuring all of +that into per-handle structs, and isolate teardown is fragile (`graal_tear_down_isolate` blocks +until every attached thread reaches a safepoint). It is unnecessarily heavy for the actual need: +independent module resolution and script caching, not full JVM-level sandboxing. + +**Chosen: object-level engines in one shared isolate.** Multiple `ScriptRuntime` Java objects, +each with its own resolver and compiled-script cache, all in the single existing GraalVM isolate, +addressed by an opaque handle. Mirrors what `native-cli` already does and requires no change to +isolate lifecycle management for the *feature* — though it does require the careful +reference-and-teardown coordination in §6, because now the isolate is shared by independently +created and destroyed engines across threads. + +## 6. Concurrency & Lifecycle Model + +This section is the heart of the design. It governs how the shared isolate, per-engine registry +entries, and in-flight ops coordinate so that no thread ever attaches to, executes on, or +resolves a module against a torn-down isolate or a freed engine record, and no native resource +leaks — under concurrent creation, execution, abandonment (env death without `cleanup()`), and +teardown across Worker threads. + +All shared C state is read and written **only under `g_mutex`**, with two documented exceptions: +the cheap top-of-function `!g_initialized` fast-path read (a benign optimization; the +authoritative check is under the lock), and the lock-free `g_isolate` NULL-check that narrows a +window before a guarded re-check. + +### 6.1 The reference-ownership invariant + +The isolate lives while any env holds an init reference. The governing invariant is: + +> **`g_ref_count` == Σ `init_refs` over all live per-env records.** + +`g_ref_count` is a derived total, not a bare global that any code path may drive to zero. +Reference accounting is **per `napi_env`**, tracked in a `g_mutex`-guarded linked list of +`env_init_rec_t { napi_env env; int init_refs; next; }`: + +- **`initialize()`** acquires one init reference *on the calling env's record* (find-or-create the + record, `init_refs++`, `g_ref_count++`, both under the same lock). The record registers exactly + one env-death hook (`env_init_cleanup`) on first creation. +- **`cleanup()`** releases one reference **only if the calling env owns one** (`init_refs > 0`). + A `cleanup()` with no matching `initialize()` on that env, or a double-`cleanup()`, is a no-op + that resolves immediately — it must never steal another env's reference and tear the isolate + down under a live user. +- **Env death** (`env_init_cleanup`, an env-cleanup hook) releases *all* of that env's remaining + references at once, from a single env-scoped decision point. This is what reclaims an abandoned + Worker that exited without calling `cleanup()`. +- **`destroyEngine` never releases an init reference** — engines and init references have distinct + lifetimes (Java registry entry vs. isolate). The product `doCleanup()` calls `destroyEngine` + then `ffi.cleanup()`; the latter is the sole release. + +Because every release is keyed on a specific env's balance, an abandoned env-A can only reach +`g_ref_count == 0` when no other env holds a reference — so it can never tear down the isolate +under a live env-B. This closes both the cross-env abandonment UAF and the symmetric +over-`cleanup()` UAF. + +The three `g_ref_count` mutators after this design are: the three `initialize()` acquire sites +(adoption / already-initialized fast path / create path), `release_isolate_ref_locked` (the +`cleanup()` path), and `env_init_cleanup` (env death, via the bounded multi-release helper +`isolate_ref_release_n_locked(n)`, which makes the reached-zero teardown decision *at most once* +regardless of how many references it drops). + +### 6.2 The teardown state machine + +When a release drops `g_ref_count` to 0, the isolate must be torn down — but only after every +in-flight op has drained, because `graal_tear_down_isolate` blocks until every GraalVM-attached +worker thread detaches, and those workers deliver chunks via a `napi_threadsafe_function` that +needs the JS event loop to run. A naïve synchronous join-on-teardown from the JS thread +therefore **deadlocks**: JS thread waits for teardown → teardown waits for the worker to detach → +the worker waits for the JS thread to run its chunk callback. + +The resolution is a `g_active_ops` counter (all in-flight ops, every engine, every thread) plus a +tri-state machine, all under `g_mutex`: -**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/`) +``` +TEARDOWN_NONE no teardown queued or running. +TEARDOWN_PENDING_WAIT a reached-zero release queued a teardown; a detached waiter thread is + blocked on `while (g_active_ops > 0 && !g_teardown_cancelled)`. The + isolate is STILL LIVE here — a fresh initialize() may ADOPT it. +TEARDOWN_TEARING_DOWN the waiter passed the point of no return and is in + graal_tear_down_isolate(). Adoption is unsafe; initialize() blocks + (deadlock-free, because g_active_ops is already 0 — nothing depends on + the JS loop). +``` -**`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); +- **Reached-zero release, `g_active_ops == 0`:** synchronous fast path — spawn+join + `cleanup_thread_fn` inline (it attaches its own Graal thread, tears down, and reports + success only when `graal_tear_down_isolate` returns 0), then clear + `g_thread`/`g_isolate`/`g_initialized`/`g_ref_count`. +- **Reached-zero release, `g_active_ops > 0`:** set `TEARDOWN_PENDING_WAIT`, spawn the detached + waiter thread, return a pending promise. Each op's completion sentinel decrements `g_active_ops` + and broadcasts `g_teardown_cond`; when it reaches 0 the waiter publishes `TEARDOWN_TEARING_DOWN` + (under the lock, the point of no return) and tears down. +- **Adoption (the deadlock fix):** an `initialize()` arriving in `TEARDOWN_PENDING_WAIT` sets + `g_teardown_cancelled = true`, takes a fresh init reference, broadcasts, and returns — the + waiter re-checks the flag, tears down nothing, and resolves every queued `cleanup()` promise + anyway (from each caller's perspective the reference it dropped is gone, whether the isolate was + physically destroyed or adopted by a newcomer is immaterial). +- **Multiple concurrent `cleanup()` calls** waiting on the same teardown each append a node + `{env, deferred, tsfn}` to `g_teardown_waiters` — a *list*, because a second/third `cleanup()` + can arrive from a different Worker env, and each thread-affine deferred must be resolved via its + own env's tsfn on its own thread. + +**Teardown-failure retry signal.** If a reached-zero teardown cannot be carried out — waiter +alloc/spawn fails, `fn_attach_thread` fails, or `graal_tear_down_isolate` returns nonzero — the +isolate is left live with `g_ref_count == 0` and no owner. Rather than fabricate a phantom +reference (which would violate the §6.1 invariant and the resolved `cleanup()` promise's +contract), a `g_mutex`-guarded `g_teardown_needed` **retry signal** is armed. It is *not* a +reference (never added to any count). It is cleared when the isolate is actually torn down or +adopted. Retry runs at two natural, already-locked points: each op-completion drain (once +`g_active_ops` reaches 0), and the top of the next `napi_initialize` (before adoption, so a +pending teardown is honored rather than silently discarded). On a nonzero-return teardown the +helper threads also **detach** their local IsolateThread before exiting (the isolate is still +live; exiting attached would leave a phantom thread that blocks later retries). The documented, +accepted residual: if teardown fails *and* no later `initialize()` or op ever occurs, the isolate +lingers until process exit — benign (one process-lifetime isolate, no invariant violation), the +deliberate tradeoff for not adding event-loop-affine async retry infrastructure to this code. + +### 6.3 Per-engine records, admission pinning, and deferred destroy + +Every engine — resolver-backed **and** resolver-less — gets a per-engine record +(`engine_bridge_t`) at creation, linked into `g_bridges`, carrying `handle`, `in_flight`, +`destroy_pending`, `deferred_registry_remove`, and (for resolver-backed engines only) +`resolver_js`/`env`/`owner`/`results`. Resolver-less records leave those resolver fields +zero/NULL. `in_flight` (per-handle registry drain) and `g_active_ops` (global isolate teardown) +are **distinct counters**, never merged. + +**Admission pins the engine atomically.** Each of the three run paths reserves `g_active_ops` +*and* pins the engine (`in_flight++` via `bridge_begin_op_locked`) in the **same** critical +section as the lifecycle check, before any window a concurrent `destroyEngine` could use: + +```c +uv_mutex_lock(&g_mutex); +if (!g_initialized || (g_teardown_state != TEARDOWN_NONE && !g_teardown_cancelled)) { + uv_mutex_unlock(&g_mutex); + /* free partials */ napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); + return NULL; } +g_active_ops++; +w->bridge = bridge_begin_op_locked(handle); // NULL for unknown handle -> worker surfaces the envelope +uv_mutex_unlock(&g_mutex); ``` -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()`. +- **Streaming / transform** reserve *early* (arg extraction is cheap relative to the async op) and + release via the completion sentinel on the worker thread. Every early-return between admission + and worker spawn (conversion error, OOM, tsfn/promise-create failure, spawn failure) unwinds + **both** `g_active_ops` and the engine pin. +- **Synchronous `run()`** reserves *late* — immediately before `fn_attach_thread`, so the + reservation spans exactly the isolate-touching window (attach→detach) with only two unwind + sites (attach-failure and normal completion); the string mallocs and arg extraction don't touch + the isolate. +- **`createEngine` / `createEngineWithResolver`** likewise do their lifecycle check + a transient + `g_active_ops` reservation in one critical section, and additionally require that the **calling + env owns an init reference** (`init_refs > 0`) — an env that never initialized must not create + engines on the shared isolate. + +With the pin taken under the admission lock, a concurrent `destroyEngine` either runs entirely +before admission (the handle is already gone → worker surfaces `Unknown engine handle`, no freed +access) or entirely after (`in_flight > 0` → destroy defers). There is no interleaving where an +admitted op observes a freed bridge. + +**Deferred destroy.** `napi_destroy_engine`, under `g_mutex`: if `in_flight > 0`, set +`destroy_pending` and defer the Java-registry removal (`fn_destroy_engine`); the last op to drain +performs it on completion. If `in_flight == 0`, remove now. `fn_destroy_engine` is called +**exactly once** per handle (immediate xor deferred, never both), and it attaches its own fresh +Graal thread so it is safe to call from the completion sentinel or directly. + +**The registry-removal step is itself teardown-guarded.** Removing the Java registry entry +touches the isolate (`fn_attach_thread(g_isolate, …)`), so it is split into +`bridge_finalize_registry` — which takes its **own** transient `g_active_ops` reservation, gated +on `g_teardown_state != TEARDOWN_TEARING_DOWN && g_isolate != NULL` in the *same* critical section +as the increment — and `bridge_finalize_free` (napi_ref deletion, still resolver-gated and on the +owner thread; result-buffer free; `free`). This closes the race where a deferred finalize could +attach to an isolate the waiter is destroying, without re-opening the completion-path +coordination: the op's own `g_active_ops--` stays on the worker thread; the finalize takes a fresh +short-lived reservation only around the attach, makes no env-affine or JS-loop-dependent call, and +never holds it across a JS callback (so it cannot re-introduce the §6.2 deadlock). + +**Env cleanup hooks reclaim abandoned engines.** Every engine registers a +`napi_add_env_cleanup_hook` at creation (checked for failure — creation is all-or-nothing; on +hook-registration failure the record is unlinked, its registry entry removed, its init reference +released, and the create throws with no usable handle escaping). When the owner env dies without +`destroyEngine`, the hook removes the Java registry entry and frees the record. Because every +engine now carries an env-affine hook, the **owner-thread `destroyEngine` guard fires for any +record** (not only resolver-backed ones): `napi_remove_env_cleanup_hook` is valid only on the +owner env, so an engine is destroyable only from its creating thread. Node runs env-cleanup hooks +LIFO, and the per-env init-record hook is registered on the *first* `initialize()` (before any +engine) — so at env death every per-engine `bridge_env_cleanup` runs (isolate still alive) before +`env_init_cleanup` releases the isolate reference(s). Ordering preserved. + +### 6.4 JS instance lifecycle + +`DataWeave` models three states — `"uninitialized" | "ready" | "cleaning-up"` — not a boolean, +because a boolean cannot represent the window during which `cleanup()` has started but +`ffi.cleanup()` has not settled: + +- **`initialize()`** — `ready` → no-op; `cleaning-up` → **throws** `DataWeaveError("Cannot + initialize while cleanup is in progress; await cleanup() first.")`; `uninitialized` → does the + load/create-engine work, `state = "ready"` on success. On engine-creation failure *after* + `ffi.initialize()` succeeded, the rollback release is modeled as pending state: `state` goes + `cleaning-up` and `cleanupPromise` is assigned the `ffi.cleanup()` rollback (with a + `.catch(() => {})` so an un-awaited rollback never becomes an unhandledRejection), so a + concurrent `initialize()` is deterministically rejected instead of racing a fresh isolate + against the in-flight release. `initialize()` and `run()` stay **synchronous** (an async + signature would be an API break). +- **`run()` / `runStreaming()` / `runTransform()`** — gated by `ensureReady()`: throw + `DataWeaveError` unless `state === "ready"`, so the internal `engineHandle === null` cleanup + window is unreachable by any public method (defense-in-depth behind the C admission check). + `runTransform` additionally re-checks `ensureReady()` **after** `await createChunkReader(input)` + (async input pre-buffering can span arbitrary time; the instance may be cleaned up during it) so + a misused instance gets a synchronous `DataWeaveError` rather than a resolved `Unknown engine + handle` envelope. +- **`cleanup()` / `doCleanup()`** — set `state = "cleaning-up"` synchronously *before* + `ffi.destroyEngine` / `ffi.cleanup` (the key ordering). It always runs `await ffi.cleanup()` + even if `destroyEngine()` throws (a real path — wrong-thread destruction throws synchronously), + so a throwing destroy cannot strand this env's init reference; the destroy error is re-thrown + after the release. `engineHandle` is cleared regardless so a retry cannot double-destroy. + Overlapping calls coalesce on `this.cleanupPromise` (one native teardown). `cleanup()` returns + `Promise`. + +**Module-level convenience API** (`run`/`cleanup`) drives a lazily-created singleton: +- `getGlobalInstance()` initializes a **local candidate** and publishes `globalInstance` only after + `initialize()` succeeds — a failed first init leaves the singleton null so the next call retries + cleanly, instead of poisoning it into permanent "not initialized". +- Process exit hooks (`beforeExit` async-drains, `exit` best-effort sync) are registered **once + per process** (module-scoped `exitHooksRegistered`, never reset), not per singleton, so + init→cleanup→reinit cycles don't accumulate listeners. `exit` is documented as best-effort: + Node does not emit it for termination signals (SIGTERM/SIGKILL) or all fatal modes; callers + needing guaranteed graceful shutdown register and await their own signal handlers. +- Module-level `cleanup()` coalesces overlapping calls via a module-scoped `cleanupPromise` (it + nulls `globalInstance` synchronously so new work builds a fresh instance, but overlapping + `cleanup()`s await the same drain and resolve only when native teardown finishes). + +### 6.5 Robustness of native allocation and streaming + +- **OOM safety.** Every allocation in the streaming/transform setup, worker, and callback paths + (`calloc`/`malloc`/`strdup`/`memcpy`, and every `napi_create_string_utf8`/ + `napi_create_threadsafe_function`/`napi_create_promise`) is NULL/status-checked before use. + Setup-phase failures throw a synchronous `napi_throw_error(env, NULL, "OOM")` (matching + `napi_run_script_engine`) and unwind `g_active_ops` + the engine pin with no double-free + (`calloc`-zeroed `w` makes the free-set `free(NULL)`-safe). Worker-thread OOM produces a + **terminal error JSON result** (a static `{"success":false,"error":"Out of memory"}` string when + the copy itself failed, flagged so it is never `free()`d), never a hung promise. +- **Argument validation.** Every FFI-facing entrypoint checks the status of every + `napi_get_value_*` conversion (handle `int64`, string size-probes and fills, `napi_typeof` for + nullable args) and throws before using the converted value, so a raw addon caller cannot turn a + malformed argument into an uninitialized native input. `inputCharset` is nullable + (`string | null | undefined`); any other type is rejected rather than silently coerced. +- **Stream error propagation.** `streamFromNative` handles **both** settlement branches of the + native `start()` promise: on rejection it records the error, marks completion, and wakes every + parked `next()` consumer (otherwise the generator hangs forever and the rejection is unhandled), + then re-throws after draining any chunks that arrived first. Rejection is tracked by a dedicated + `startRejected` boolean, not a value sentinel, so `Promise.reject(undefined)` propagates + correctly. + +## 7. Architecture (layer map) + +### Layer 1 — Java (`native-lib/src/main/java/org/mule/weave/lib/`) -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. +- **`ScriptRuntime.java`** — from static singleton to per-instance + a + `ConcurrentHashMap` registry with `register`/`get`/`destroy` and an + `AtomicLong` handle allocator. The resolver is bound once at construction (immutable for the + instance's lifetime); the `static setResolver` write-once mutation is removed. + `compositeResolver()` / `createModuleComponentsFactory()` become instance methods. + `getInstance()` is **kept** returning a lazily-created default (ClassLoader-only, handle-less) + instance so the resolver-less legacy entrypoints used by Python are untouched. +- **`CallbackWeaveResourceResolver.java`** — stores a `PointerBase ctx` alongside the callback, + forwarded on every `callback.invoke(...)`; constructor `(ResolveModuleCallback, PointerBase ctx)`. +- **`NativeCallbacks.java`** — `ResolveModuleCallback` gains a `ctx` parameter + (`invoke(IsolateThread, PointerBase ctx, CCharPointer modulePath)`), mirroring the existing + `WriteCallback`/`ReadCallback` ctx idiom. This is what lets one shared native callback dispatch + to the correct per-handle JS resolver on the C side. +- **`NativeLib.java`** — adds handle-based lifecycle + execution entrypoints (`create_engine`, + `create_engine_with_resolver`, `destroy_engine`, `run_script_engine`, + `run_script_callback_engine`, `run_script_input_output_callback_engine`) resolving via + `ScriptRuntime.get(handle)`. The legacy singleton entrypoints (`run_script`, + `run_script_callback`, `run_script_input_output_callback`) are **preserved unchanged** for + Python. The old `*_with_resolver` entrypoints are **removed** (see §9). ### 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`). +- Per-handle resolver bridge state in `g_bridges` (§6.3) instead of a process-global bridge. +- **Resolver dispatch:** `createEngineWithResolver` passes the bridge record's address as the + `ctx`; when Java invokes `resolve_module_callback(thread, ctx, path)`, C casts `ctx` back to the + bridge and calls its JS resolver **synchronously on the JS thread** (no + `napi_threadsafe_function` — the create call runs synchronously on the calling JS thread, so the + original deadlock rationale still holds). A per-handle `owner`-thread guard fails closed to "not + found" if `resolve_module_callback` is reached from a non-owner thread (e.g. a streaming worker). +- All of §6's machinery: `g_active_ops`, the `TEARDOWN_*` state machine, `g_teardown_cancelled`, + `g_teardown_needed`, the per-env `g_env_recs` list, the `g_bridges` list, admission pinning, and + the split finalize. +- N-API methods: `createEngine`, `createEngineWithResolver`, `destroyEngine`, and handle-taking + `runScriptEngine`, `runScriptStreamingEngine`, `runScriptTransformEngine`. ### Layer 3 — Node TypeScript (`native-lib/node/src/`) -**`ffi.ts`** — add `createEngine()`, `createEngineWithResolver(resolver)`, `destroyEngine(handle)`, and handle-taking `runScriptEngine`, `runScriptStreamingEngine`, `runScriptTransformEngine`. Remove `runWithResolver`. +- **`ffi.ts`** — `createEngine`, `createEngineWithResolver`, `destroyEngine`, and handle-taking + `runScriptEngine`, `runScriptStreamingEngine`, `runScriptTransformEngine`. `runWithResolver` + removed. +- **`dataweave.ts`** — `DataWeave` owns a `private engineHandle`, the three-state lifecycle + machine, and the module-level singleton/exit-hook/coalescing logic (§6.4). `initialize()` calls + `ffi.createEngineWithResolver(this.resolveModule)` or `ffi.createEngine()`; run methods route + through the handle-based FFI (one code path per method, parameterized by handle); + `cleanup()` calls `ffi.destroyEngine` then `ffi.cleanup`. +- **`stream.ts`** — `streamFromNative` error propagation (§6.5). **`reader.ts`** — + `createChunkReader` pre-buffers async inputs (the native read callback is synchronous and cannot + await), which is why `runTransform` re-checks readiness after it. -**`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 +## 8. Data Flow ``` new DataWeave({ resolveModule: A }).initialize() + → ffi.initialize() // env init record for this env: init_refs 0→1, g_ref_count++ → 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 + → addon.c: allocate bridge_A { env, ref to A, owner=thisThread, in_flight:0 }; register env hook + → Java: new CallbackWeaveResourceResolver(callback, ctx=&bridge_A); + new ScriptRuntime(resolver) → handle_A = register(rt) + → handle_A stored as this.engineHandle dwA.run(script importing "custom/lib.dwl") → ffi.runScriptEngine(handle_A, script, inputs) + → addon.c: admission (g_active_ops++, in_flight++ on bridge_A) → attach → fn_run_script_engine → 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 + composite resolver: ClassLoader miss → callback.invoke(thread, ctx=&bridge_A, "custom/lib.dwl") + → C: resolve_module_callback casts ctx→bridge_A; thread==owner? yes → call resolver A synchronously + → result flows back, script compiles; on completion: in_flight--, g_active_ops-- -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 +new DataWeave({ resolveModule: B }).initialize() → handle_B, bridge_B (independent resolver + owner) +dwB.run(...) → resolves via resolver B only; A's cache untouched; no cross-talk +``` -- **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. +## 9. Error Handling & Backward Compatibility + +- **Module not found / resolver throws:** resolver returns `null` → composite resolver falls + through → standard DataWeave "unable to resolve module" error (unchanged, scoped per-handle). +- **Wrong-thread resolver invocation:** per-handle `owner` check fails closed to "not found" + rather than touching `napi_env` cross-thread. +- **Invalid/unknown/destroyed handle:** `ScriptRuntime.get(handle)` returns null → the entrypoint + returns `{"success":false,"error":"Unknown engine handle"}` (resolved for async ops, returned as + the JSON string for sync `run()`), never an NPE. +- **Admission / argument / allocation failures:** synchronous `napi_throw_error` (generic Error); + worker-thread OOM → terminal error JSON. Never `napi_reject_deferred` (absent from `addon.c`). +- **Python binding:** zero changes — it never called the removed `*_with_resolver` entrypoints and + continues on `getInstance()`. +- **Node, resolver-less / single-resolver usage:** behaves identically; the new code path is a + functional superset. +- **Intended breaking changes (pre-GA, no shims):** the dwlib C ABI drops the exported + `run_script_with_resolver` / `run_script_callback_with_resolver` / + `run_script_input_output_callback_with_resolver` entrypoints and replaces them with the + `*_engine` set, and adds a `ctx` parameter to `ResolveModuleCallback`. dwlib is consumed by this + repo's own Python and Node bindings in lockstep. `DataWeave.cleanup()` changes from `void` to + `Promise`. These are documented in the PR, not shimmed. + +## 10. Testing Strategy + +- **Java unit** (`native-lib:test`): two `ScriptRuntime` instances with different in-memory + resolvers each resolve only their own module; `destroy()` removes an instance. (The + `@CEntryPoint` methods can't be driven from a hosted JVM — GraalVM word types don't box — so + handle-based entrypoint coverage lives at the Node integration layer.) +- **Node integration** (`native-lib:nodeTest`, real addon, `vi.mock` of `ffi` forbidden): the core + W-23692110 regression (two independent resolvers in one process); unknown/destroyed-handle + envelopes for all three run paths; the deadlock regression (active stream + `cleanup()` + + concurrent `run()` resolves within a bounded timeout); same-instance lifecycle + (init/run/transform during the cleanup window); ref-count-proxy teardown assertions (a + subsequent raw engine call throwing `/not initialized/` proves the isolate reached zero refs); + and `worker_threads` Worker lifecycle — resolver-backed and resolver-less engines in a Worker, + per-Worker resolver binding, **normal Worker exit without `cleanup()`** (the abandonment / + init-reference-release proof: N Workers each `initialize()` + create N≥3 engines and exit; the + main thread's engine must survive and final teardown must reach exactly zero), + `Worker.terminate()` mid-life, and explicit in-Worker `cleanup()`. +- **Unit** (`ffi` mocked, no dwlib): `DataWeave.initialize()` ref-count/rollback safety; module + singleton poisoning recovery; module + instance `cleanup()` coalescing; `stream.ts` rejection + propagation (parked consumer wakes and throws; buffered-then-reject drains first); `runTransform` + post-pre-buffer re-check; `doCleanup()` releasing the init reference even when `destroyEngine` + throws. +- **Documented posture on non-forceable paths.** Allocator/N-API fault injection and exact + cross-thread teardown interleavings are **not deterministically forceable** from JS/vitest (no + addon-boundary fault-injection hook — deliberately not added, YAGNI/test-only surface). Their + correctness rests on the C-level invariants in §6, verified by code reasoning and adversarial + review; the Worker tests are best-effort probabilistic guards (green on fixed code, cannot + false-fail on it). This is a standing, documented decision. +- **Native image build** (`native-lib:nativeCompile`) stays green. + +## 11. Follow-Up Work + +- **Python binding parity:** port the handle-based `create_engine`/`run_script_engine` API to the + Python binding so both bindings share one mental model (child GUS item under W-23692110). The + broad Python-binding modernization currently riding along in this PR is acknowledged as a + scope-bundling and deferred to its own follow-up PR rather than split mid-review. +- **Streaming/transform + custom-module resolution** across the background-thread boundary remains + a separate, not-yet-scoped effort (unrelated to the singleton fix). ## 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` | +| Singleton root cause | `native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java` | | 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` | +| WriteCallback/ReadCallback ctx idiom | `native-lib/src/main/java/org/mule/weave/lib/NativeCallbacks.java` | +| Concurrency & lifecycle machinery | `native-lib/node/src/addon.c` | +| JS lifecycle / singleton / exit hooks | `native-lib/node/src/dataweave.ts` | +| Stream error propagation | `native-lib/node/src/stream.ts` | +| Node binding API + lifecycle docs | `native-lib/node/README.md`, `native-lib/node/docs/external-modules.md` | +| Original external-modules design | `docs/superpowers/specs/2026-08-04-nodejs-external-modules-design.md` | + +## Appendix: Hardening provenance + +The concurrency & lifecycle model (§6) converged over a series of code-review rounds; each round's +decisions are folded into the sections above. This map exists only for git archaeology — the +per-round design documents were consolidated into this file. + +| Round(s) | Area folded into | Decision | +|----------|------------------|----------| +| Feature (08-07) | §1–§5, §7–§9 | Object-level engines behind opaque handles; per-handle resolver bridge; ABI redesign. | +| 5 (08-11) | §6.2 | `cleanup()`-during-active-stream deadlock → async teardown + waiter thread + `TEARDOWN_*` adoption. | +| 6 (08-14) | §6.3, §6.4 | JS three-state lifecycle; atomic streaming/transform admission under `g_mutex`; handle-read validation. | +| 7 (08-18 ffi-sweep) | §6.3, §6.5, §9 | Atomic admission for sync `run()`; uniform `napi_get_value_*` status checks; docs await `cleanup()`. | +| 8 (08-18 oom-setup) | §6.5 | OOM-safe streaming/transform setup allocations. | +| 9 (08-18 engine/worker-oom) | §6.3, §6.5 | Deferred registry removal for all engines; worker/callback OOM → terminal result; N-API-create checks. | +| 10 (08-19 dangling-ctx) | §6.3, §6.4 | Env-cleanup removes the Java registry entry (`deferred_registry_remove`); shutdown-doc accuracy. | +| 11 (08-19 engine-pin) | §6.1, §6.3, §6.4 | Env hook + owner-guard for every engine; admission-time engine pin in all 3 paths; register-once exit hooks. | +| 12 (08-19 worker-ref-leak) | §6.1, §6.3, §6.4 | Init-reference release on abandoned env; teardown-guarded split finalize; module `cleanup()` coalescing; `runTransform` re-check; all-or-nothing engine creation. | +| 13 (08-20 per-env init) | §6.1 | Per-`napi_env` init-reference ownership; `g_ref_count == Σ init_refs`. | +| 14 (08-21 review5) | §6.2, §6.3 | Engine-creation admission requires an owned init reference; `g_teardown_needed` retry flag; `doCleanup()` releases the ref even when destroy throws. | +| 15 (08-21 review6) | §6.2, §6.4, §6.5 | Singleton-poisoning fix; stream rejection propagation; teardown return-code checks; init-driven stranded-teardown retry. | +| 16 (08-24 review7) | §6.2, §6.4, §6.5, §9 | Detach on failed teardown; init-hook-failure retry arming; observable init rollback; `Promise.reject(undefined)` fix; lifecycle-doc accuracy. | 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 deleted file mode 100644 index 4ae676c2..00000000 --- a/docs/superpowers/specs/2026-08-11-cleanup-teardown-deadlock-fix-design.md +++ /dev/null @@ -1,101 +0,0 @@ -# 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. 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 deleted file mode 100644 index 7d67e352..00000000 --- a/docs/superpowers/specs/2026-08-14-instance-lifecycle-state-fix-design.md +++ /dev/null @@ -1,111 +0,0 @@ -# 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. 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 deleted file mode 100644 index 92eab942..00000000 --- a/docs/superpowers/specs/2026-08-18-engine-lifecycle-and-worker-oom-hardening-design.md +++ /dev/null @@ -1,116 +0,0 @@ -# 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 - -**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. - -- **#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: baseline **878 passed / 59 skipped / 0 failed**, unchanged (no new test — see §4). -- `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. -- **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. 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 deleted file mode 100644 index 4fa2c0a1..00000000 --- a/docs/superpowers/specs/2026-08-18-ffi-admission-and-conversion-sweep-design.md +++ /dev/null @@ -1,120 +0,0 @@ -# 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. 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 deleted file mode 100644 index 855f9fdc..00000000 --- a/docs/superpowers/specs/2026-08-18-oom-safe-streaming-transform-setup-design.md +++ /dev/null @@ -1,127 +0,0 @@ -# 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). diff --git a/docs/superpowers/specs/2026-08-19-engine-pin-and-cleanup-hook-hardening-design.md b/docs/superpowers/specs/2026-08-19-engine-pin-and-cleanup-hook-hardening-design.md deleted file mode 100644 index fb933c9e..00000000 --- a/docs/superpowers/specs/2026-08-19-engine-pin-and-cleanup-hook-hardening-design.md +++ /dev/null @@ -1,147 +0,0 @@ -# Engine-Pin & All-Engines-Cleanup Hardening — Round 11 (W-23692110) - -**Status:** Design approved, ready for planning. - -**Source reviews:** `docs/pr-157-follow-up-andy-code-review-11.md` (2 findings) and `docs/pr-157-follow-up-code-review-2.md` (6 findings). All overlapping; deduplicated into 6 work items below. Verified against live source at commit `50b2930` (round-10 tip). - -**Scope:** `native-lib/node` only — `src/addon.c`, `src/dataweave.ts`, and Node integration tests under `tests/`. Do **not** touch `native-lib/python/**`, the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`), `dw_napi_run_script`, or `ScriptRuntime.getInstance()`. The Java side (`NativeLib.java`, `ScriptRuntime`) is read for context but not modified. - -## Problem - -The 11th "andy" review and a second general code review together raise 7 findings; 6 are real and one (the C ABI break) is a documented-by-design decision, not a code change. - -### #1 (P1) — Resolver-less engines leak on Worker exit (no env cleanup hook) - -`napi_create_engine` (resolver-less, `addon.c:1644`) links a per-engine record into `g_bridges` but registers **no** `napi_add_env_cleanup_hook`; only `napi_create_engine_with_resolver` does (`addon.c:1695`). A Worker (or the main thread) that creates a resolver-less `DataWeave` instance and terminates without calling `destroyEngine()` strands: the native `engine_bridge_t` record, the Java `ScriptRuntime` registry entry, and the native-library reference (`g_ref_count` never decremented for that instance). Repeated Worker create/terminate cycles leak engines and prevent isolate teardown. - -### #2 (P1) — Streaming/transform admission reserves the isolate before pinning the engine - -`napi_run_script_streaming_engine` reserves `g_active_ops++` at `addon.c:841` but does not pin the engine (`bridge_begin_op`) until `addon.c:925` — a wide window (arg extraction, `w`/tsfn/promise allocation) in which a concurrent Worker's `destroyEngine(handle)` observes `in_flight == 0`, unlinks and frees the bridge, and removes the Java registry entry. The already-admitted op then spawns its worker with `w->bridge` pointing at freed memory (or NULL after the fact) and can fail with "Unknown engine handle" or dereference the freed bridge in `resolve_module_callback`. `napi_run_script_transform_engine` has the identical shape (`g_active_ops++` at `addon.c:1324`, `bridge_begin_op` at `addon.c:1429`). - -### #3 (P1) — Synchronous `runScriptEngine` never pins the engine at all - -`napi_run_script_engine` (`addon.c:1791-1879`) increments `g_active_ops` (`:1847`) to protect the isolate but never calls `bridge_begin_op`. A concurrent Worker can `destroyEngine(handle)` while this synchronous call is attaching to Graal or executing `fn_run_script_engine` (`:1858`); for a resolver-backed engine that frees the bridge Java still holds as the resolver ctx → `resolve_module_callback` dereferences freed memory. `g_active_ops` gates only the *global isolate*, not the *per-engine* record. - -### #4 (documented, not a code change) — dwlib C ABI break - -This branch removes the exported `run_script_with_resolver` / `run_script_callback_with_resolver` / `run_script_input_output_callback_with_resolver` entrypoints (present on master) and replaces them with `create_engine` / `create_engine_with_resolver` / `destroy_engine` / `run_script_engine` / `run_script_callback_engine` / `run_script_input_output_callback_engine`, and inserts a `ctx` parameter into the `ResolveModuleCallback` signature. The three legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`) are preserved. This is the intended multi-engine redesign; dwlib is consumed by this repo's own Python and Node bindings in lockstep. **Decision (user):** document the break in the PR/spec; do NOT add compatibility shims. No code change in this round. - -### #5 (Medium) — Process exit listeners accumulate across singleton re-creation - -`getGlobalInstance` (`dataweave.ts:289-304`) attaches a `beforeExit` and an `exit` listener every time it (re)creates `globalInstance`; the module-level `cleanup()` (`:341-353`) nulls the singleton but never removes those listeners. Repeated init→cleanup→reinit cycles accumulate two listeners per cycle and eventually emit Node's `MaxListenersExceededWarning`. - -### #6 (Medium) — Unknown-handle coverage does not exercise the native entrypoints - -`ScriptRuntimeTest.unknownEngineHandleProducesExactErrorJson` (`ScriptRuntimeTest.java:677-683`) only asserts on the `UNKNOWN_ENGINE_HANDLE_JSON` constant and `ScriptRuntime.get`; it deliberately cannot invoke the `@CEntryPoint` methods (GraalVM word types don't box in a hosted JVM). So no test drives the `*_engine` entrypoints against unknown/destroyed handles through the real addon, nor exercises the cross-Worker run-vs-destroy race in #2/#3. - -## Design - -### 1. Register an env cleanup hook for every engine + extend the owner-thread destroy guard (finding #1) - -**Cleanup hook for all engines.** In `napi_create_engine`, store `rec->env = env` and register `napi_add_env_cleanup_hook(env, bridge_env_cleanup, rec)` — exactly as `napi_create_engine_with_resolver` already does. `bridge_env_cleanup` and `bridge_finalize` already handle a resolver-less record correctly: `resolver_js == NULL` → skip `napi_delete_reference`, still unlink from `g_bridges`, remove the Java registry entry (round-10 `do_registry_remove=true`), and free the record. So the round-10 registry-removal path now also reclaims resolver-less engines abandoned by a terminating env. `rec->owner` is already recorded (`addon.c:1643`). - -**Owner-thread destroy guard extends to all engines (approved contract change).** Registering a cleanup hook gives every engine env-affine state: the hook is bound to its creating env, and `napi_remove_env_cleanup_hook` (called by `destroyEngine` before an early free, `addon.c:1738`) is only valid on that owner env/thread. Today the cross-thread guard in `napi_destroy_engine` (`addon.c:1703`) fires only when `owned->resolver_js != NULL`. Change it to fire for **any** record (`owned != NULL`), so a resolver-less engine is also only destroyable from its creating thread. - -- **Why this is safe:** every JS `DataWeave` instance is constructed and destroyed on a single thread (its owning env), so the guard never rejects a legitimate call. This reverses the round-9 invariant "resolver-less engines remain destroyable from any thread," which was only ever exercised by the (now-closed) case of a resolver-less engine having no env-affine state. -- **Why the alternative is worse:** leaving the guard resolver-only while registering a hook means a cross-thread `destroyEngine` would either skip `napi_remove_env_cleanup_hook` (leaving Node holding a hook pointing at a freed record → UAF at env teardown) or call it cross-thread (undefined behavior). Extending the guard is the correct closure. - -Update the guard's comment block (`addon.c:1683-1700`) to state the guard now keys on "a record exists" because every engine carries an env cleanup hook, not just resolver `napi_ref` state. - -**`bridge_finalize` napi_ref deletion stays resolver-gated** (`addon.c:237`: `resolver_js != NULL && env != NULL`) — a resolver-less record has no ref to delete; only the hook registration and the owner guard change. - -### 2. Fold engine lookup + `in_flight++` into the locked admission transaction (findings #2, #3) - -Introduce a locked-admission variant so the per-engine pin happens in the **same** critical section as the `g_active_ops` reservation and lifecycle check, before any window a concurrent `destroyEngine` could use. - -**New helper** (`addon.c`, near `bridge_begin_op`): -```c -// Increment this engine's in_flight while g_mutex is ALREADY held (admission -// transaction). Caller must hold g_mutex. Returns the record (NULL if unknown -// handle -- nothing to pin, worker will surface "Unknown engine handle"). -static engine_bridge_t* bridge_begin_op_locked(long long handle) { - engine_bridge_t* b = bridge_find(handle); - if (b != NULL) b->in_flight++; - return b; -} -``` -`bridge_begin_op` stays for callers that need the self-locking form; internally it becomes `lock; b = bridge_begin_op_locked(handle); unlock; return b;`. - -**Streaming / transform:** in the admission critical section (`addon.c:835-842` / `1318-1325`), after `g_active_ops++`, also call `w->bridge = bridge_begin_op_locked(handle64)` **before** unlocking, and delete the later standalone `bridge_begin_op` call (`:925` / `:1429`). Every existing failure path between admission and the worker spawn (conversion errors, OOM, tsfn/promise creation failures, `spawn_rc != 0`) must now **also** release the pin. Because those paths currently only do the `g_active_ops--` release, each must additionally call `bridge_end_op(w->bridge, /*env_still_alive=*/true)` (the env is live on the JS admission thread) to balance `in_flight` and finalize if a concurrent destroy is now pending. The completion sentinel path is unchanged — it already calls `bridge_end_op`. - -- **Ordering:** with the pin taken under the same lock as the admission check, a concurrent `destroyEngine` either runs entirely before admission (then `bridge_find` in admission returns the record only if not yet destroyed; if already destroyed, the record is gone and the worker surfaces "Unknown engine handle" — no freed access) or entirely after (then `in_flight > 0`, so destroy defers per round-9/10). There is no interleaving where an admitted op observes a freed bridge. -- **Unwind completeness:** the plan must enumerate every early-return between the locked admission and the spawn and add the `bridge_end_op` release, mirroring how each already releases `g_active_ops`. A pin leaked here would wedge `destroyEngine` (never drains) exactly like a leaked `g_active_ops` wedges teardown. - -**Synchronous `runScriptEngine`:** pin the engine for the isolate-touching window. Because this path reserves `g_active_ops` *late* (`addon.c:1840-1848`, after arg extraction), take the pin in that same critical section: -```c -uv_mutex_lock(&g_mutex); -if (!g_initialized || (g_teardown_state != TEARDOWN_NONE && !g_teardown_cancelled)) { ... release, throw ... } -g_active_ops++; -engine_bridge_t* bridge = bridge_begin_op_locked(handle); -uv_mutex_unlock(&g_mutex); -``` -Then release the pin in **both** the attach-failure path and normal completion, alongside the existing `g_active_ops--`. The current post-run `bridge_find` + `resolver_results_free_all` (`addon.c:1860-1863`) uses the pinned `bridge` directly (no second lookup needed; the pin kept it alive). Release ordering at completion: after `resolver_results_free_all` and detach, call `bridge_end_op(bridge, /*env_still_alive=*/true)` — which may finalize a deferred destroy — then the existing `g_active_ops--` broadcast. `bridge_end_op` handles `NULL` (unknown handle) as a no-op. - -- **Sync-path note:** unlike streaming/transform there is no background thread, so `env_still_alive` is always true here (the JS thread runs the whole op). An unknown handle (`bridge == NULL`) still runs `fn_run_script_engine`, which returns the resolved "Unknown engine handle" JSON — behavior unchanged. - -### 3. Register process exit listeners exactly once (finding #5) - -Move the `beforeExit`/`exit` registration out of `getGlobalInstance` so it runs once per module, guarded by a module-scoped `let exitHooksRegistered = false` that is **never reset** (unlike `cleanupStarted`). The listeners already tolerate a null `globalInstance`: `cleanup()` no-ops when `globalInstance` is null, and `cleanupStarted` still coalesces `beforeExit`/`exit` for a given shutdown. So one registration covers every current and future revived singleton, and init→cleanup→reinit cycles no longer accumulate listeners. - -```ts -let exitHooksRegistered = false; -function registerExitHooksOnce(): void { - if (exitHooksRegistered) return; - exitHooksRegistered = true; - process.on("beforeExit", async () => { if (cleanupStarted) return; cleanupStarted = true; await cleanup(); }); - process.on("exit", () => { if (cleanupStarted) return; cleanup(); }); -} -``` -`getGlobalInstance` calls `registerExitHooksOnce()` after `globalInstance.initialize()`. Update the doc comment (`dataweave.ts:267-287`) to say the hooks are registered once for the process, not per singleton. - -### 4. Real *_engine unknown/destroyed-handle + run-vs-destroy tests (finding #6) - -Add **Node integration tests** (real addon, `vi.mock` of `ffi` is forbidden — mirror `tests/integration/independent-engines.test.ts`): - -- **Unknown / destroyed handle envelope:** for each of `runScriptEngine` (sync), `runScriptStreamingEngine`, `runScriptTransformEngine`, invoke against (a) a never-registered handle and (b) a handle whose engine was `destroyEngine`'d, and assert the result is the terminal `{"success":false,"error":"Unknown engine handle"}` envelope (resolved, not thrown for the async ops; the sync op returns the JSON string) and that the process does not crash and no C string leaks (the op resolves/returns cleanly). -- **Cross-Worker run-vs-destroy (findings #2/#3):** spin a `worker_threads` Worker that creates an engine and runs a stream/transform, and from another context destroy/cleanup during the admission window, asserting no crash and a clean terminal result. Note in the test file that this race is **not** deterministically forceable at a fixed interleaving (same limitation rounds 5–10 documented); the test is a best-effort probabilistic guard (loop N iterations) that is green on fixed code and cannot false-fail on it. If a deterministic hook proves infeasible, the test still asserts the unknown/destroyed-handle envelope contract, which is deterministic, and the concurrency correctness rests on the code reasoning in §2. - -These raise the vitest baseline above 878. The plan sets the exact new counts. - -## Testing - -- New Node integration tests per §4 (deterministic envelope assertions + best-effort race guard). -- No Java test change (the `@CEntryPoint` hosted-JVM limitation is real; coverage moves to the Node integration layer against the real addon, which is the correct layer). -- Findings #1/#2/#3 lifecycle correctness that is not deterministically forceable is covered by code reasoning against the invariants in §Design (same documented posture as rounds 5–10). - -## Verification - -- `cd native-lib/node && npm run build:addon` clean (no new warnings in touched regions); `npm run build` (tsc) clean. -- `npm test` green at the new baseline (set in the plan; ≥ 878 + new 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`), `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 (async) or a synchronous `napi_throw_error` (admission/arg/alloc/resource failures) — never `napi_reject_deferred`. -- `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`, every engine record's `in_flight`/`destroy_pending`/`deferred_registry_remove`) is read/written only under `g_mutex`, except the documented lock-free `g_isolate` NULL-check in `bridge_finalize`. -- 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. -- Every engine now carries an env cleanup hook, so the owner-thread `destroyEngine` guard keys on "a record exists," not on resolver `napi_ref` state. `bridge_finalize`'s `napi_ref` deletion stays resolver-gated. -- Per-engine `in_flight` and global `g_active_ops` stay **distinct** counters (per-handle registry drain vs. global isolate teardown) — not merged. -- Preserve every round-1..10 fix: coalesced `cleanup()`, the JS three-state lifecycle machine, the `TEARDOWN_*` state machine + `napi_initialize` adoption path (incl. round-7's `g_teardown_cancelled` carve-out), the worker-thread `g_active_ops` decrement, the atomic admission blocks, the round-6 handle-read validations, round-7 conversion-status checks, round-8 setup-allocation NULL checks, round-9 worker/callback OOM + N-API-create checks + deferred registry removal, round-10 env-cleanup registry removal + `g_isolate`-guarded finalize. -- Node vitest baseline currently **878 passed / 59 skipped / 0 failed**; this round raises it (new tests) and must stay green. - -## Rejected Alternatives - -- **#1 via a teardown-time sweep of `g_bridges` instead of per-engine hooks.** Rejected: a global sweep would run on whatever thread triggers isolate teardown, deleting env-affine records off their owner thread — the exact thread-affinity violation the per-env-hook design (F2) exists to avoid. Per-engine hooks dispose each record on its own env's thread. -- **#1 leaving the owner guard resolver-only while adding a hook to resolver-less engines.** Rejected: `napi_remove_env_cleanup_hook` on an early destroy would then run cross-thread (UB) or be skipped (dangling hook → UAF at env teardown). The guard must cover every hooked engine. -- **#2/#3 via a JS-side lease (await per-engine drain before destroy).** Rejected (same as round-9): no per-engine "await my ops" primitive exists at the JS layer; `run()` is synchronous and streaming is an abandonable generator. The authoritative pin lives in C, taken atomically at admission. -- **#2/#3 by re-looking-up the bridge after admission.** Rejected: a second lookup still races destroy in the gap; only holding the pin (`in_flight++`) under the admission lock closes the window. -- **#3 pinning the sync run at the top (before arg extraction).** Rejected: the arg-extraction/OOM path does not touch the engine, so pinning there only adds unwind sites; pin in the same late critical section as `g_active_ops`, matching the existing round-7 reasoning for that path. -- **#4 compatibility shims for the removed `*_with_resolver` ABI.** Rejected (user decision): dwlib is consumed by this repo's own bindings in lockstep; the redesign intentionally replaces that ABI. Documented as an intended break; no shims. -- **#5 removing listeners in `cleanup()` (retain references, `removeListener`).** Rejected in favor of register-once: simpler, no per-instance bookkeeping, and the hooks already tolerate a null singleton, so a single lifetime registration is correct and leak-free. -- **#6 adding a native fault-injection hook to force the race deterministically.** Rejected as test-only production surface (YAGNI), consistent with rounds 6–10; the deterministic envelope assertions plus a best-effort probabilistic race guard are the coverage. -- **Modifying the Java `ScriptRuntime` registry to tolerate late lookups.** Rejected as out of scope and the wrong layer — the C addon must hold the pin so the registry entry is never removed under an admitted op. diff --git a/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md b/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md deleted file mode 100644 index fc80c61b..00000000 --- a/docs/superpowers/specs/2026-08-19-worker-ref-leak-and-teardown-race-hardening-design.md +++ /dev/null @@ -1,203 +0,0 @@ -# Worker Ref-Leak & Teardown-Race Hardening — Round 12 (W-23692110) - -**Status:** Design approved, ready for planning. - -**Source review:** `docs/pr-157-follow-up-code-review-3.md` (9 findings), verified against live source at commit `e1b9ee0` (round-12 tip; round-11 code + the #7 doc fix). Two findings are already resolved and are out of scope for the implementation round below: - -- **#7 (docs)** — the two `cleanup()` README bugs (false "fatal signals" claim; over-broad "drains anywhere in the process" claim) are fixed in `e1b9ee0`. -- **#1 (dwlib C ABI break)** — factual and by design. The project is **pre-GA**; the multi-engine redesign intentionally replaces the `run_script_*_with_resolver` exports with the `*_engine` entrypoints and adds `ctx` to `ResolveModuleCallback`. No compatibility shims, no major-version ceremony required at this stage. **Decision (user): OK, not addressed.** No code change. - -**Scope:** `native-lib/node` only — `src/addon.c`, `src/dataweave.ts`, and Node integration tests under `tests/`. Do **not** touch `native-lib/python/**`, the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`), `dw_napi_run_script`, or `ScriptRuntime.getInstance()`. The Java side (`NativeLib.java`, `ScriptRuntime`) is read for context but not modified. - -## Problem - -Round 11 gave every engine an env cleanup hook so an abandoned Worker's env teardown reclaims the engine record and Java registry entry. A follow-up review found that reclamation is **incomplete** (the init reference leaks — #2) and that the deferred finalize path it relies on has a **teardown race** (#3), plus three medium code issues (#4, #5, #6) and two test-coverage gaps (#8, #9). All seven are verified real against live source. - -### #2 (High) — Abandoned-env teardown leaks the initialization reference - -Every `DataWeave` instance calls `ffi.initialize()` on construction (`dataweave.ts:87`), which does `g_ref_count++` (`addon.c:506`; also the fast-path `:477` and the adoption path `:463`). The only `g_ref_count--` is in `napi_cleanup` (`addon.c:2153`), reached from JS via `ffi.cleanup()`. When a Worker (or the main env) terminates **without** calling `cleanup()`, the env cleanup hook `bridge_env_cleanup` → `bridge_finalize` (`addon.c:252-293`) frees the engine record, deletes the napi_ref, and removes the Java registry entry — but never decrements `g_ref_count`. So the shared isolate's reference count never returns to zero and the isolate is never torn down. Repeated Worker create/terminate cycles without explicit `cleanup()` keep the isolate alive indefinitely. This directly contradicts the round-11 comment at `addon.c:1686` claiming the hook prevents leaking "the native-lib reference." - -### #3 (High) — Deferred registry removal attaches to an isolate that teardown may be destroying - -`bridge_finalize` (`addon.c:224-243`) reads `g_isolate` **without `g_mutex`** and calls `fn_attach_thread(g_isolate, &thread)` then `fn_destroy_engine(thread, …)` to remove the Java registry entry. The streaming/transform worker threads release their `g_active_ops` reservation (`addon.c:745-748` for streaming; the transform analogue) **before** the completion sentinel runs `bridge_end_op` → `bridge_finalize`. Once `g_active_ops` reaches 0, the `teardown_waiter_thread_fn` is free to begin `graal_tear_down_isolate()`. So the sequence - -1. worker releases `g_active_ops` (now 0), -2. waiter wakes, transitions `TEARING_DOWN`, calls `graal_tear_down_isolate()`, -3. sentinel's `bridge_finalize` reads `g_isolate` (passes the NULL check because step 2's clear hasn't landed / is racing) and calls `fn_attach_thread` on an isolate being destroyed - -is possible. This is **both** a C data race on `g_isolate` (lock-free read racing a write under lock) **and** an attach-vs-teardown TOCTOU. The round-11 whole-branch review adjudicated the *spawn-failure* variant benign because it runs with the reservation still held / isolate guaranteed alive; the **deferred-finalize** variant is not benign because it can run after `g_active_ops` is already 0. - -### #4 (Medium) — `runTransform` can dispatch on an engine cleaned up during input pre-buffering - -`runTransform` (`dataweave.ts:221-248`) calls `ensureReady()` (`:226`), then `await createChunkReader(input)` (`:234`) — a suspension point that, for async input, can take arbitrary time — then dispatches with `this.engineHandle!` (`:238`). A caller can start the transform, `cleanup()` the instance while the reader is pre-buffering, then resume into a dispatch with a cleared/destroyed handle. The round-11 C admission pin makes this **memory-safe** (worst case is a resolved `Unknown engine handle` envelope, not a UAF), but the readiness check is stale by the time of dispatch. - -### #5 (Medium) — Module-level `cleanup()` does not coalesce overlapping calls - -The module-level `cleanup()` (`dataweave.ts:371-383`) nulls `globalInstance` **synchronously** before awaiting `instance.cleanup()`. A second overlapping call sees `globalInstance === null` and resolves immediately, even though the first call's native teardown is still draining. The instance-level `cleanup()` correctly coalesces via `this.cleanupPromise` (`:131`); the module wrapper does not, so its contract ("resolves once native teardown has finished") is violated for the second caller. - -### #6 (Medium) — Ignored `napi_add_env_cleanup_hook` status leaks a returned handle - -`napi_create_engine` (`addon.c:1692`) and `napi_create_engine_with_resolver` (`addon.c:1743`) ignore the return status of `napi_add_env_cleanup_hook`. If registration fails, the function still returns a usable handle, but the engine now has **no** env cleanup hook, so an abandoned Worker permanently strands its engine record, Java registry entry, and (per #2) init reference. Engine creation is not all-or-nothing. - -### #8 (Medium) — The run-vs-destroy test cannot prove the pin guarantee - -`engine-handle-contract.test.ts:177-231` fires `destroyEngine()` on the same JS thread **after** admission, then accepts *either* success *or* the `Unknown engine handle` envelope. On fixed code the pin was already acquired at admission, so this ordering must deterministically succeed; accepting the error envelope means a regression that removes the pin still passes the test. The assertion is too weak to detect the very regression it exists to guard. - -### #9 (Medium) — No Worker integration coverage for the documented per-Worker model - -The README (`README.md:445-456`) instructs users to construct a separate resolver-backed `DataWeave` instance per Worker, but no test creates a `worker_threads` Worker. There is no coverage for resolver-backed/resolver-less engines inside a Worker, normal Worker exit without `cleanup()` (the #2 scenario), `Worker.terminate()`, independent module resolution, or subsequent main-thread initialization. - -## Design - -The two correctness fixes (#2, #3) share the teardown-coordination trio `g_ref_count` / `g_active_ops` / the lock-free `g_isolate` read. Per the approved approach, the fix is **robust but bounded**: close the race for real and track the init reference properly, using **targeted consolidation of only the ref-release/finalize step** where sharing is warranted — without re-opening the broader coordination substructure (the round-5 `TEARDOWN_*` state machine + adoption path, the round-9/10 deferred-removal logic) that took six rounds to stabilize. - -### 1. Release the init reference on abandoned-env teardown (#2) - -**New helper — `release_isolate_ref_locked()`** (caller holds `g_mutex`). It carries the exact "one initialization reference is going away" logic that `napi_cleanup` Case 5 already implements: decrement `g_ref_count`; if it reaches 0, drive the **existing** teardown decision (immediate teardown when `g_active_ops == 0`, or queue the `teardown_waiter` when `g_active_ops > 0`, setting `TEARDOWN_PENDING_WAIT`). This is *targeted* consolidation — only the decrement-and-maybe-teardown step, not the surrounding machinery. `napi_cleanup` is refactored to call it (behavior-preserving); the env-cleanup path calls it too. - -**Env-cleanup path releases the ref.** `bridge_env_cleanup` reclaims an abandoned env's engine. Because that env's `initialize()` did one `g_ref_count++` per engine it created, the reclamation must do one matching release per engine: - -- In `bridge_env_cleanup`'s **direct finalize** path (`in_flight == 0`, `addon.c:279-293`): after finalizing the record, call `release_isolate_ref_locked()` once, under `g_mutex`. -- In its **deferred-drain** path (`in_flight > 0`, marks `destroy_pending`/`deferred_registry_remove`, `addon.c:269-278`): the last op to drain (`bridge_end_op` → finalize) must perform the release. Thread a flag on the record — `deferred_ref_release` — set alongside `deferred_registry_remove` in the env-cleanup deferral, so `bridge_end_op` knows to release the ref exactly once when it finalizes. (The `destroyEngine` deferral does **not** set it — that path is paired with an explicit `ffi.cleanup()` in JS and must not double-release.) - -**Ownership rule (the invariant):** exactly one `g_ref_count` release per `initialize()`. `napi_cleanup` releases for instances torn down via explicit JS `cleanup()`; the env-cleanup path releases for instances abandoned by a terminating env. `destroyEngine` never releases (its JS caller always follows with `ffi.cleanup()`). These are mutually exclusive per engine because `destroyEngine` removes the env hook (so an engine reclaimed by the hook was never explicitly destroyed) and JS `cleanup()` calls `destroyEngine` then `ffi.cleanup()` on the *live* env (so the hook never fires for it). - -This makes the round-11 comment at `addon.c:1686` accurate. Update that comment to state the hook now also releases the init reference. - -### 2. Guard the isolate-touching finalize with a transient admission reservation (#3) - -`g_active_ops > 0` is the exact invariant that keeps the isolate alive (the waiter blocks on `while (g_active_ops > 0)`; the Case-4 synchronous fast path holds `g_mutex` throughout its `g_active_ops == 0` check + teardown). The fix moves the lock-free `g_isolate` read + registry-removal attach into a **short, self-contained `g_active_ops` reservation taken under `g_mutex`**, gated on teardown state — so the isolate provably cannot begin teardown across the attach, and the record-lifecycle machinery (`in_flight`, the worker-thread `g_active_ops--`, `bridge_end_op`) is **not** restructured. - -> **Mechanism decision:** the approved approach is the **transient reservation** below, not the more invasive "move `in_flight--`/`g_active_ops--` onto the worker thread and split the completion path across threads." In the live code the op's own `g_active_ops--` happens on the worker thread (`streaming_thread_fn:746` / `transform_thread_fn:1241`) while the finalize decision runs later on the JS thread (`call_js_write` → `bridge_end_op` → `bridge_finalize`); threading the reservation through that split would re-open the round-5/9/10/11 completion coordination the "bounded" constraint keeps closed. The transient reservation closes the identical race by taking a *fresh* reservation only around the attach, wherever finalize happens. - -**Split `bridge_finalize` into two phases:** - -- `bridge_finalize_registry(b)` — the isolate-touching phase. It takes its **own** transient `g_active_ops` reservation, checking teardown state in the *same critical section* as the increment: - - ```c - static void bridge_finalize_registry(engine_bridge_t* b) { - if (b == NULL || !fn_destroy_engine) return; - uv_mutex_lock(&g_mutex); - // If the isolate is already being physically torn down, or is gone, the - // Java registry died (or is dying) with it -- nothing to remove, and an - // attach would race graal_tear_down_isolate. Skip. The check and the - // g_active_ops++ are ONE critical section, so no teardown path (Case-4 - // sync, which holds g_mutex throughout; the waiter's TEARING_DOWN publish, - // also under g_mutex) can interleave between them. - if (g_teardown_state == TEARDOWN_TEARING_DOWN || g_isolate == NULL) { - uv_mutex_unlock(&g_mutex); - return; - } - g_active_ops++; // pins the live isolate against teardown - uv_mutex_unlock(&g_mutex); - - void* thread = NULL; - if (fn_attach_thread(g_isolate, &thread) == 0) { - fn_destroy_engine(thread, b->handle); - fn_detach_thread(thread); - } - - uv_mutex_lock(&g_mutex); - g_active_ops--; - uv_cond_broadcast(&g_teardown_cond); // verbatim release pattern - uv_mutex_unlock(&g_mutex); - } - ``` - -- `bridge_finalize_free(b, env_still_alive)` — the non-isolate phase: napi_ref deletion (owner JS thread, env alive; stays resolver-gated `resolver_js != NULL && env != NULL`) + `resolver_results_free_all` + `free(b)`. Touches no GraalVM isolate state. - -`bridge_finalize(b, env_still_alive, do_registry_remove)` becomes a thin wrapper preserving its exact current signature and every call site: `if (do_registry_remove) bridge_finalize_registry(b); bridge_finalize_free(b, env_still_alive);`. All existing callers (the two creators' rollback, `bridge_env_cleanup` direct path, `bridge_end_op`, `napi_destroy_engine` immediate path) keep calling `bridge_finalize` unchanged — the reservation-guarded registry removal is now automatic for all of them. - -**No completion-path restructuring.** `streaming_thread_fn` / `transform_thread_fn` keep their existing worker-thread `g_active_ops--` (verbatim) and `bridge_end_op` calls exactly as-is; `bridge_end_op` keeps its `in_flight--` + finalize-decision logic exactly as-is. Only the *body* of the registry-removal step (now inside `bridge_finalize_registry`) changes. - -**Why this closes the race, against all three teardown paths:** -- **Waiter (Case 5 → `TEARING_DOWN`):** the waiter publishes `TEARDOWN_TEARING_DOWN` under `g_mutex` *before* dropping the lock to call `graal_tear_down_isolate`. `bridge_finalize_registry`'s check+increment is one critical section: either it runs first (increments `g_active_ops`, so the waiter's `while (g_active_ops > 0 ...)` blocks until the attach completes and releases), or the waiter wins and publishes `TEARING_DOWN`/clears `g_isolate` first (so the check skips). No attach ever overlaps `graal_tear_down_isolate`. -- **Sync fast path (Case 4):** holds `g_mutex` across its `g_active_ops == 0` check *and* the spawn/join of `cleanup_thread_fn`. `bridge_finalize_registry` cannot acquire the lock mid-teardown; it either increments before Case 4 reads `g_active_ops` (Case 4 then sees > 0 and defers to a waiter) or runs after Case 4 cleared `g_isolate`/`g_initialized` (check skips). -- **Adoption:** never tears down (`g_teardown_cancelled`), so `g_isolate` stays valid; a stray attach is harmless. - -**Deadlock-safety (the load-bearing review gate):** the transient reservation must not re-introduce the round-5 deadlock. Round-5's deadlock was a *blocking wait on the JS event loop* while an op needed that loop. `bridge_finalize_registry` attaches its **own** Graal thread, makes **no** env-affine N-API call and **no** wait on the JS loop, and its reservation is released in the same function after a bounded `fn_destroy_engine` — it cannot depend on the event loop turning, and its reservation is never held across a JS callback. This must be explicitly confirmed in review. - -**Preserves round-5's decrement-on-worker-thread reasoning:** the op's own `g_active_ops--` stays on the worker thread, untouched. `bridge_finalize_registry`'s reservation is an additional, independent, short-lived one. - -### 3. `runTransform` readiness re-check after pre-buffering (#4) - -In `runTransform` (`dataweave.ts`), call `this.ensureReady()` again immediately after `await createChunkReader(input)`, before `streamFromNative(...)`. If the instance was cleaned up during the await, the caller gets a synchronous `DataWeaveError` (the same error `ensureReady` throws elsewhere) instead of a resolved `Unknown engine handle` envelope. No lease is introduced — the authoritative guard is the C admission pin (round 11 #2/#3); this only improves the failure ergonomics for a misused instance. The first `ensureReady()` at the top stays (fail fast before pre-buffering when already not-ready). - -### 4. Module-level `cleanup()` coalescing (#5) - -Add a module-scoped `cleanupPromise: Promise | null`. The module `cleanup()` becomes: if `cleanupPromise` is set, return it; else if `globalInstance` is null, return; else capture the instance, null `globalInstance`, store `cleanupPromise = instance.cleanup()`, `await` it in a `try`, and clear `cleanupPromise` in `finally`. Overlapping callers all await the same promise and resolve only when the underlying native teardown finishes — matching the instance-level coalescing pattern. The `cleanupStarted` exit-hook coalescer is unchanged (it coalesces `beforeExit`/`exit` for a shutdown; this coalesces overlapping manual calls). Keep the `cleanupStarted = false` reset last, as today. - -### 5. Check `napi_add_env_cleanup_hook` status; make creation all-or-nothing (#6) - -In both `napi_create_engine` and `napi_create_engine_with_resolver`, capture the `napi_status` from `napi_add_env_cleanup_hook`. On non-`napi_ok`: - -- unlink the just-linked record from `g_bridges` (under `g_mutex`), -- `bridge_finalize_registry(record)` to remove the Java registry entry (the engine was just created on this same live thread; the isolate is alive and `g_active_ops` need not be held because we are on the creating JS thread before returning — `g_isolate` is stable here, the same condition the existing destroyEngine fallback relies on), -- `release_isolate_ref_locked()` to release this creation's init reference (this instance's `initialize()` bumped it), -- `bridge_finalize_free(record, /*env_still_alive=*/true)`, -- `napi_throw_error` and return NULL — no usable handle escapes. - -Because the record was just constructed and linked on this thread and no op could have been admitted against it yet (`in_flight == 0`, no concurrent admission — the JS wrapper hasn't returned the handle), the unlink-and-finalize is race-free. - -### 6. Strengthen the run-vs-destroy test (#8) - -In `engine-handle-contract.test.ts`, for the **admitted ordering** (destroy fired after the streaming/transform op is admitted), require **success + complete chunks** — remove the "or Unknown engine handle" acceptance for that specific ordering. On fixed code the pin is already held at admission, so success is guaranteed; a regression that drops the pin would now produce the error envelope and **fail** the test. Keep any genuinely-unforceable cross-thread interleaving as a separately-labeled best-effort probe. - -### 7. Worker integration tests (#9) - -Create `native-lib/node/tests/integration/worker-lifecycle.test.ts` using real `worker_threads` Workers loading the real compiled addon. Coverage: - -- **Resolver-backed engine in a Worker:** create, run a script that resolves a custom module via the Worker's `resolveModule`, assert correct output — proving per-Worker resolver binding. -- **Resolver-less engine in a Worker:** create, run, assert output. -- **Normal Worker exit without `cleanup()` (the #2 proof):** run N cycles of {spawn Worker → create engine → run → let the Worker exit without `cleanup()`}, then assert the main thread can still `initialize()` and run, and that the process is not wedged. This is the behavioral observation of the #2 ref release (pre-fix, the leaked ref would keep the isolate alive; the test asserts continued healthy operation and clean final teardown). -- **`Worker.terminate()` mid-life** then subsequent main-thread `initialize()`/run succeeds. -- **Explicit `cleanup()` inside a Worker** resolves and leaves the main thread healthy. - -**Shared-state discipline:** these Worker tests share the parent process's isolate. Each Worker's own engine lifecycle must be balanced, and the file must end with a final main-thread `cleanup()` so it doesn't perturb sibling integration files — the same discipline `independent-engines.test.ts` follows. Vitest `pool: "forks"` isolates per file, so the file's residual state does not leak across files, but within-file balance still matters for the assertions. - -**Determinism posture (stated in the test file):** exact cross-thread timing interleavings (#3's race) are **not** deterministically forceable — matching the rounds 5–11 posture. The deterministic teeth are #8's required-success admitted-ordering assertion and #2's "Worker exits → main thread still works + final teardown clean" assertion. #3's correctness rests on the code reasoning in Design §2 (the reservation window), with the Worker tests as best-effort probabilistic guards over N iterations that are green on fixed code and cannot false-fail on it. - -## Testing - -- Strengthened `engine-handle-contract.test.ts` admitted-ordering assertion (#8). -- New `worker-lifecycle.test.ts` (#9), doubling as behavioral coverage for #2 (and best-effort for #3). -- Unit coverage for the module-level `cleanup()` coalescing (#5): two overlapping `cleanup()` calls both await the same drain and neither resolves before native teardown completes. -- Unit coverage for `runTransform` re-check (#4): consuming a transform generator after the instance was cleaned up during the input await surfaces a `DataWeaveError` synchronously at resume, not a resolved error envelope. -- No Java test change (the `@CEntryPoint` hosted-JVM limitation is unchanged; coverage stays at the Node integration layer). -- #2/#3 lifecycle correctness that is not deterministically forceable is covered by code reasoning against the invariants in §Design plus the best-effort Worker guards (same documented posture as rounds 5–11). - -## Verification - -- `cd native-lib/node && npm run build:addon` clean (no new warnings in the touched regions: `bridge_finalize*`, `bridge_env_cleanup`, `bridge_end_op`, `napi_cleanup`, the two creators, the streaming/transform completion sentinels); `npm run build` (tsc) clean. -- `npm test` green at the new baseline (currently 885 passed / 59 skipped / 0 failed; this round adds the #5, #4, #8 assertions and the #9 Worker suite — the plan sets the exact new counts). -- `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 (async) or a synchronous `napi_throw_error` (admission/arg/alloc/resource failures) — never `napi_reject_deferred`. -- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread. No env-affine napi call may be made off the owning thread. `bridge_finalize_free`'s napi_ref deletion stays resolver-gated (`resolver_js != NULL && env != NULL`) and on the owner thread. -- All shared C state (`g_initialized`, `g_active_ops`, `g_teardown_state`, `g_teardown_cancelled`, `g_ref_count`, every engine record's `in_flight`/`destroy_pending`/`deferred_registry_remove`/the new `deferred_ref_release`) is read/written only under `g_mutex`. In `bridge_finalize_registry` the `g_teardown_state`/`g_isolate` check and the transient `g_active_ops++` are one critical section under `g_mutex`; the subsequent `g_isolate` read for the attach happens only after that increment pinned the isolate alive (the check having ruled out `TEARING_DOWN`/NULL) — closing the round-12 #3 race. -- 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);` -- **Exactly one `g_ref_count` release per `initialize()`** (the #2 invariant): `napi_cleanup` for explicitly-cleaned instances; the env-cleanup path for abandoned envs; `destroyEngine` never releases. Mutually exclusive per engine. -- `fn_destroy_engine` is called **exactly once** per handle. -- Per-engine `in_flight` and global `g_active_ops` stay **distinct** counters — not merged. -- The round-5 deadlock fix must be preserved: the op's own `g_active_ops--` stays on the worker/completion thread, never moved to a JS-thread callback; and no blocking wait on the JS event loop is introduced. `bridge_finalize_registry`'s transient reservation is taken and released within that one function, never held across a JS callback, and its guarded step makes no env-affine/JS-loop-dependent call — confirm in review. -- Preserve every round-1..11 fix: coalesced instance `cleanup()`, the JS three-state lifecycle machine, the `TEARDOWN_*` state machine + `napi_initialize` adoption path (incl. round-7's `g_teardown_cancelled` carve-out), the worker-thread `g_active_ops` decrement, the atomic admission blocks + round-11 admission-time engine pin (`bridge_begin_op_locked`) in all three run paths, the round-6 handle-read validations, round-7 conversion-status checks, round-8 setup-allocation NULL checks, round-9 worker/callback OOM + N-API-create checks + deferred registry removal, round-10 env-cleanup registry removal + `g_isolate`-guarded finalize, round-11 env cleanup hook for every engine + owner-thread destroy guard for every record + register-once exit hooks. -- Node vitest baseline currently **885 passed / 59 skipped / 0 failed**; this round raises it (new tests) and must stay green. - -## Rejected Alternatives - -- **#2 by decrementing `g_ref_count` inline in `bridge_finalize` without the shared helper.** Rejected: the "reached zero → immediate teardown vs. queue the waiter" decision already lives in `napi_cleanup` Case 5; duplicating it invites divergence. A single `release_isolate_ref_locked()` keeps both paths identical. -- **#2 by having `destroyEngine` also release the ref.** Rejected: `destroyEngine`'s JS caller (`doCleanup`) always follows with `ffi.cleanup()`, which releases the ref; adding a release in `destroyEngine` would double-release and tear the isolate down under live instances. -- **#3 by taking `g_mutex` around the `g_isolate` read + attach in `bridge_finalize`.** Rejected: `fn_attach_thread`/`fn_destroy_engine` enter GraalVM and can block; holding `g_mutex` across them would serialize all teardown coordination behind a GraalVM call and risk lock-ordering issues with the waiter. The transient reservation holds `g_mutex` only for the check+increment, then releases it before the GraalVM attach. -- **#3 by moving `in_flight--`/`g_active_ops--` onto the worker thread and reusing the op's own reservation across the finalize (spec's earlier literal wording).** Rejected as re-opening the round-5/9/10/11 completion coordination the approved approach keeps bounded: in the live code the op's `g_active_ops--` is on the worker thread while the finalize decision runs later on the JS thread via `bridge_end_op`; threading one reservation across that split would restructure `bridge_end_op` and both completion sentinels across thread boundaries. The transient reservation closes the identical race by taking a *fresh* short-lived reservation only around the attach, wherever finalize runs — no completion-path restructuring. -- **#3 with a dedicated `g_finalizing` counter separate from `g_active_ops`.** Rejected as re-opening the coordination substructure the approved approach keeps bounded: it adds a second teardown-gating counter that the waiter must also wait on, duplicating what `g_active_ops` already expresses. A transient `g_active_ops` reservation reuses the counter the waiter already blocks on and is provably correct. -- **#4 via a JS-side operation lease that blocks `cleanup()` until the transform completes.** Rejected (same as rounds 9/11): no per-engine "await my ops" primitive exists at the JS layer, and the authoritative pin already lives in C. The re-check is the minimal ergonomic close; the lease would duplicate the C pin's guarantee at a layer that cannot enforce it. -- **#5 by not nulling `globalInstance` until the drain settles.** Rejected: a concurrent convenience-API call would then revive/return the instance mid-teardown. Nulling synchronously (so new work builds a fresh instance) plus a module `cleanupPromise` (so overlapping `cleanup()`s coalesce) matches the instance-level design and is correct. -- **#6 by leaving the handle valid and logging on hook-registration failure.** Rejected: a handle with no env cleanup hook silently reintroduces exactly the #2 leak the round is closing. Creation must be all-or-nothing. -- **#8 keeping the "success OR error envelope" acceptance for the admitted ordering.** Rejected: that acceptance is precisely what lets a pin regression pass. The admitted ordering is deterministic on correct code, so the test must require success. -- **#9 driving the "cross-thread" scenario on a single JS thread only.** Rejected as insufficient for the documented per-Worker model: real `worker_threads` Workers are needed to exercise per-Worker engine binding and the abandoned-env (#2) path. The exact race remains best-effort, but the Worker lifecycle itself must be really exercised. -- **Modifying the Java `ScriptRuntime` to reference-count or tolerate late lookups.** Rejected as out of scope and the wrong layer — the C addon owns the isolate reference and the pin. diff --git a/docs/superpowers/specs/2026-08-19-worker-teardown-dangling-resolver-ctx-design.md b/docs/superpowers/specs/2026-08-19-worker-teardown-dangling-resolver-ctx-design.md deleted file mode 100644 index 69686ae2..00000000 --- a/docs/superpowers/specs/2026-08-19-worker-teardown-dangling-resolver-ctx-design.md +++ /dev/null @@ -1,104 +0,0 @@ -# Worker-Teardown Dangling Resolver Ctx & Shutdown-Doc Accuracy — Round 10 (W-23692110) - -**Status:** Design approved (lightweight round), ready for direct implementation. - -**Source review:** `docs/pr-157-follow-up-andy-code-review-10.md` (two findings, both verified against live source at commit `d504c0f`, the round-9 tip). - -**Scope:** `native-lib/node` only — `src/addon.c` (finding 1) and `src/dataweave.ts` (finding 2). Do **not** touch `native-lib/python/**`, the legacy singleton entrypoints, or `ScriptRuntime.getInstance()`. The Java side is not modified — the C addon must stop leaving a live registry entry pointed at freed memory rather than change Java's registry semantics. - -## Problem - -### #1 (P1) — Worker teardown frees a resolver bridge but leaves its Java registry entry (and resolver ctx) dangling - -`napi_create_engine_with_resolver` passes the `engine_bridge_t* bridge` to Java as the resolver ctx (`addon.c:1640`); Java's `CallbackWeaveResourceResolver` retains it, and `resolve_module_callback` casts that same ctx word back to `engine_bridge_t*` (`addon.c:1450`). - -When the owning Worker/main env tears down, the per-env cleanup hook `bridge_env_cleanup` runs. It **frees** the bridge — `bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/false)` at `addon.c:265` — but deliberately passes `do_registry_remove=false`, so it does **not** call `fn_destroy_engine`. The `ScriptRuntime` stays in the Java registry with a `CallbackWeaveResourceResolver` whose ctx now points at freed native memory. A subsequent invocation of that handle dereferences freed memory (UAF). - -This is exactly the round-9 decision: round 9 gave every engine a record and deferred registry removal for the `destroyEngine` path, but chose `do_registry_remove=false` on the env-cleanup path (`addon.c:105-108`) out of caution about calling `fn_destroy_engine` during env teardown. Round 10 shows that caution was wrong: leaving the registry entry is a UAF. - -**Both env-cleanup sub-paths have the gap:** -- Direct free (`in_flight == 0`, `addon.c:265`): frees with `do_registry_remove=false`. -- Deferred (`in_flight > 0`, `addon.c:254-258`): sets `destroy_pending=true` but leaves `destroy_via_destroy_engine=false`, so the later `bridge_end_op` → `bridge_finalize` drain (`addon.c:297-303`) also skips the registry removal. - -### #2 (P2) — Shutdown doc over-promises `exit`-hook coverage - -`dataweave.ts:276-280` says the synchronous `exit` hook is "the last-ditch fallback for `process.exit()`, uncaught exceptions, and fatal signals." Node does **not** emit `exit` for termination signals such as SIGTERM/SIGKILL (absent a JS signal handler), nor for all fatal failure modes. The comment should describe `exit` as best-effort only and tell callers who need guaranteed graceful shutdown to register and await their own signal handlers. - -## Design - -### 1. Remove the registry entry during env cleanup (finding #1) - -Make `bridge_env_cleanup` remove the Java registry entry before/when it frees the bridge, on **both** sub-paths, guarded on isolate liveness. - -**Why calling `fn_destroy_engine` here is safe (the round-9 caution, resolved):** -- `bridge_env_cleanup` is registered **only for resolver-backed engines** (`addon.c:1666`; resolver-less engines register no hook, `addon.c:1598-1601`), so this path is exactly the dangling-ctx case. -- `destroyEngine` removes the hook (`napi_remove_env_cleanup_hook`, `addon.c:1738`) for any engine it handles — deferred or not — so `bridge_env_cleanup` only ever fires for an engine that was **never** passed to `destroyEngine`. Such an engine's `initialize()` ref was likewise never released (both go through `doCleanup()`), so `g_ref_count > 0` and the process-wide GraalVM isolate is still alive: `fn_destroy_engine`'s fresh-thread attach is legal. -- `fn_destroy_engine` attaches its **own** isolate thread (not JS-thread-affine), so it is safe from the env-cleanup hook thread — the same property `destroyEngine`'s deferred-drain finalize already relies on. -- **The one exception:** the main env can tear down *after* `napi_cleanup` already tore down the isolate (`g_isolate == NULL`). Then the Java registry died with the isolate and there is nothing to remove — so the registry removal must be **guarded on `g_isolate != NULL`**. - -**Exactly-once preserved:** `destroyEngine` and `bridge_env_cleanup` are mutually exclusive per handle (destroyEngine removes the hook), so `fn_destroy_engine` still runs at most once per handle. - -**Changes (`addon.c`):** - -a. **Harden `bridge_finalize`'s registry-removal guard** to skip when the isolate is gone — protects every caller and covers the "isolate torn down by drain time" case for the deferred path: -```c -if (do_registry_remove && fn_destroy_engine && g_isolate) { - void* thread = NULL; - if (fn_attach_thread(g_isolate, &thread) == 0) { fn_destroy_engine(thread, b->handle); fn_detach_thread(thread); } -} -``` -(`g_isolate` is read outside `g_mutex` here — the same accepted pattern as `napi_destroy_engine`'s fallback at `addon.c:1753-1756`; the NULL check narrows the window and makes a torn-down isolate a no-op instead of an unsafe `fn_attach_thread(NULL, …)`.) - -b. **`bridge_env_cleanup` direct path** (`addon.c:265`): pass `do_registry_remove=true`: -```c -bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/true); -``` - -c. **`bridge_env_cleanup` deferred path** (`addon.c:254-258`): set the deferred-registry-removal flag so the draining op removes the entry: -```c -if (b->in_flight > 0) { - b->destroy_pending = true; - b->deferred_registry_remove = true; // env-cleanup, like destroyEngine, must remove the registry on drain - uv_mutex_unlock(&g_mutex); - return; -} -``` - -d. **Rename `destroy_via_destroy_engine` → `deferred_registry_remove`.** The field now gates the deferred registry removal for **both** `destroyEngine` and `bridge_env_cleanup`, so the old name (implying "only via destroyEngine") is actively misleading. Update the declaration/comment (`addon.c:105-109`), the set site in `napi_destroy_engine` (`addon.c:1729`), the new set site in `bridge_env_cleanup`, and the read in `bridge_end_op` (`addon.c:298`). Update the stale comments at `addon.c:105-108`, `261-265`, and `300-302` to state that the env-cleanup path now removes the registry. - -### 2. Correct the shutdown doc (finding #2) - -Reword `dataweave.ts:276-280` so the `exit` hook is described as best-effort synchronous cleanup that runs for `process.exit()`, uncaught exceptions, and normal process end — and explicitly note that Node does **not** emit `exit` for termination signals (SIGTERM/SIGKILL) or all fatal failure modes, so callers needing guaranteed graceful shutdown must register and await their own signal handlers. Doc-only; no behavior change. - -## Testing - -**No new runtime test.** Consistent with rounds 6–9: the env-teardown UAF path is not deterministically forceable from JS/vitest (it requires a Worker to exit with a live resolver engine and then re-invoke a freed handle across the teardown boundary — no addon-boundary fault-injection exists). Coverage is code reasoning against the exactly-once and isolate-liveness invariants above. #2 is doc-only. - -Baseline unchanged: **878 passed / 59 skipped / 0 failed**. - -## 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. -- `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 or a synchronous `napi_throw_error` — never `napi_reject_deferred`. -- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread. -- All shared C state (incl. every engine record's `in_flight`/`destroy_pending`/`deferred_registry_remove`) is read/written only under `g_mutex`, except the documented lock-free `g_isolate` NULL-check in `bridge_finalize` (matching the existing `napi_destroy_engine` fallback pattern). -- `fn_destroy_engine` is called **exactly once** per handle — the `destroyEngine` and `bridge_env_cleanup` paths stay mutually exclusive via hook removal. -- The owner-thread `destroyEngine` restriction stays scoped to engines with resolver `napi_ref` state. -- Preserve every round-1..9 fix. -- Node vitest baseline **878 passed / 59 skipped / 0 failed**. - -## Rejected Alternatives - -- **Leave the env-cleanup path as `do_registry_remove=false` and instead make Java's registry tolerate a freed ctx.** Rejected: out of scope (Node-binding-only) and the wrong layer — the addon must not leave a live registry entry pointing at freed memory. It also cannot: the ctx is opaque to Java. -- **Null the bridge's resolver fields instead of removing the registry entry, so a later `resolve_module_callback` fails closed.** Rejected: the bridge memory is freed, so there is nothing left to null; and the `ScriptRuntime` itself (script cache, module loader) would leak in the Java registry forever. Removing the registry entry reclaims both. -- **Unconditionally call `fn_destroy_engine` without the `g_isolate` guard.** Rejected: at main-env teardown after isolate destruction, `g_isolate == NULL` and `fn_attach_thread(NULL, …)` is unsafe; the registry is already gone, so the call is both dangerous and pointless. -- **Add a runtime regression test.** Rejected: not deterministically forceable (rounds 6–9 precedent); no addon-boundary fault injection for the Worker-exit-then-reinvoke race. -- **Keep the field name `destroy_via_destroy_engine`.** Rejected: after this change it also gates the env-cleanup path, so the name would misdescribe half its uses. diff --git a/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md b/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md deleted file mode 100644 index 92563a58..00000000 --- a/docs/superpowers/specs/2026-08-20-per-env-init-reference-ownership-design.md +++ /dev/null @@ -1,184 +0,0 @@ -# Per-Env Init-Reference Ownership — Round 13 (W-23692110) - -**Status:** Design approved (user), ready for planning. - -**Source review:** `docs/pr-157-follow-up-code-review-4.md`, Finding #5 (Medium), verified against live source at commit `765c273` (round-12 tip). Findings #1, #2, #3, #4, #6 in that review are test-quality/coverage items or already-shipped fixes and are **out of scope** for this round (they may be addressed in a separate test-hardening round); this round fixes only #5, the one production-correctness finding. - -**Scope:** `native-lib/node` only — `src/addon.c` and Node integration tests under `tests/`. Do **not** touch `native-lib/python/**`, 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 read for context but not modified. `src/dataweave.ts` is **not** modified — the product-facing `DataWeave` class already maintains the sanctioned 1:1 pairing, so no JS change is needed; the fix hardens the C boundary underneath it. - -## Problem - -### #5 (Medium) — Abandoned-env reference release relies on an unenforced raw-addon invariant - -`g_ref_count` (`addon.c:37`) is a single process-global reference counter with **no notion of which `napi_env` owns each reference**. Its accounting assumes a strict **1 `initialize()` ↔ 1 engine ↔ 1 `cleanup()`** pairing: - -- `napi_initialize` does `g_ref_count++` at three sites: the adoption path (`:531`), the already-initialized fast path (`:545`), and the create path (`:574`). -- `napi_cleanup` → `release_isolate_ref_locked` does the matching `g_ref_count--` (`:2358-2359`) and, on the last release, drives isolate teardown. -- An **abandoned engine's** env-cleanup hook also releases one reference: `bridge_env_cleanup` (`:339`, direct path) or `bridge_end_op` (`:404`, deferred path), gated by `engine_bridge_t.deferred_ref_release`, calling `isolate_ref_release_core_locked()`. - -The defect: the **per-engine** env-cleanup hook releases a reference that logically belongs to **`initialize()`**, not to the engine. The product `DataWeave` class calls `initialize()` exactly once per engine and releases them together via one `cleanup()`, so the counts happen to match. But the addon exports raw `initialize`, `createEngine`, and `createEngineWithResolver` (`addon.c:2494-2504`) with **nothing enforcing the pairing**. A raw consumer that does `initialize()` **once**, then `createEngine()` **N times**, registers **N** per-engine cleanup hooks against a reference count of **1**. When that env is abandoned: - -1. the first engine's hook (`bridge_env_cleanup` → `isolate_ref_release_core_locked`) drives `g_ref_count` `1 → 0`, -2. `isolate_ref_release_core_locked` (`:2297-2338`) sees zero and **tears the isolate down** (synchronously when `g_active_ops == 0`, or queues the waiter otherwise), -3. the remaining `N-1` engines — and, in a multi-env process, **another env's still-valid engines** — are now operating on a torn-down isolate. - -This is a use-after-free / premature-teardown hazard, documented but unenforced in the comment at `addon.c:2289-2296`. Finding #5 asks that the addon boundary either enforce the pairing, track init ownership separately from engine records, or make the raw surface inaccessible. The raw `.node` file cannot truly be made inaccessible (anything can `require()` it), and enforcing one-engine-per-init would reject valid multi-engine usage. **Decision (user): track initialization ownership separately from engine records** — the robust option that fixes the UAF while preserving the multi-engine feature. - -## Design - -Introduce **per-`napi_env` init-reference accounting** so `g_ref_count` becomes a derived total rather than a bare global that any engine hook can drive to zero. One invariant governs the whole design: - -> **`g_ref_count` == Σ `init_refs` over all live env records.** - -Every reference in the global count is owned by exactly one env's record; a reference can only be released by the same env that acquired it (via that env's `cleanup()`) or by that env's death hook (releasing all of that env's outstanding references at once). The per-engine cleanup hook stops touching `g_ref_count` entirely — which is the actual bug fix. The teardown decision still fires only on the true global last-release, and only from an env-scoped release path, so it can never tear the isolate down while another env holds a reference. - -### 1. New per-env record and registry - -```c -// One record per napi_env that has ever taken an init reference (via -// initialize()). init_refs is that env's net initialize()-minus-cleanup() -// balance. The record is created lazily on the env's first initialize(), -// registers exactly one env-death hook (env_init_cleanup) at creation, and is -// freed when its env dies (that hook) after releasing every reference the env -// still holds. All fields mutated only under g_mutex. -// -// INVARIANT: g_ref_count == sum of init_refs over all records in g_env_recs. -typedef struct env_init_rec { - napi_env env; - int init_refs; - struct env_init_rec* next; -} env_init_rec_t; -static env_init_rec_t* g_env_recs = NULL; // linked list, guarded by g_mutex -``` - -Helpers (all require the caller to hold `g_mutex`): - -- `env_init_rec_t* env_init_rec_find_locked(napi_env env)` — linear scan of `g_env_recs`, returns the record or NULL. Mirrors `bridge_find`. -- `env_init_rec_t* env_init_rec_acquire_locked(napi_env env, bool* is_new)` — find-or-create the record and `init_refs++`. Sets `*is_new = true` when it just allocated the record (the caller must then register the env-death hook, outside any napi-illegal context — see §3). Returns NULL only on `calloc` failure (caller treats as a hard error and does not bump `g_ref_count`). - -### 2. `napi_initialize` — acquire a per-env reference alongside `g_ref_count` - -Each of the three `g_ref_count++` sites gains a paired `init_refs` acquire on the calling env, under the same `g_mutex` hold that already guards the `g_ref_count++`: - -- **Adoption path (`:530-534`):** currently `g_teardown_cancelled = true; g_ref_count++; broadcast; unlock; return`. Add `env_init_rec_acquire_locked(env, &is_new)` before the `g_ref_count++`. On `calloc` failure: do **not** cancel the teardown, do **not** bump `g_ref_count`; unlock and `napi_throw_error(env, NULL, "Failed to allocate env init record")`, return NULL. (The teardown stays queued; the caller's initialize failed cleanly.) -- **Fast path (`:544-548`):** `if (g_initialized) { g_ref_count++; ... }` — add the acquire before the bump, same failure handling (unlock + throw, no bump). -- **Create path (`:573-575`):** after a successful isolate build, before `g_ref_count++`, do the acquire. Perform the `env_init_rec_acquire_locked` **first** (it only allocates a small node); only if it succeeds proceed to `g_initialized = 1; g_ref_count++`. On acquire failure, `g_isolate` is already non-NULL (the create path's `init_thread_fn` just built it) while `g_initialized` is still 0 — simply unlocking and throwing would leave that combination in place, which the wait loop's `g_isolate != NULL && !g_initialized` clause treats as "a teardown is in flight," permanently hanging every subsequent `initialize()` in `uv_cond_wait` with nothing left to broadcast. So on this failure the just-built isolate is torn down (via the same `cleanup_thread_fn` idiom used elsewhere) before throwing, clearing `g_isolate`/`g_initialized` back to NULL/0 and restoring the same recoverable state the sibling spawn-failure/`init`-error paths already leave (they never built an isolate in the first place). If the teardown itself cannot attach to the isolate, `g_isolate` is left non-NULL as a best-effort degradation — the same posture already accepted for `cleanup_thread_fn`'s attach-failure path elsewhere. - -**Hook registration for a new record.** When `env_init_rec_acquire_locked` reports `is_new`, register exactly one env-death hook for the init record: -`napi_add_env_cleanup_hook(env, env_init_cleanup, rec)`. This is legal in all three paths (they run on the env's own JS thread with the env alive). If the hook registration **fails**, the record cannot guarantee its references are reclaimed on env death — roll back: decrement the just-acquired `init_refs` (freeing the record if it drops to 0), do not bump `g_ref_count`, unlock, throw. This mirrors round-12 #6's all-or-nothing posture for the per-engine hook. - -Ordering note (LIFO): because the init-record hook is registered on the **first** `initialize()` for an env — before any engine is created — Node's env-cleanup hooks run **LIFO**, so `env_init_cleanup` runs **after** every per-engine `bridge_env_cleanup` for that env. Every engine bridge is thus finalized (Java registry entry removed, napi_ref deleted) while the isolate is **still alive**, and only then does the init record release the isolate reference(s). This preserves the exact ordering the round-10/11/12 fixes rely on. - -### 3. `env_init_cleanup` — release all of a dead env's references, once - -New env-death hook, registered per §2. Runs on the dying env's own thread with the env still alive (standard env-cleanup-hook contract): - -```c -static void env_init_cleanup(void* arg) { - env_init_rec_t* rec = (env_init_rec_t*)arg; - if (rec == NULL) return; - uv_mutex_lock(&g_mutex); - // Unlink from g_env_recs. - env_init_rec_t** pp = &g_env_recs; - while (*pp != NULL) { if (*pp == rec) { *pp = rec->next; break; } pp = &(*pp)->next; } - int n = rec->init_refs; - rec->init_refs = 0; - free(rec); - // Release exactly the references this env still held. release_n... makes the - // teardown decision at most ONCE, after decrementing all n, so it never - // spawns a second waiter or tears down an already-torn isolate mid-loop. - isolate_ref_release_n_locked(n); - uv_mutex_unlock(&g_mutex); -} -``` - -The env that reaches `env_init_cleanup` without having called `cleanup()` for each of its references (the abandoned-Worker case, and the raw multi-engine-per-init case) releases them here — **all at once, from a single env-scoped decision point.** Because `g_ref_count == Σ init_refs`, releasing this env's `n` reaches 0 **only** if no other env holds a reference, so an abandoned env-A can never tear down the isolate under a live env-B. - -### 4. Bounded multi-release helper `isolate_ref_release_n_locked` - -`isolate_ref_release_core_locked` (`:2297-2338`) currently decrements **one** reference and then makes the teardown decision. A naive loop calling it `n` times would, after the reference that reaches 0 tears down and sets `g_ref_count = 0`, make the remaining iterations no-op on an already-zero count — correct by luck, but it also re-runs the `g_teardown_state != TEARDOWN_NONE` early-return and would mis-handle the `g_active_ops > 0` waiter case if a second "last release" were computed. Make it explicit and single-decision: - -```c -// Release n (>=0) initialization references at once, then make the teardown -// decision AT MOST ONCE. Caller holds g_mutex; this KEEPS it held. Equivalent -// to n serial core releases for the count, but guarantees the reached-zero -// teardown/waiter logic runs exactly once. n==0 is a no-op. -static void isolate_ref_release_n_locked(int n) { - if (n <= 0) return; - if (g_ref_count >= n) g_ref_count -= n; else g_ref_count = 0; - if (g_ref_count > 0) return; // other envs still hold refs - if (g_teardown_state != TEARDOWN_NONE) return; // a teardown already drives - // ... the SAME reached-zero body as isolate_ref_release_core_locked: - // g_active_ops == 0 -> synchronous cleanup_thread_fn + clear globals; - // g_active_ops > 0 -> spawn waiter, TEARDOWN_PENDING_WAIT, empty list. -} -``` - -Refactor `isolate_ref_release_core_locked` to `isolate_ref_release_n_locked(1)` (behavior-preserving for the single-release callers). The single-decision reached-zero body is written once and shared. - -### 5. `napi_cleanup` — gate the release on the calling env's ownership - -`release_isolate_ref_locked(env)` (`:2353`) currently does an unconditional `if (g_ref_count > 0) g_ref_count--;`. Gate it on the calling env's own balance so an env can only release a reference it actually holds (user decision: gate `cleanup()` too, closing the symmetric over-`cleanup()` UAF): - -```c -static napi_value release_isolate_ref_locked(napi_env env) { - env_init_rec_t* rec = env_init_rec_find_locked(env); - if (rec == NULL || rec->init_refs == 0) { - // This env holds no init reference: a cleanup() with no matching - // initialize() on this env (or a double-cleanup()). Do NOT touch - // g_ref_count -- releasing here would steal another env's reference and - // could tear the isolate down under a live user. No-op: resolve immediately. - uv_mutex_unlock(&g_mutex); - return already_resolved_promise(env); - } - rec->init_refs--; - if (g_ref_count > 0) g_ref_count--; - // ... the rest of Cases 1..5 UNCHANGED (the decrement above replaces the old - // unconditional one; g_ref_count-driven teardown decision is identical). - ... -} -``` - -The record is **not** freed here even if `init_refs` hits 0 — its env is still alive and may `initialize()` again, and its env-death hook still needs to run (with `init_refs == 0`, `env_init_cleanup` releases nothing, which is correct). This matches the product pattern of `cleanup()` then possibly re-`initialize()` on the same env. - -### 6. Per-engine hook stops touching `g_ref_count` (the core fix) - -Remove the init-reference release from the per-engine path entirely: - -- Delete the `deferred_ref_release` field from `engine_bridge_t` (`:121`) and every write (`bridge_env_cleanup:328`) and read (`bridge_end_op:398,404`). -- `bridge_env_cleanup`'s direct path (`:339`) no longer calls `isolate_ref_release_core_locked()`. -- `bridge_end_op` (`:404`) no longer conditionally releases the ref. - -The per-engine hooks keep doing everything else — unlink the bridge, remove the Java registry entry (`do_registry_remove`), delete the resolver napi_ref, free the record. They simply no longer own an isolate reference, because they never did: the reference belongs to `initialize()`, now tracked by the env init record. - -`isolate_ref_release_core_locked` becomes reachable only via `isolate_ref_release_n_locked`; if no other caller remains, it is folded into the `n==1` path (kept as a thin wrapper only if a call site still reads better with it). - -## Invariants preserved / established - -1. **`g_ref_count == Σ init_refs`** — established; every `g_ref_count` mutation is paired with an `init_refs` mutation on a specific env (init: both +1; cleanup: both −1 for the calling env; env death: −n for the dying env). The three initialize sites, `release_isolate_ref_locked`, and `env_init_cleanup` are the *only* mutators of `g_ref_count` after this round. -2. **An env releases only what it owns** — both `cleanup()` (§5) and env-death (§3) are keyed on a specific env's record; neither can drive `g_ref_count` below the references still held by *other* envs. Closes the cross-env UAF (abandoned env) **and** the symmetric over-`cleanup()` UAF. -3. **Teardown fires only on true global last-release** — the reached-zero body runs only when `g_ref_count` hits 0 after an env-scoped decrement, exactly as before; the multi-release helper makes that decision **once** per env-death. -4. **`destroyEngine` never releases an init reference** — unchanged; it was never a `g_ref_count` mutator and still isn't. -5. **`fn_destroy_engine` called exactly once per handle** — unchanged; the per-engine finalize path is untouched except for dropping the ref release. -6. **Thread affinity** — `env_init_cleanup` runs on its env's own thread with the env alive (env-cleanup-hook contract), doing only `g_mutex`-guarded integer/list work and `free` — no env-affine napi calls, no cross-thread napi. The init-record hook is registered on the env's own thread. Consistent with the round-11/12 per-engine hook design. -7. **Deadlock/adoption state machine (`TEARDOWN_*`, `g_teardown_cancelled`, the waiter)** — untouched; the reached-zero body it hooks into is the same, now shared via `isolate_ref_release_n_locked`. -8. **Sanctioned 1:1 usage is behavior-identical** — one env, one `initialize()` (`init_refs 1`, hook registered), one engine, one `cleanup()` (`init_refs 0`, `g_ref_count 0`, teardown as today). All existing round-1..12 tests must stay green with no assertion changes. - -## Testing - -Real-addon integration tests under `native-lib/node/tests/integration/` (no `vi.mock` of `ffi`), mirroring `instance-lifecycle.test.ts`'s ref-count proxy technique (a subsequent raw engine call throwing `/not initialized/` proves the isolate reached zero refs and was torn down; a call that succeeds proves it is still alive). - -1. **Raw multi-engine-per-init does not prematurely tear down (the #5 core).** On one env (the main test thread): `ffi.initialize()` **once**, then `ffi.createEngine()` **twice** (handles h1, h2). Run a script on h2 to prove the isolate is live. `ffi.destroyEngine(h1)` — the isolate must remain alive: a run on h2 still succeeds. Then `ffi.destroyEngine(h2)` and one `ffi.cleanup()` (the single init reference). Now a fresh raw engine call must observe `/not initialized/`. *Pre-fix predicted behavior: acceptable here because destroyEngine (not the env hook) drives per-engine teardown and does not release the ref — so this test alone does not isolate #5; it guards that the multi-engine-per-init shape stays live under partial destroy.* **Primary #5 regression is test 2.** -2. **Over-`cleanup()` from an env cannot steal a reference / tear down under a live user.** `ffi.initialize()` once, `ffi.createEngine()` (h). Call `ffi.cleanup()` **twice**. The first releases this env's one reference (isolate torn down — this env owned exactly one). The second must be a **no-op** (`init_refs` already 0): it must not throw, and — critically — must not drive `g_ref_count` negative or perturb a *subsequently* re-initialized isolate. Prove: after the double-cleanup, `ffi.initialize()` again + `createEngine` + run succeeds (the second cleanup did not corrupt the count), then balance with one `cleanup()` and assert `/not initialized/`. -3. **Symmetric-ownership proof via the module API (regression guard for sanctioned path).** The existing `instance-lifecycle.test.ts` ref-count-proxy tests (napi_cleanup refactor; revived-singleton) must remain green unchanged — they already assert the 1:1 path tears down to zero. Add one assertion-level note only if needed; no new test required if these cover it. -4. **Worker abandonment still releases (round-12 #2 behavior preserved).** The existing `worker-lifecycle.test.ts` "N Workers exit without cleanup" test must remain green: each Worker does one `initialize()` + one engine, so its env-death `env_init_cleanup` releases exactly one reference — identical net behavior to the round-12 `deferred_ref_release` path it replaces. (If review round-4 Finding #1's stronger zero-reference assertion is added in a separate round, it must still pass here.) - -All tests balance shared isolate state (final `cleanup()` / `/not initialized/` probe) so they do not perturb sibling integration files sharing the vitest worker process. Full Node suite target: **895 passed / 59 skipped / 0 failed** plus the new tests (2 new integration tests → **897 passed / 59 skipped / 0 failed**), unless a new test file adds more. - -## Rejected alternatives - -- **Enforce one engine per `initialize()` at the addon boundary (review option 1).** Rejected: rejects valid raw multi-engine-per-init usage, and still requires per-init tracking to detect "this env already has a live engine under its current init reference" — no simpler than per-env accounting, strictly more restrictive. -- **Make the raw addon private/inaccessible (review option 3).** Rejected: `dwlib_addon.node` is a file on disk; any consumer can `require()` it. Narrowing the package's documented surface is a docs change that leaves the underlying C hazard intact — effectively won't-fix. -- **Reference-count per engine instead of per env.** Rejected: the reference semantically belongs to `initialize()` (isolate lifetime), not to an engine (Java registry entry lifetime). Coupling it to engines is exactly the mispairing that caused #5. -- **Keep `deferred_ref_release` and additionally cap releases at the env's engine count.** Rejected: still keyed on engines, still lets an abandoned env with more engines than its true init count over-release; per-env accounting is the correct key. -- **Store the init record via `napi_set_instance_data`.** Rejected: `napi_set_instance_data` is single-slot per env and may already be reserved by future addon needs; a `g_mutex`-guarded list mirrors the existing `g_bridges` pattern the codebase already reasons about, and is visible to the cross-env teardown decision that instance-data (env-local) is not. diff --git a/docs/superpowers/specs/2026-08-21-review5-teardown-and-admission-hardening-design.md b/docs/superpowers/specs/2026-08-21-review5-teardown-and-admission-hardening-design.md deleted file mode 100644 index c15af492..00000000 --- a/docs/superpowers/specs/2026-08-21-review5-teardown-and-admission-hardening-design.md +++ /dev/null @@ -1,236 +0,0 @@ -# Review #5 Remediation — Engine-Creation Admission + Teardown-Failure Recovery + Regression-Test Strength - -**Date:** 2026-08-21 -**Branch:** `w-23692110-multi-engine-design` (PR #157) -**Round:** 14 -**Addresses:** `docs/pr-157-follow-up-code-review-5.md` (1 High, 5 Medium, 1 Low) - -## Context - -PR #157 ships the multi-engine Node binding for the DataWeave native library. Round 13 replaced the unsafe per-engine init-reference release with per-`napi_env` init-reference ownership, establishing the invariant **`g_ref_count == Σ (per-env init_refs)`**. Review #5 confirms that fix is correct and turns to three residual risk areas: engine-creation admission (a live concurrency hole), teardown-failure recovery (a live but owner-less isolate can be stranded), and regression-test strength (the round-13 tests do not actually pin the round-12 defect). - -The reviewed head is `bd68c70` — the exact round-13 HEAD. The subsequent master merge (`212424d`) touched only `native-lib/python/**` and a `package-lock.json` dep bump, so every line reference in the review is still accurate against the current tree. - -This round is **Node-binding only**. It does not touch `native-lib/python/**`, 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 unchanged. Handle width stays C `long long`. Errors surface as resolved JSON strings (async) or synchronous `napi_throw_error` / thrown `DataWeaveError` — never `napi_reject_deferred`. `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread; no env-affine napi call is made from the waiter or a wrong thread. - -**Preserved invariant (every g_mutex release):** `g_ref_count == Σ per-env init_refs`. No fix in this round resurrects a reference that no env owns. - -## Findings and Fixes - -### #1 (High) — engine creation can attach to an isolate being torn down - -**Defect.** `napi_create_engine` (addon.c:1837–1923) and `napi_create_engine_with_resolver` (addon.c:1926+) test `g_initialized` **outside** `g_mutex`, then call `fn_attach_thread(g_isolate, …)` and `fn_create_engine(…)` with (a) no requirement that the calling `napi_env` owns an init reference, and (b) no `g_active_ops` reservation pinning the isolate across the attach. An env that never called `initialize()` (or that already released its reference) can observe a still-`g_initialized` isolate while another env drops the final reference and the waiter/cleanup thread begins `graal_tear_down_isolate()`. The create then attaches to / creates an engine on a tearing-down isolate — a use-after-free. - -**Fix.** Mirror the proven admission pattern already used by `bridge_finalize_registry` (addon.c:286–313): perform the lifecycle check and the reservation in **one critical section** under `g_mutex`, at the top of each create function: - -```c -uv_mutex_lock(&g_mutex); -// Admission (one critical section — no teardown can interleave between the -// checks and the reservation, because every teardown transition and the -// g_active_ops==0 fast path also hold g_mutex): -// (1) isolate must be live and NOT past the point of no return, -// (2) the CALLING env must own an init reference (round-13 ownership model: -// an env with no reference must not create engines on the shared isolate), -// (3) pin the live isolate for the duration of the attach/create. -env_init_rec_t* self = env_init_rec_find_locked(env); -if (!g_initialized || g_isolate == NULL || - g_teardown_state == TEARDOWN_TEARING_DOWN || - self == NULL || self->init_refs == 0) { - uv_mutex_unlock(&g_mutex); - napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); - return NULL; -} -g_active_ops++; // pins the live isolate against teardown across the attach -uv_mutex_unlock(&g_mutex); -``` - -After this point, the existing attach/create/detach body runs unchanged, and the `g_active_ops` reservation is **released on every path that leaves the function after the reservation was taken** — success and each failure branch — with the verbatim pattern used everywhere else: - -```c -uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); -``` - -The `fn_create_engine`/`fn_create_engine_with_resolver`/`fn_attach_thread` availability checks (`if (!fn_create_engine) …`) move to *before* the lock (they throw without having taken the reservation) or stay after with the release — the implementer picks whichever keeps the diff minimal, provided every post-reservation exit balances `g_active_ops`. - -**Consequence for the record/hook-registration tail.** The existing OOM/hook-failure rollback paths in both create functions (the `calloc`-failure and `napi_add_env_cleanup_hook`-failure branches) must release the `g_active_ops` reservation in addition to their current cleanup (destroy the created engine, unlink, finalize). The reservation is released once, right before the function returns, on both the success path (after `napi_create_int64` produces the return value) and every failure path. - -**Retires a caveat.** Round-13's `env_init_cleanup` header documents a "pathological raw-ffi order" where `createEngine()` on env B succeeds because env A already initialized, *before* B's own `initialize()`. With requirement (2), that call is now correctly **rejected** (B owns no reference), so the caveat's premise no longer holds. Update that comment to note the create path now enforces per-env ownership. - -**Confirm during review:** -- The lifecycle check and `g_active_ops++` are in one `g_mutex` critical section; no teardown transition can split them. -- Every exit after the reservation balances `g_active_ops` exactly once (no double-decrement, no leak). Count the paths: success, invalid-handle, calloc-fail, hook-fail (create-engine); success, invalid-handle, attach-fail, calloc-fail, reference-fail, hook-fail (resolver variant — note attach-fail and the alloc failures *before* the reservation is taken must NOT decrement). -- An env with `init_refs == 0` (never initialized, or already cleaned up) is rejected with `Not initialized`. -- `g_ref_count` is untouched by this fix (creation never mutated it post round-13); the invariant is unaffected. - -### #2 + #3 (Medium) — teardown-failure paths strand a live, owner-less isolate - -**Defect.** On a reached-zero release, three failure modes leave the isolate physically alive with `g_ref_count == 0` and no pending teardown: -- **#2:** `release_isolate_ref_locked` Case 5 (addon.c:2607–2656) — `teardown_waiter_create` fails (promise/tsfn/resource-name N-API allocation) after `g_ref_count` was decremented to 0. Current code returns `NULL` (throws) with `g_teardown_state` reset to `TEARDOWN_NONE`. Also the Case 5 waiter **spawn** failure restores `g_ref_count = env_init_refs_total_locked()` (= 0) and leaves the isolate live. -- **#3:** `isolate_ref_release_n_locked` (addon.c:2399–2452, called by `env_init_cleanup` on env death) — waiter thread spawn fails, or `cleanup_thread_fn` attach fails so `torn_down` stays 0. `g_ref_count` is restored to `env_init_refs_total_locked()` (= 0 when the dying env was the last), isolate stays live. - -In all three, `g_ref_count == 0` and no env record remains that could call `cleanup()` again, and no `g_teardown_state` is set — so nothing ever retries teardown. The isolate is stranded until an unrelated later `initialize()` happens to adopt it (which may never come). The round-13 invariant (`g_ref_count == Σ init_refs`) is correctly *preserved* by these paths, but preserving it is not sufficient: a zero-owner live isolate needs a retry owner. - -**Fix — a `g_mutex`-guarded retry flag, not a phantom reference.** Add: - -```c -// Set under g_mutex when a reached-zero teardown could NOT be carried out -// (waiter alloc/spawn failed, or cleanup_thread_fn attach failed) and the -// isolate was therefore left live with g_ref_count == 0 and no pending -// teardown. This is a RETRY SIGNAL, not an ownership reference: g_ref_count -// stays 0 so the invariant g_ref_count == Σ init_refs is unaffected. It is -// cleared when the isolate is (a) actually torn down, or (b) adopted by a -// later initialize(). While set with g_active_ops > 0, the drain point at op -// completion retries the teardown once ops reach 0. -static bool g_teardown_needed = false; -``` - -Set `g_teardown_needed = true` in each of the three failure branches (#2 Case-5 waiter-create failure and waiter-spawn failure; #3 `isolate_ref_release_n_locked` waiter-spawn failure and `torn_down == 0` after the sync attempt) **only when** the isolate was left live (`g_isolate != NULL && g_ref_count == 0`). - -**Retry trigger at the op-completion drain point.** The natural retry owner is the last active op finishing. Add a helper that runs the reached-zero teardown decision: - -```c -// Caller holds g_mutex, KEEPS it held. If a prior teardown failed and left the -// isolate live with no owners (g_teardown_needed), and ops have now drained -// (g_active_ops == 0) with still no owners (g_ref_count == 0) and no teardown -// in progress, retry the synchronous teardown exactly as Case 4 does. -static void retry_stranded_teardown_locked(void) { - if (!g_teardown_needed) return; - if (g_ref_count > 0) { g_teardown_needed = false; return; } // adopted → no retry - if (g_teardown_state != TEARDOWN_NONE) return; // a teardown drives - if (g_active_ops > 0) return; // wait for drain - if (g_isolate == NULL) { g_teardown_needed = false; return; } - // g_active_ops == 0, g_ref_count == 0, isolate live: same synchronous - // teardown as Case 4 / isolate_ref_release_n_locked's g_active_ops==0 branch. - uv_thread_t tid; uv_thread_options_t opts; - opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - 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); - if (torn_down) { - g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; - g_teardown_needed = false; - } - // else: spawn/attach failed again — leave g_teardown_needed set to retry on - // the next drain (or a later initialize() adoption clears it). -} -``` - -Call `retry_stranded_teardown_locked()` under `g_mutex` at each op-completion drain point — i.e. immediately after the existing `g_active_ops--; uv_cond_broadcast(...)` blocks in the streaming/transform completion paths (the `bridge_end_op`/`g_active_ops--` sites). Since those sites already hold `g_mutex` for the decrement, fold the retry call into the same critical section (decrement, broadcast, then retry) to avoid re-locking. - -**Adoption clears the flag.** In `napi_initialize`'s adoption path (the `TEARDOWN_PENDING_WAIT` branch and the fast-path ref bump), and anywhere a new reference is acquired on a surviving isolate, set `g_teardown_needed = false` — a new owner means the isolate is wanted again. Concretely: whenever `env_init_acquire_and_hook` succeeds and `g_ref_count` transitions from 0 to 1 on a live isolate, clear the flag. The simplest correct placement is at the acquire sites right after a successful `g_ref_count++` on an already-live isolate. - -**Why a flag and not "restore caller ownership on alloc failure".** Restoring `self->init_refs` and `g_ref_count` on the failing env would (a) violate the caller's contract (the JS `cleanup()` promise resolves as if the reference was dropped, but the count says otherwise), and (b) for the env-death path (#3) the record is already freed — there is no env to restore ownership to. A separate retry signal decoupled from the reference count is the only model that works uniformly for both the live-caller and no-surviving-env cases while keeping `g_ref_count == Σ init_refs` exactly true. - -**Confirm during review:** -- `g_teardown_needed` is read/written only under `g_mutex`. -- The invariant `g_ref_count == Σ init_refs` holds at every g_mutex release — the flag never substitutes for a reference. -- The retry is idempotent and bounded: it makes the reached-zero teardown decision at most once per drain, and a repeated attach failure simply re-arms for the next drain without spinning. -- No env-affine napi call is made from any thread but the env's own (the retry runs on the JS thread at op completion; `cleanup_thread_fn` attaches its own GraalVM thread and makes no napi calls). -- Adoption in `napi_initialize` clears the flag so a re-init does not later tear down a wanted isolate. -- No deadlock: `retry_stranded_teardown_locked` spawns+joins `cleanup_thread_fn` while holding `g_mutex`, exactly as the existing Case-4 / `isolate_ref_release_n_locked` g_active_ops==0 branch does; `cleanup_thread_fn` takes no lock. - -### #4 (Medium) — cross-env regression test that actually pins the round-12 defect - -**Defect.** Round-13's `env-init-ownership.test.ts` are single-env smoke tests whose own header admits they pass on the pre-fix addon. `worker-lifecycle.test.ts`'s N-Worker test creates only **one** engine per Worker init, so it never exercises the round-12 over-release (N per-engine releases against one init reference). - -**Fix.** Add a Worker-based regression test to `worker-lifecycle.test.ts` (reusing its inline-JS-body + built-addon harness) that: -1. On the main thread: `initialize()` and create a live engine (`h_main`), run a script to confirm it works. -2. Spawn a Worker that: `initialize()` once, creates **N ≥ 3** engines (resolver-less is fine), runs a script on one, and exits **without** `cleanup()` and without destroying its engines — so the Worker env dies with N engines under one init reference. -3. After the Worker exits: assert `h_main` **still runs** (`6 * 7 === 42`) — proving the shared isolate was not torn down by the Worker's env death. -4. Balance the main reference (`destroyEngine(h_main)` + `cleanup()`), then assert a raw `runScriptEngine(Number.MAX_SAFE_INTEGER, …)` throws `/not initialized/i` — proving the count reached exactly zero (no leak, no over-release). - -**Determinism note in the test.** On the **round-12** implementation this goes RED: the Worker's env-death hooks fired N per-engine releases against a count of 1, driving `g_ref_count` negative/to-zero and tearing the isolate down under the live `h_main` → step 3's run fails (isolate gone) or the process wedges. On round-13+ each abandoned env releases exactly one reference regardless of engine count, so `h_main` survives. The test must await Worker `exit` (not just `message`) before asserting step 3, so the env-death hooks have run. Use the stricter `runWorker` helper from #5. - -The two existing `env-init-ownership.test.ts` smoke tests stay (they guard the single-env liveness path), but the file header's "known coverage gap … remains a follow-up" paragraph is updated to point at this new cross-env test as the gap's closure. - -**Confirm during review:** -- The test loads the real built addon (no `vi.mock`), spawns a genuine Worker, and creates N ≥ 3 engines in it. -- It awaits Worker exit before the post-exit assertions. -- It balances all references so it does not perturb sibling integration files (main `cleanup()` at the end; the file's `afterAll` already calls `ffi.cleanup()` idempotently). -- The RED-on-round-12 / green-on-round-13 reasoning is documented in a comment. - -### #5 (Medium) — Worker lifecycle helper hides a nonzero exit - -**Defect.** `runWorker` (worker-lifecycle.test.ts:71–83) resolves as soon as the Worker posts a message, and its `exit` handler only rejects `if (code !== 0 && !msg)`. A Worker that posts its success result and *then* exits nonzero (e.g. an env-cleanup-hook failure during teardown) resolves as success — the failure is hidden. - -**Fix.** Rework the promise so that: -- The message is captured but resolution waits for `exit`. -- On `exit`: reject **every** nonzero code (`new Error("Worker exited with code " + code + (msg ? "" : " and posted no message"))`). -- On `exit` code 0 **with** a captured message: resolve with the message. -- On `exit` code 0 **without** a message: reject as a distinct diagnostic (`"Worker exited 0 without posting a result"`). -- Keep the `error` handler rejecting. - -All existing callers already `await` the result and assert `msg.ok`, so tightening resolution to `exit` is compatible; the abandon-variant Workers exit 0 after posting, so they still resolve. - -**Confirm during review:** no caller regresses; the N-Worker abandon test and the new #4 test both still pass; a hypothetical nonzero-exit Worker now rejects. - -### #6 (Medium) — `DataWeave.cleanup()` leaks the init reference if `destroyEngine()` throws - -**Defect.** `DataWeave.doCleanup()` (dataweave.ts:145–159) calls `ffi.destroyEngine(this.engineHandle)` before `await ffi.cleanup()`. If `destroyEngine` throws (a real path: wrong-thread destruction throws synchronously), the `finally` resets `this.state`/`this.engineHandle` but `ffi.cleanup()` never runs — the native init reference for this env is never released, and the engine handle is no longer reachable from the instance. The reference leaks. - -**Fix.** Ensure `ffi.cleanup()` runs even when `destroyEngine()` throws, preserving the primary (destruction) error: - -```ts -private async doCleanup(): Promise { - this.state = "cleaning-up"; - let destroyError: unknown; - try { - if (this.engineHandle !== null) { - try { - ffi.destroyEngine(this.engineHandle); - } catch (e) { - // Preserve the primary error but STILL release the native init - // reference below — otherwise a throwing destroyEngine() (e.g. - // wrong-thread destruction) would strand this env's reference and - // block isolate teardown. The engine handle is cleared regardless so - // a retry does not double-destroy. - destroyError = e; - } finally { - this.engineHandle = null; - } - } - await ffi.cleanup(); - } finally { - this.state = "uninitialized"; - } - if (destroyError !== undefined) throw destroyError; -} -``` - -The `await ffi.cleanup()` now always runs (releasing the reference); a destruction error is re-thrown after cleanup so callers still observe it. If `ffi.cleanup()` itself also throws, its error propagates from the `await` (the destruction error is then suppressed — acceptable: the reference-release failure is the more actionable one, and this matches the "report/suppress secondary" guidance). - -**Test.** Add a unit test (in the existing `dataweave.ts` unit suite, with `ffi` mocked) where `destroyEngine` is mocked to throw: assert (a) `ffi.cleanup()` was still called, (b) the original destruction error propagates from `cleanup()`, (c) `this.state` ends `uninitialized`. - -**Confirm during review:** `ffi.cleanup()` is invoked on the throwing-`destroyEngine` path; the primary error is preserved; `engineHandle` is cleared so a subsequent cleanup does not re-destroy; the coalescing/`cleanupPromise` logic in the public `cleanup()` wrapper is unaffected. - -### #7 (Low) — resolver quick-start examples omit cleanup - -**Defect.** `external-modules.md:7–25` and `README.md:231–253` show resolver-backed `DataWeave` instances with no `await dw.cleanup()`, though later docs state uncleaned instances retain their engine and resolver closure. - -**Fix.** Wrap each complete example's `dw.initialize()`/`dw.run()` in `try { … } finally { await dw.cleanup(); }` and make the surrounding scope `async` (or add a one-line note that the snippet runs inside an async function). Keep the example output comments intact. - -**Confirm during review:** both examples show `await dw.cleanup()` in a `finally`; the snippets remain runnable (async context noted); no other doc claims are altered. - -## Task Ordering - -1. **#1** — engine-creation admission (isolated, High, `addon.c`). -2. **#2 + #3** — teardown-failure retry flag + drain-point retry + adoption clear (`addon.c`; shared machinery, done as one task). -3. **#6** — `doCleanup()` reference-leak fix + unit test (`dataweave.ts`). -4. **#5** — `runWorker` helper strictness (`worker-lifecycle.test.ts`). -5. **#4** — cross-env Worker regression test (`worker-lifecycle.test.ts`; depends on #5's stricter helper). -6. **#7** — docs cleanup (`external-modules.md`, `README.md`). - -Each task ends green on the full Node vitest suite. Baseline before this round: **897 passed / 59 skipped / 0 failed**. Net new tests: #6 (1 unit) + #4 (1 integration) → target **899 passed / 59 skipped / 0 failed** (the helper change in #5 alters no test count). - -## Build & Test - -- Build: `cd native-lib/node && npm run build:addon && npm run build:ts` -- Test: `DATAWEAVE_NATIVE_LIB=/Users/lmariano/dev/mulesoft/data-weave-cli/native-lib/node/native/dwlib.dylib npm test` -- `dwlib.dylib` is unchanged this round (only `addon.c`, `dataweave.ts`, tests, and docs change — no Java). - -## Rejected Alternatives - -- **#1: check `g_initialized` under the lock but skip the `g_active_ops` reservation.** Insufficient: the attach happens after the lock is dropped, so a teardown can still start between the check and `fn_attach_thread`. The reservation is what pins the isolate across the attach, exactly as `bridge_finalize_registry` does. -- **#2/#3: restore the failing caller's ownership (`init_refs`/`g_ref_count`) instead of a flag.** Breaks the JS `cleanup()` contract (promise resolves as released while the count says held) and is impossible for the env-death path (the record is already freed). A retry signal decoupled from the count is the only uniform model. -- **#2/#3: spawn a dedicated retry thread that polls until teardown succeeds.** Adds a background thread and a spin loop for a rare OOM/spawn-failure path; the op-completion drain point is a natural, already-locked retry owner with no new thread. -- **#4: keep documenting the gap (round-13 decision).** The reviewer raised this class twice; the user chose to write the real cross-env test this round. diff --git a/docs/superpowers/specs/2026-08-21-review6-singleton-stream-teardown-hardening-design.md b/docs/superpowers/specs/2026-08-21-review6-singleton-stream-teardown-hardening-design.md deleted file mode 100644 index 4bcd5bf9..00000000 --- a/docs/superpowers/specs/2026-08-21-review6-singleton-stream-teardown-hardening-design.md +++ /dev/null @@ -1,334 +0,0 @@ -# Review #6 Remediation — Singleton, Stream, and Teardown Hardening (Round 15) - -**Status:** Design approved. Ready for implementation plan. - -**Scope decision (user):** Fix all 8 code findings (#1–#8). Finding #9 (Python-binding scope) is left as-is with a PR note, not a code change. Full pipeline (spec → plan → SDD). Standing finish: push + update PR #157. - -**Reviewed head:** `7017ded` (round-14 HEAD). All 8 code findings validated against live source before this design. - ---- - -## Context - -`docs/pr-157-follow-up-code-review-6.md` raised 9 findings against PR #157 head `7017ded`. Eight are code fixes; #9 is a scope/process observation (the PR carries broad Python-binding modernization beyond the Node multi-engine change) handled by a PR comment, not code. - -The findings fall into three clusters plus one process note: - -- **Cluster A (TypeScript, 2 High):** a real user-facing singleton-poisoning bug (#1) and a real stream-hang bug (#2). -- **Cluster B (C teardown, 2 Medium):** two hardening gaps (#3, #4) in the round-14 teardown machinery. -- **Cluster C (C teardown design, 1 Medium):** the drain-reachability gap (#5) that round 14's own final reviewer flagged as a non-blocking observation. -- **Cluster D (tests, 2 Medium + 1 Low):** test-hygiene fixes (#6, #7, #8) that keep the suite honest. - -**Preserved invariant (unchanged from round 14, binding on every C change here):** -`g_ref_count == Σ per-env init_refs` at every `g_mutex` release. `g_teardown_needed` is a retry SIGNAL, not a reference — set only when `g_ref_count == 0` and the isolate is still live; never added to any count; read/written only under `g_mutex`. - -**Global constraints (carried from prior rounds):** -- Node-binding only. Never touch `native-lib/python/**`, the Java side, or the legacy singleton entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`, `dw_napi_run_script`, `ScriptRuntime.getInstance()`). -- Handle width stays C `long long`. -- Errors surface as resolved JSON strings (async) or synchronous `napi_throw_error` / thrown `DataWeaveError` — never `napi_reject_deferred`. -- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread; no env-affine napi call from the wrong thread. - ---- - -## Cluster A — TypeScript user-facing bugs - -### #1 (High): a failed first module-level initialization permanently poisons the singleton - -**Defect:** `getGlobalInstance()` (`native-lib/node/src/dataweave.ts:366-372`) assigns `globalInstance` *before* `initialize()` succeeds: - -```ts -function getGlobalInstance(): DataWeave { - if (!globalInstance) { - globalInstance = new DataWeave(); - globalInstance.initialize(); // if this throws, globalInstance stays set-but-uninitialized - registerExitHooksOnce(); - } - return globalInstance; -} -``` - -If `initialize()` throws (bad `DATAWEAVE_NATIVE_LIB` path, transient native failure), the singleton remains a non-null, uninitialized `DataWeave`. Every later `run*()` reuses it and fails only with "not initialized" — even after the underlying cause is fixed. - -**Fix:** construct and initialize a *local candidate*; assign `globalInstance` only after `initialize()` returns; register exit hooks after the successful assignment. - -```ts -function getGlobalInstance(): DataWeave { - if (!globalInstance) { - // Initialize a LOCAL candidate first; publish the singleton only after - // initialize() succeeds. A failed first init (bad lib path / transient - // native failure) must NOT leave a poisoned, uninitialized singleton that - // makes every later run*() fail "not initialized" even after the fault is - // fixed (review #6 #1). On throw, globalInstance stays null and the next - // call retries cleanly. - const candidate = new DataWeave(); - candidate.initialize(); - globalInstance = candidate; - registerExitHooksOnce(); - } - return globalInstance; -} -``` - -**Regression:** fail singleton init once (mock `ffi.initialize` to throw), assert the call rejects/throws and `globalInstance` was not published; then correct the fault (mock initialize to succeed) and assert the next `run()` builds a fresh working singleton. - -### #2 (High): a rejected native streaming promise can hang the consumer forever - -**Defect:** `streamFromNative()` (`native-lib/node/src/stream.ts:39-47`) wires only the fulfilled branch: - -```ts -const metaPromise = start(chunkCb).then((raw) => { - metaRaw = raw; - done = true; - while (pendingResolves.length > 0) { - const resolve = pendingResolves.shift(); - if (resolve) resolve(); - } -}); -``` - -If `start()` rejects, `done` never becomes `true` and parked `next()` consumers (waiting on a `pendingResolves` promise, stream.ts:55) are never woken → the generator hangs forever. The rejection is also unhandled. - -**Fix:** handle both settlement branches — on rejection, record the error, set completion, wake all waiters. After the drain loop, if a start error was recorded, throw it (so the consumer sees a rejection, not a silent empty completion). Buffered chunks that arrived before the rejection still drain first. - -```ts - let startError: unknown; - const wakeAll = () => { - while (pendingResolves.length > 0) { - const resolve = pendingResolves.shift(); - if (resolve) resolve(); - } - }; - const metaPromise = start(chunkCb).then( - (raw) => { metaRaw = raw; done = true; wakeAll(); }, - (err) => { - // Native start() rejected. Without this branch, `done` stays false and a - // consumer parked in next() is never woken -> the generator hangs forever, - // and the rejection is unhandled (review #6 #2). Record the failure, mark - // completion, and wake every waiter; the error is re-thrown after draining - // any chunks that arrived before the rejection. - startError = err; - done = true; - wakeAll(); - } - ); - - while (true) { - if (chunks.length > 0) { yield chunks.shift()!; continue; } - if (done) break; - await new Promise((resolve) => { pendingResolves.push(resolve); }); - } - - while (chunks.length > 0) { yield chunks.shift()!; } - - await metaPromise; // settles (fulfilled) since we handled rejection above - if (startError !== undefined) throw startError; - return parseStreamingResult(metaRaw ?? ""); -``` - -Note: because the `.then(onFulfilled, onRejected)` form handles rejection, `metaPromise` itself always fulfills, so `await metaPromise` never throws and there is no unhandled rejection. The consumer-visible error is the explicit `throw startError`. - -**Regression:** `start: () => Promise.reject(new Error("native start boom"))` with a consumer that is already parked in `next()` before the rejection settles — assert `next()` (or the `for await`) rejects with the error and does not hang. A second test: chunks buffered then rejection — assert buffered chunks yield first, then it throws. - ---- - -## Cluster B — C teardown hardening - -### #3 (Medium): isolate teardown reports success even when Graal teardown fails - -**Defect:** both teardown sites treat calling `graal_tear_down_isolate()` as success without checking its `int` return (`typedef int (*graal_tear_down_isolate_fn)(void*)`, addon.c:12): - -- `cleanup_thread_fn` (addon.c:2332): `fn_tear_down_isolate(local_thread); *out_torn_down = 1;` -- `teardown_waiter_thread_fn` (addon.c:2366-2368): `fn_tear_down_isolate(local_thread); torn_down = true;` (comment at 2360 literally says "Ignore the return code, matching today's behavior.") - -If teardown returns nonzero, the callers still clear `g_isolate`/`g_initialized`/`g_ref_count` as if the isolate is gone — orphaning a live isolate and allowing a *second* `graal_create_isolate` in the same process (unsupported). - -**Fix:** set `torn_down` only when the call returns 0. - -- `cleanup_thread_fn`: - ```c - *out_torn_down = (fn_tear_down_isolate(local_thread) == 0) ? 1 : 0; - // Nonzero: teardown failed, isolate still live -- leave *out_torn_down = 0 so - // the caller retains g_isolate/g_initialized and arms the retry (review #6 #3). - ``` -- `teardown_waiter_thread_fn`: - ```c - torn_down = (fn_tear_down_isolate(local_thread) == 0); - ``` - Update the stale comment at 2360. - -The existing "attach failed → leave torn_down 0" paths already handle the retained-live-isolate case correctly; #3 just extends that to the "attach succeeded but teardown returned nonzero" case. Arming the retry on a nonzero teardown is handled together with #4 below (both are in `teardown_waiter_thread_fn`'s post-teardown block). - -### #4 (Medium): async teardown-waiter attach failure leaves an ownerless isolate without retry - -**Defect:** in `teardown_waiter_thread_fn`'s post-teardown lock (addon.c:2379-2389), when `!cancelled && !torn_down` (attach failed, or — after #3 — teardown returned nonzero), the code leaves `g_ref_count == 0`, no owner, no pending waiter, `g_teardown_state = TEARDOWN_NONE`, and does *not* arm `g_teardown_needed`. The comment claims "retried on the next last release" — but this async waiter path IS the last-release path (`isolate_ref_release_n_locked`'s `g_active_ops > 0` branch spawned it). There is no future last-release; the isolate is stranded with no retry signal. - -**Fix:** in that post-teardown block, when teardown did not happen and the isolate is still live with zero owners, arm the retry signal: - -```c - uv_mutex_lock(&g_mutex); - if (!cancelled && torn_down) { - g_thread = NULL; - g_isolate = NULL; - g_initialized = 0; - g_ref_count = 0; - } else if (!cancelled && g_isolate != NULL && g_ref_count == 0) { - // Teardown did not happen (attach failed, or graal_tear_down_isolate - // returned nonzero -- review #6 #3) and this async-waiter path IS the - // last release: g_ref_count is already 0 with no owner and no pending - // waiter. Arm the retry signal so a later op-completion drain or an - // initialize() retries teardown -- otherwise the live isolate is stranded - // with nothing to reclaim it (review #6 #4). - g_teardown_needed = true; - } - g_teardown_state = TEARDOWN_NONE; - g_teardown_cancelled = false; - ... -``` - -This mirrors the arm already present in `isolate_ref_release_n_locked`'s waiter-spawn-failure path (addon.c:2570) and Case-4 sync-failure path. - ---- - -## Cluster C — the #5 drain-reachability gap - -### #5 (Medium): stranded-teardown retry is not guaranteed to run when no operation remains - -**Defect:** the zero-active-op synchronous failure paths arm `g_teardown_needed`: -- `isolate_ref_release_n_locked` sync branch (addon.c:2543-2548) -- `release_isolate_ref_locked` Case-4 (addon.c:2725) - -But `retry_stranded_teardown_locked()` is called ONLY from the streaming (addon.c:967) and transform op-completion drains. In the zero-op state there is no pending operation to drain, so the retry never fires. Worse, a later `initialize()` currently *adopts* the isolate and clears the flag (the fast-path / adoption clears at addon.c:623/643/724) instead of completing the pending teardown. `cleanup()` has already resolved, so from the caller's view the reference was released — but the isolate the retry was meant to reclaim is silently kept alive and its retry intent discarded. - -**Chosen fix (user decision): make the next `initialize()` complete the pending teardown before adopting — no new async infrastructure.** - -At the top of `napi_initialize`, under `g_mutex`, before the existing adoption / fast-path / create-path logic: if `g_teardown_needed` is set (a prior teardown failed and the isolate is stranded with zero owners), call `retry_stranded_teardown_locked()` first. - -- If the retry succeeds, `g_isolate` becomes `NULL` and `g_initialized` becomes 0 → `napi_initialize` falls through to the create path and builds a fresh isolate. The pending teardown is honored, not discarded. -- If the retry fails again (spawn/attach/teardown still failing), the isolate is still live; `napi_initialize` proceeds to adopt it via the existing fast path (which clears the now-still-set flag). Adopting a live isolate whose teardown was merely resource-reclamation (not a malfunction) is safe and functionally identical to normal adoption. - -```c - uv_mutex_lock(&g_mutex); - // A prior last-release could not tear the isolate down and armed the retry - // signal (review #6 #3/#4). Because retries otherwise fire only at op - // completion, a zero-op stranded isolate would never be reclaimed and a naive - // adoption below would silently discard the pending teardown (review #6 #5). - // Drive the pending teardown to completion here first: on success g_isolate is - // cleared and we build a fresh isolate below; on repeated failure the live - // isolate is adopted by the fast path (safe -- teardown was reclamation, not a - // malfunction). - retry_stranded_teardown_locked(); - // ... existing TEARDOWN_PENDING_WAIT adoption / fast-path / create-path logic ... -``` - -`retry_stranded_teardown_locked()` already no-ops safely when `g_teardown_needed` is false, when `g_active_ops > 0`, or when a teardown is in progress — so this call is a cheap guard on the common path (flag clear → immediate return). - -**Documented residual degradation (accepted):** if a teardown fails AND no later `initialize()` or streaming/transform op ever occurs, the stranded isolate lingers until process exit, where the OS reclaims it. This is benign (a single process-lifetime isolate, no correctness or reference-count violation) and is the deliberate tradeoff for avoiding event-loop-affine async retry infrastructure on this concurrency-sensitive code. This residual is documented in a comment at the arming sites and in the spec's Rejected Alternatives. - ---- - -## Cluster D — test hardening - -### #6 (Medium): Worker clean-lifecycle scenarios suppress explicit engine-destruction errors - -**Defect:** in `runWorker`'s worker body (`native-lib/node/tests/integration/worker-lifecycle.test.ts:62-65`), the `cleanup: true` path swallows `destroyEngine` errors: - -```js -if (workerData.cleanup) { - try { addon.destroyEngine(handle); } catch (_) {} - await addon.cleanup(); -} -``` - -A broken explicit-destruction path can be masked by the subsequent `addon.cleanup()`, so a "clean lifecycle" test still passes. - -**Fix:** capture the destruction error, still run `addon.cleanup()` in a `finally`, then fold the original error into the posted message (so the stricter `runWorker` exit handling and the caller's `msg.ok` assertion surface it): - -```js -if (workerData.cleanup) { - let destroyErr; - try { - addon.destroyEngine(handle); - } catch (e) { - destroyErr = e; // preserve; do NOT let cleanup() mask a broken destroy path - } finally { - await addon.cleanup(); - } - if (destroyErr) msg = { ok: false, error: "destroyEngine failed: " + String(destroyErr) }; -} -``` - -This keeps all existing clean-path Workers green (destroy succeeds → `destroyErr` undefined → `msg` unchanged) while surfacing a real destruction failure as `ok: false`. - -### #7 (Medium): the cross-env regression can contaminate later tests on failure - -**Defect:** the round-14 cross-env test (`worker-lifecycle.test.ts:209-272`) acquires `hMain` and a main-thread init reference with no `try/finally`. Any Worker or assertion failure before the final `destroyEngine(hMain)` + `cleanup()` leaves global native state (live isolate, held reference) for subsequent tests. - -**Fix:** wrap the test body in `try/finally`. In `finally`, destroy `hMain` if it was acquired and balance the main init reference (`await ffi.cleanup()`), guarded so the balancing does not throw over and mask an original assertion failure: - -```ts - let hMain: number | null = null; - try { - ffi.initialize(LIB_PATH); - hMain = ffi.createEngine(); - // ... existing test body, using hMain ... - } finally { - // Balance global native state even if a Worker/assertion failed above, so - // this test cannot strand a live isolate + held reference for sibling - // integration tests (review #6 #7). Do not let cleanup errors mask the - // original failure. - try { - if (hMain !== null) ffi.destroyEngine(hMain); - await ffi.cleanup(); - } catch { /* balancing best-effort; original failure (if any) propagates */ } - } -``` - -The final positive assertions (main engine survives; raw op throws `/not initialized/i` after balancing) stay in the `try` so the test still proves what it did before; only the reference balancing moves to `finally`. Because the `finally` now always balances, the `/not initialized/i` probe must run inside `try` *before* the finally's cleanup (it already does — it is the last positive step of the body). The RED-on-round-12 behavior is unchanged: the main-engine survival assertion still fails on round-12. - -### #8 (Low): the initialization unit test can falsely pass when reinitialization is a no-op - -**Defect:** `dataweave-initialize.test.ts:248-252` asserts a second `initialize()` via `toHaveBeenLastCalledWith()`, but the *first* `initialize()` already called `createEngine()` with the same (no) arguments — so the assertion passes even if the second init created no engine. - -**Fix:** clear the `createEngine` mock before the re-initialization (`vi.mocked(ffi.createEngine).mockClear()`), or assert the call count went from 1 to 2. The design uses `mockClear()` before the second `initialize()` plus `expect(ffi.createEngine).toHaveBeenCalledTimes(1)` after, proving the re-init genuinely created a fresh engine. - ---- - -## File / task structure - -Each task ends with an independently testable deliverable and a fresh reviewer gate. - -| Task | Finding(s) | Files | Test | -|------|-----------|-------|------| -| 1 | #1 | `src/dataweave.ts` (`getGlobalInstance`) | `tests/unit/dataweave-initialize.test.ts` (+1) | -| 2 | #2 | `src/stream.ts` (`streamFromNative`) | `tests/unit/stream.test.ts` (+2) | -| 3 | #3, #4 | `src/addon.c` (`cleanup_thread_fn`, `teardown_waiter_thread_fn`) | error-path C hardening; suite unchanged | -| 4 | #5 | `src/addon.c` (`napi_initialize`) | error-path C hardening; suite unchanged | -| 5 | #6, #7 | `tests/integration/worker-lifecycle.test.ts` | suite unchanged (all green paths still pass) | -| 6 | #8 | `tests/unit/dataweave-initialize.test.ts` | tightened assertion; suite unchanged | - -**Task ordering rationale:** -- Tasks 1 and 6 both touch `dataweave-initialize.test.ts`; Task 1 *appends* a new test, Task 6 *tightens an existing* test — no overlap, but Task 6 runs after Task 1 to avoid a stale line-anchor. -- Tasks 3 and 4 both touch `addon.c` teardown machinery; 3 (thread-fn return codes + arm) precedes 4 (`napi_initialize` drives the retry), since 4's fix relies on 3's arming being correct. -- Task 5's two changes (#6, #7) are in one file and reviewed together. - -**Expected suite deltas:** Task 1 +1 unit, Task 2 +2 unit; Tasks 3–6 no count change (error-path C hardening + test tightening). Round-14 baseline 899/59/0 → **902/59/0** after this round. - ---- - -## Verification (end-to-end) - -1. `cd native-lib/node && npm run build:addon` — clean, no new warnings in `cleanup_thread_fn`, `teardown_waiter_thread_fn`, or `napi_initialize`. `npm run build:ts` clean. -2. `npm test` (with `DATAWEAVE_NATIVE_LIB` set) — **902 passed / 59 skipped / 0 failed**. -3. **Invariant audit (review gate):** every `g_ref_count` mutation still paired with an `init_refs` mutation or a rollback to `env_init_refs_total_locked()`/0; `g_teardown_needed` set only when `g_ref_count == 0`, never added to a count; all new shared-state access under `g_mutex`. The #3 return-code check must never clear `g_isolate`/`g_initialized`/`g_ref_count` on a nonzero teardown. -4. #1/#2 regressions genuinely reproduce the bug (fail on the pre-fix code): singleton stays poisoned; stream hangs/rejects unhandled. -5. `git diff --check` — no whitespace errors. - ---- - -## Rejected Alternatives - -- **#1: reset `globalInstance = null` in a `catch` inside `getGlobalInstance` instead of a local candidate.** Works, but the local-candidate pattern is clearer (the singleton is never observably set to a bad value, even transiently across an `await` boundary elsewhere) and matches the "construct-then-publish" idiom the reviewer requested. -- **#2: reject via `napi_reject_deferred` / a rejected returned promise from the generator.** The generator contract is to throw from `next()`; the codebase deliberately surfaces errors as thrown values, not rejected deferreds (global constraint). An explicit `throw startError` after draining is the idiomatic fit. -- **#5: dedicated async retry owner (uv_async / uv_timer).** Fully closes the no-future-init-or-op residual, but adds event-loop-affine async infrastructure and new concurrency surface to the most sensitive code in the binding. The user chose init-driven completion + documented degradation as the lower-risk option; the residual (isolate lingers to process exit if nothing else ever happens) is benign. -- **#5: block `napi_initialize` until the pending teardown physically completes on a helper thread even when it keeps failing.** Could deadlock or spin on a persistently failing `graal_tear_down_isolate`; adopting the live isolate after one retry attempt is safe and bounded. -- **#9: split the Python-binding work into its own PR now.** User chose to leave the PR as-is and note the bundling in a PR comment; no git surgery this round. diff --git a/docs/superpowers/specs/2026-08-24-review7-teardown-detach-rollback-doc-hardening-design.md b/docs/superpowers/specs/2026-08-24-review7-teardown-detach-rollback-doc-hardening-design.md deleted file mode 100644 index e649b370..00000000 --- a/docs/superpowers/specs/2026-08-24-review7-teardown-detach-rollback-doc-hardening-design.md +++ /dev/null @@ -1,108 +0,0 @@ -# Review #7 — Teardown-detach, init-rollback, and lifecycle-doc hardening (W-23692110) - -**Status:** Approved design. Reviewed head at review time: `aaeafb9`; live head at design time: `6fb5603` (post-rebase onto master). All findings re-verified against `6fb5603`; line numbers below are the live ones. - -**Reviewer:** `docs/pr-157-follow-up-code-review-7.md` (the "code-review" series, round 7). Eight findings. Seven are in scope this round (#1–#7); #8 (Python-scope split) is kept as the standing "leave-as-is, note in PR" decision and answered to the reviewer, not actioned in code. - -## Goal - -Close the seven code/documentation findings from review #7 without regressing the multi-engine lifecycle invariants established in rounds 1–15. Two are genuine native-lifecycle defects (a phantom attached GraalVM thread on failed teardown; an unsignaled init-wait after a failed init-hook rollback), one is a TypeScript rollback-observability defect, one is a low-severity stream mis-report, one is a test-hygiene gap, and two are documentation corrections. - -## Invariant (unchanged, preserved by every fix) - -`g_ref_count == Σ per-env init_refs` at every `g_mutex` release. `g_teardown_needed` is a **retry signal, not a reference** — set only when `g_ref_count == 0` and the isolate is still live; never added to any count; read/written only under `g_mutex`. Teardown state machine: `TEARDOWN_NONE` / `TEARDOWN_PENDING_WAIT` / `TEARDOWN_TEARING_DOWN`, all transitions under `g_mutex`. Errors surface as resolved JSON strings (async) or synchronous `napi_throw_error` / thrown `DataWeaveError` — never `napi_reject_deferred`. - -## Global Constraints (binding on every task) - -- Node-binding-only scope. 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()`. Java side not modified this round. -- Handle width stays C `long long` everywhere. -- Errors surface as resolved JSON strings (async) or synchronous `napi_throw_error` / thrown `DataWeaveError` — NEVER `napi_reject_deferred`. -- `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are thread-affine to the env's JS thread; no env-affine napi call from the waiter/wrong thread. -- All new shared state read/written only under `g_mutex`. -- `initialize()` and `run()` stay **synchronous** (returning `void` / `ExecutionResult`, not Promises) — an async signature is an API break and is a rejected alternative. -- `docs/superpowers/plans/` is git-ignored; only specs are tracked. Do not `git add -A` — untracked scratch docs under `docs/` must stay untracked; stage only named files. - -## Findings and chosen fixes - -### #1 (High) — failed Graal teardown leaves the cleanup thread attached to the live isolate - -**Where:** `native-lib/node/src/addon.c` — `cleanup_thread_fn` (~2337–2347) and `teardown_waiter_thread_fn` (~2379–2388). - -**Defect:** Both paths attach a local IsolateThread (`fn_attach_thread`), call `fn_tear_down_isolate(local_thread)`, and treat a nonzero return as failure (correctly, since review #6 #3) by leaving the isolate live and arming the retry. But on that failure branch they exit the helper thread **without detaching** `local_thread`. `graal_tear_down_isolate` does not tear down on a nonzero return, so the attachment is still live; exiting the OS thread while attached leaves a phantom attached thread in the isolate, which can make a later retry teardown block or fail indefinitely. - -**Fix:** On the nonzero-teardown branch **only**, call `fn_detach_thread(local_thread)` before leaving `torn_down` / `*out_torn_down` at 0. The success branch (return 0) is untouched — the isolate is gone and the thread must NOT be detached against a torn-down isolate. The attach-failure branch is untouched — no thread was attached. - -- `cleanup_thread_fn`: change `*out_torn_down = (fn_tear_down_isolate(local_thread) == 0) ? 1 : 0;` to capture the result, and on nonzero call `fn_detach_thread(local_thread)` before leaving `*out_torn_down` at 0. -- `teardown_waiter_thread_fn`: same shape around `torn_down = (fn_tear_down_isolate(local_thread) == 0);`. - -This does not change any state-machine transition, ref count, or the retry arming — it only reclaims the thread attachment on the already-existing failure path. - -### #2 (Medium) — init-hook failure can wedge all future initialization if compensating teardown fails - -**Where:** `native-lib/node/src/addon.c` `napi_initialize` (~682–726), the `if (!env_init_acquire_and_hook(env))` rollback block. - -**Defect:** When `env_init_acquire_and_hook` fails after the isolate was built, the code spawns `cleanup_thread_fn` to tear the just-built isolate back down. On success (`torn_down`) it clears `g_isolate`/`g_thread`/`g_initialized` — recoverable. But on the `else` branch (spawn failed, or attach/teardown failed) it leaves `g_isolate != NULL, g_initialized == 0, g_teardown_state == TEARDOWN_NONE`, and **no retry signal armed**. The next `initialize()` on any env reaches the wait loop condition `g_isolate != NULL && !g_initialized`, and with `TEARDOWN_NONE` it cannot take the adoption branch, so it falls into `uv_cond_wait(&g_teardown_cond, ...)` that nothing will ever broadcast → every future `initialize()` hangs forever. - -**Fix:** In that `else` branch, arm the retry signal: `g_teardown_needed = true;`. `retry_stranded_teardown_locked()` already runs at the very top of `napi_initialize` (~607, under `g_mutex`), so the next `initialize()` retries the stranded teardown before reaching the wait loop — either clearing the isolate (then building fresh) or, on repeated failure, adopting the still-live isolate via the fast path. This mirrors the identical arm already present in `teardown_waiter_thread_fn` (~2402–2410) and `isolate_ref_release_n_locked`'s waiter-spawn-failure path. Ref-count reasoning is unchanged: `g_ref_count` is still 0 here (we never bumped it), so arming `g_teardown_needed` (a signal, not a reference) does not perturb the invariant. - -### #3 (Medium) — module/instance initialization rollback starts async cleanup without observing it - -**Where:** `native-lib/node/src/dataweave.ts` `initialize()` (~92–105), the `catch` block calling `ffi.cleanup()`. - -**Defect:** When engine creation throws after `ffi.initialize()` succeeded, the catch calls `ffi.cleanup()` (which returns `Promise`) to release the native library ref, but neither awaits nor attaches a handler. A rollback-teardown rejection becomes an unhandledRejection, and a caller can immediately retry `initialize()`/`run()` while that rollback is still in flight — racing a fresh `graal_create_isolate` against the in-flight release. - -**Fix:** Model the rollback as pending state using the existing `state`/`cleanupPromise` machinery, keeping `initialize()` synchronous: -- Before throwing, set `this.state = "cleaning-up"` and assign `this.cleanupPromise` to the rollback promise: `ffi.cleanup()` wrapped so that when it settles the state returns to `"uninitialized"` and `cleanupPromise` clears (in a `.finally`), mirroring `doCleanup()`/`cleanup()`. -- Attach a `.catch(() => {})` to the stored promise so an un-awaited rollback never surfaces as an unhandledRejection. -- Because `state` is `"cleaning-up"` until the rollback settles, a concurrent `initialize()` hits the existing `"cleaning-up"` guard and throws "Cannot initialize while cleanup is in progress; await cleanup() first." — deterministic rejection instead of a race. A concurrent `cleanup()` coalesces onto the same `cleanupPromise` (existing behavior). -- The synchronous `throw new DataWeaveError(...)` to the *current* caller is preserved (the initialize attempt failed); the difference is the rollback is now observable and re-initialization is gated until it settles. - -This reuses the exact coalescing/observability contract the codebase already documents for `cleanup()`; no new field is required beyond reusing `cleanupPromise`. - -### #4 (Medium) — root native-lib README documents stale synchronous Node cleanup - -**Where:** `native-lib/README.md` §4 "Explicit instance lifecycle" (~460, `dw.cleanup()` with no await, no try/finally) and §9 "Cleanup" (~638–644, describes only a `process.on('exit')` hook and shows bare `cleanup()`). - -**Fix:** Update both examples to `await dw.cleanup()` / `await cleanup()` inside `try/finally`, and align the hook/lifecycle prose with the accurate package README (`native-lib/node/README.md`): the async `Promise` return, the `beforeExit` (awaits/drains) + `exit` (sync fallback) hook pair, that signals are not covered, and the last-reference teardown condition. Documentation-only; no code. - -### #5 (Medium) — class-level and package-README cleanup docs overstate teardown completion - -**Where:** `native-lib/node/src/dataweave.ts` `cleanup()` JSDoc (~109–119) and `native-lib/node/README.md:222`. - -**Defect:** Both say cleanup "resolves once the underlying native isolate has actually finished tearing down" unconditionally. That holds only when the call releases the **final** shared native reference; otherwise it resolves after releasing this instance's engine while the isolate stays live for other instances. - -**Fix:** State the final-reference condition explicitly, matching the wording already correct in the module-level `cleanup` doc (README.md:193): resolves after isolate teardown only when releasing the last initialized instance; otherwise resolves as soon as this instance is released. Documentation/JSDoc only. - -### #6 (Low) — native stream rejection of `undefined` is misreported as an empty response - -**Where:** `native-lib/node/src/stream.ts` (~39 `let startError: unknown;`, ~54–57 the two-arg `.then`, ~74 `if (startError !== undefined) throw startError;`). - -**Defect:** `startError !== undefined` is the rejection sentinel. `Promise.reject(undefined)` is valid JS, so a native `start()` that rejects with literal `undefined` is indistinguishable from "never rejected" — the generator swallows it and returns the normal empty-metadata result instead of throwing. Previously triaged as an unreachable non-blocking Minor; flagged again in review #7, so close it properly. - -**Fix:** Replace the value sentinel with a dedicated `let startRejected = false;` boolean, set to `true` in the rejection handler (alongside recording `startError`), and gate the re-throw on `if (startRejected) throw startError;`. This tracks rejection by settlement state, not by the rejected value, so `Promise.reject(undefined)` propagates correctly. The chunk-draining/wake logic is unchanged. - -### #7 (Low) — test cleanup can hide a regression when the test body otherwise passes - -**Where:** `native-lib/node/tests/integration/worker-lifecycle.test.ts` balancing `finally` (~282–291) of the "inits once + creates N engines + exits without cleanup" test. - -**Defect:** The `finally` swallows `destroyEngine()`/`ffi.cleanup()` failures unconditionally (`catch { }`). Suppression is correct only to avoid masking an already-propagating body failure; if the body **succeeded**, a cleanup failure (a real lifecycle regression) is silently discarded and the test still passes. - -**Fix:** Track whether the try body completed successfully (e.g. set `bodySucceeded = true` as the last statement inside `try`, after the final assertion). In the `finally`'s catch, `throw` the cleanup error when `bodySucceeded` is true (surface the regression); only suppress when the body was already failing (a body throw means `bodySucceeded` stayed false and the original error is already propagating). Preserve the round-12/round-6 property that the survival assertions stay inside `try` and best-effort balancing still runs. - -### #8 (Medium, NOT actioned) — PR scope includes Python-binding modernization - -**Decision:** Kept as the standing "leave-as-is, note in PR" decision. No git surgery to split the Python work this round. Answered to the reviewer in the PR: the Python modernization split is acknowledged and deferred to its own follow-up PR; doing the split now would rewrite history mid-review-cycle. This matches the decision recorded in every prior round. - -## Rejected alternatives - -- **Make `initialize()`/`run()` async to await the #3 rollback.** API break — `run()` returns `ExecutionResult`, `initialize()` returns `void`. The pending-state model (reusing `cleanupPromise` + the `"cleaning-up"` guard) gives observability and re-init gating without changing the sync signatures. Same reasoning as the round-5 deadlock fix. -- **#1: detach on every path (including success).** Wrong — on a successful teardown the isolate is destroyed; `fn_detach_thread` against a torn-down isolate is a use-after-free. Detach only on the nonzero-return failure branch, where the isolate is provably still live. -- **#2: block/spin in the rollback path until teardown succeeds.** Reintroduces a potential hang on the init thread. Arming `g_teardown_needed` and letting the existing top-of-`napi_initialize` retry reclaim it is the established, bounded pattern. -- **#6: keep the value sentinel but special-case `undefined`.** Fragile; a dedicated boolean is the direct fix and matches "track rejection with a separate settlement flag" from the review. - -## 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 on the rebased tree: **26 files, 943 passed / 32 skipped / 0 failed** (includes master's rebased-in TCK infrastructure). Doc-only findings (#4, #5) add no tests; #3, #6, #7 each may add/adjust a targeted regression. Target: **0 failures**, with the new/adjusted regressions passing and the full 729-case TCK conformance run still 0-failed. -- Whole-branch final review on the most capable model, tracing the #1/#2 native-lifecycle changes against the teardown state machine and retry-signal invariant. From 0b7714e5ec772abea929c94c91fab70396bcffb8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 11:24:30 -0300 Subject: [PATCH 137/216] docs(specs): unify Node & Python on one handle-based engine model, remove ScriptRuntime singleton Design spec for extending the handle-based multi-engine model to the Python binding using one unified model (shared process-wide isolate + N handle- addressed engines) and eliminating the ScriptRuntime singleton entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-python-multi-engine-unification-design.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md diff --git a/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md b/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md new file mode 100644 index 00000000..65b82942 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md @@ -0,0 +1,281 @@ +# Design: Unify Node & Python on One Handle-Based Engine Model (remove the ScriptRuntime singleton) + +**Date:** 2026-08-26 +**Status:** Approved (brainstorm); pending implementation plan +**Branch:** `w-23692110-multi-engine-design` (extends PR #157 — one unified change, not a follow-up) +**Tracks:** GUS W-23692110 — "Native-lib: support multiple DataWeave engine instances with independent module resolvers" +**Related:** [2026-08-07-native-lib-multi-engine-design.md](./2026-08-07-native-lib-multi-engine-design.md) (the Node multi-engine design this extends), [2026-08-04-nodejs-external-modules-design.md](./2026-08-04-nodejs-external-modules-design.md) + +> **Pre-GA.** `dwlib` is consumed only by this repo's own Node and Python bindings, in lockstep. +> This design intentionally breaks the C ABI and removes the Java singleton; there are no +> compatibility shims. The Python *public* API is preserved. + +## 1. Goal + +Put the Node and Python bindings on a **single, unified engine-isolation model** — one shared +process-wide GraalVM isolate holding N handle-addressed engines, each with its own module +resolver and script cache — and **remove the `ScriptRuntime` singleton entirely** so that all +execution is handle-addressed in both bindings. Maximize shared code by making the Java engine +layer the single source of truth that both bindings drive through the identical C ABI. + +## 2. Background + +PR #157 gave the **Node** binding multiple isolated engines per process using "one shared isolate ++ object-level engine handles" (see the related design). It kept, for backward compatibility, the +`ScriptRuntime` static singleton (`defaultInstance` / `getInstance()`) and three legacy +singleton C entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`), +because the **Python** binding still used them. + +Two facts make unification the right move now: + +1. **The rebase exposed a real collision.** Master shipped a Python module-resolver feature that + calls `run_script_with_resolver` (a 2-arg-callback singleton entrypoint). PR #157's ABI break + removed exactly that entrypoint and changed the resolver callback to a 3-arg (ctx) form. After + rebasing, master's Python tests run against this branch's dwlib and fail + (`run_script_with_resolver not found`) — the current red CI. +2. **The two bindings had diverged in isolation model.** Python historically used **one isolate + per `DataWeave` instance** (isolate-per-instance); Node uses **one shared isolate + engine + handles**. Maintaining two models is undesirable. The maintainer's decision: unify on one + model and reuse as much code as possible. + +## 3. Why the shared-isolate model is the unifying choice + +There are two candidate isolation models: + +- **Isolate-per-engine** (Python's current model): each instance gets its own isolate; separate + heaps; teardown is trivially independent. +- **Shared isolate + engine handles** (Node's model): one process-wide isolate; engines are cheap + Java objects in a registry; the isolate is reference-counted and torn down on last release. + +Unifying on **shared isolate + engine handles** is correct because: + +- **Node cannot cheaply move to isolate-per-engine.** `graal_tear_down_isolate` blocks until all + GraalVM-attached threads reach a safepoint; Node's streaming workers deliver chunks via a + `napi_threadsafe_function` that needs the libuv event loop to run. Tearing an isolate down while + the loop must keep running is the deadlock PR #157 spent ~15 review rounds hardening. Multiplying + that per-isolate is strictly worse, and switching Node off its shipped model discards that work. +- **Python can trivially move to the shared model.** Its ctypes calls are synchronous and it owns + its stream-worker threads directly, so it needs *none* of Node's `PENDING_WAIT`/adoption/retry + machinery — just a reference count and a synchronous drain-before-teardown. +- **The engine logic already lives in a shared layer** (Java `ScriptRuntime` registry + the + `*_engine` C ABI), so both bindings reuse it verbatim. + +**Accepted trade-off:** Python instances in one process now share one isolate's heap instead of +having separate heaps. This is weaker memory isolation, relevant only if mutually-untrusted scripts +run in one process expecting heap-level separation. The maintainer accepted this in exchange for a +single maintained model. + +## 4. The reuse boundary (fixed by the architecture) + +**Shared common core (used identically by both bindings):** +- Java `ScriptRuntime` + the handle registry (`register`/`get`/`destroy`). +- The engine C ABI: `create_engine`, `create_engine_with_resolver`, `run_script_engine`, + `run_script_callback_engine`, `run_script_input_output_callback_engine`, `destroy_engine`. +- The 3-arg `ResolveModuleCallback(thread, ctx, modulePath)` contract. + +**Necessarily binding-specific (cannot share source):** +- Isolate lifecycle (`graal_create_isolate`/`graal_tear_down_isolate`) + reference count, thread + attach/detach, resolver-callback marshalling, stream worker threads. This *must* live in the + binding because the isolate C API is called from *outside* the isolate; Java code runs *inside* + one and cannot create/tear down its own. Node implements this in `addon.c` (N-API/C); Python in + `native.py` (ctypes). They share no source but implement the **same lifecycle contract** — which + is captured in a short shared "engine lifecycle contract" doc. + +Net: one shared Java engine ABI as the source of truth; each binding drives it with thin, +host-appropriate glue. + +## 5. Architecture (layer map) + +### Java — `native-lib/src/main/java/org/mule/weave/lib/` (shared core; mostly subtractive) +- **`ScriptRuntime.java`** — delete `defaultInstance` + `getInstance()`. Keep the registry, the + resolver-bound-at-construction model, and instance execution. `ScriptRuntime` becomes purely + handle-addressed. +- **`NativeLib.java`** — delete the 3 legacy `@CEntryPoint`s (`run_script`, `run_script_callback`, + `run_script_input_output_callback`). Keep only the `*_engine` + `create_engine[_with_resolver]` + + `destroy_engine` set. (The `*_with_resolver` entrypoints removed by PR #157 stay removed.) +- **`NativeCallbacks.java`** — `ResolveModuleCallback` stays the 3-arg ctx form; the old 2-arg form + is gone. Both bindings use the 3-arg form. + +### Node — `native-lib/node/src/addon.c` (near-zero change) +- Delete `dw_napi_run_script`, the `fn_run_script`/`run_script` `dlsym`, and its entry in the + required-symbols guard. The engine API and teardown state machine are otherwise untouched. +- Verify whether any public JS export still surfaces the legacy `run`; if so, remove it as a + documented pre-GA break. + +### Python — `native-lib/python/src/dataweave/` (the bulk of the work) +- **`native.py` (`NativeRuntime`)** — rewrite the glue to the shared model: + - Module-level shared state guarded by one lock: `_isolate` (or None), `_isolate_ref_count`, + the main attached thread. + - Bind the `*_engine` + `create_engine[_with_resolver]` + `destroy_engine` symbols. + - The 3-arg ctx resolver trampoline + a `{handle: (resolver, buffers)}` map. + - The reference-count + drain-before-teardown lifecycle (§6). +- **`runtime.py` (`DataWeave`)** — `initialize()` acquires an isolate ref + creates one engine + (`create_engine` or `create_engine_with_resolver`) and stores its `handle`; run methods route + through the `*_engine` entrypoints with that handle; `cleanup()` drains this instance's stream + workers, `destroy_engine(handle)`, releases the isolate ref. **The public Python API surface is + unchanged.** +- **`models.py`** — `RESOLVE_MODULE_CALLBACK` ctypes signature gains the `ctx` argument. +- **Tests** — migrate off `run_script_with_resolver` to the engine ABI; add multi-instance + isolation + refcount-teardown coverage. + +### New shared artifact +- A short **engine lifecycle contract** doc (the invariant list) that both bindings reference. + +## 6. Python lifecycle & teardown model + +**Shared state (module-level in `native.py`), all mutations under one module lock:** +- `_isolate` (the single process-wide isolate, or None), `_isolate_ref_count`, main attached thread. +- **Invariant:** `_isolate_ref_count` == number of live engines across all `DataWeave` instances, + and the isolate exists iff the count > 0. + +**`initialize()` (per instance):** +1. Under the lock: if `_isolate` is None → `graal_create_isolate()` + attach the main thread once; + then `_isolate_ref_count += 1`. +2. `create_engine()` or `create_engine_with_resolver(ctx=handle, trampoline)` → store `handle` on + the instance. (Handle is allocated by Java; for the resolver case the ctx *is* that handle, so + the map entry is added immediately after the handle is returned — see §7 for the ordering note.) + +Each instance owns exactly one engine handle and contributes exactly one to the isolate refcount. + +**`run` / `run_streaming` / `run_callback` / `run_transform`:** route through the `*_engine` +entrypoints with the instance's `handle`. Stream workers attach their own GraalVM thread, run, +detach on completion (existing pattern). + +**`cleanup()` (per instance):** +1. Drain *this instance's* stream workers — signal cancel + **join** the threads (synchronous; + Python owns the threads, so no event loop and no deadlock). +2. `destroy_engine(handle)`; remove the resolver-map entry; clear the instance handle. +3. Under the lock: `_isolate_ref_count -= 1`; **if it reaches 0** → detach the main thread and + `graal_tear_down_isolate()`, set `_isolate = None`. + +**Why this stays simple:** teardown happens only on the *last* release, by which point every +instance has already joined its own workers in step 1 — so the isolate has no attached worker +threads when `graal_tear_down_isolate` runs. Hence **none** of Node's `PENDING_WAIT`/adoption/retry +machinery is needed. + +**Idempotency / safety:** `cleanup()` on an uninitialized or already-cleaned instance is a no-op; +double-`cleanup()` releases the ref only once (guarded by the instance's handle being cleared). + +**Concurrency:** keep today's **per-instance serialization** of native execution calls +(`_serialized_native_operation`), but allow **different instances to run concurrently** — each on +its own attached thread in the shared isolate (GraalVM supports multiple attached threads). The +module lock guards only isolate refcount/create/teardown; it is **not** held during script +execution, so one engine's long-running script never blocks another engine's `initialize()`/`run()`. + +## 7. Resolver dispatch & the streaming/resolver hazard + +**Per-engine resolver dispatch (ctx mechanism):** +- `create_engine_with_resolver` passes `ctx = handle` (the engine handle). +- Python registers **one** C trampoline (`RESOLVE_MODULE_CALLBACK`). GraalVM calls it with + `(thread, ctx, module_path)`; it looks up `ctx` in `{handle: (resolver, buffers)}`, invokes that + engine's Python resolver, and returns the source-buffer pointer. +- This is the Python analog of Node's per-handle bridge — same ctx concept, so the Java/ABI side is + identical. Multiple Python engines with different resolvers dispatch correctly within the shared + isolate. + +**Ordering note:** the ctx passed to `create_engine_with_resolver` is the handle it returns, so +either (a) allocate the handle first and pass it as ctx, or (b) register the trampoline against a +provisional key and re-key once the handle is known. The plan will pick the concrete mechanism; the +requirement is that no resolve callback can fire for a handle before its map entry exists (resolves +only occur during a `run` on that engine, which happens strictly after `create_engine_with_resolver` +returns, so this is naturally safe). + +**Streaming / transform + custom modules — parity with Node (out of scope):** +Resolving a *custom* module reached from a background stream-worker thread is the pre-existing +documented hazard. Python adopts the **same owner-thread guard** as Node: the trampoline resolves +only when invoked on the engine's owner thread and fails closed ("not found") on a background stream +thread — identical behavior across bindings. Built-in modules resolve normally everywhere; +synchronous `run()` with a resolver resolves custom modules fully in both bindings. + +This is a conservative parity choice, not a hard Python limitation: because Python callbacks hold +the GIL, Python could potentially support streaming custom-module resolution later as a +Python-specific enhancement. Out of scope here to preserve one unified behavior. + +## 8. Data flow + +``` +dwA = DataWeave(resolve_module=A); dwA.initialize() + → lock: _isolate None → graal_create_isolate() + attach main thread; ref 0→1 + → create_engine_with_resolver(ctx=handleA, trampoline); map[handleA] = (A, buffers); dwA._handle = handleA + +dwB = DataWeave(resolve_module=B); dwB.initialize() + → lock: _isolate exists → reuse; ref 1→2 + → create_engine_with_resolver(ctx=handleB, trampoline); map[handleB] = (B, buffers) + +dwA.run("... import custom/lib ...") + → run_script_engine(handleA, script, inputs) [own attached thread] + → Java engine A: ClassLoader miss → callback(thread, ctx=handleA, "custom/lib") + → trampoline: map[handleA] → resolver A → source; A's cache used, B untouched + +dwB.run(...) → resolves via B only; independent cache; no cross-talk + +dwA.cleanup() → join dwA workers; destroy_engine(handleA); del map[handleA]; ref 2→1 (isolate stays) +dwB.cleanup() → join workers; destroy_engine(handleB); ref 1→0 → detach main + graal_tear_down_isolate(); _isolate=None +``` + +## 9. Error handling + +- **Isolate create fails** → `DataWeaveError`; refcount not incremented; `_isolate` stays None. +- **`create_engine` fails after isolate create** → release the isolate ref (tearing down if this + call created it), then raise — a failed init leaks nothing. +- **Unknown/destroyed handle** → Java `get(handle)` is null → entrypoint returns + `{"success":false,"error":"Unknown engine handle"}`; Python surfaces an unsuccessful + `ExecutionResult`/`DataWeaveError`, never a crash. +- **Resolver raises / returns non-str** → trampoline returns None → standard "unable to resolve + module" (unchanged Python behavior). +- **`run`/stream after `cleanup()`** → instance guard raises `DataWeaveError` (handle already + cleared); the C layer never sees a stale handle. +- **`destroy_engine` throws during `cleanup()`** → still release the isolate ref (so a throwing + destroy cannot strand the isolate), then re-raise — mirrors Node's `doCleanup()`. +- **`graal_tear_down_isolate` returns nonzero** → raise `DataWeaveError`, but leave `_isolate` set + with count 0; the next `initialize()` reuses that live isolate (count 0→1). No retry flag needed + — there is no event loop to defer to. + +## 10. Backward compatibility (all intended, pre-GA, no shims) + +- **dwlib C ABI:** removes `run_script` / `run_script_callback` / `run_script_input_output_callback`; + keeps only the `*_engine` + `create_engine[_with_resolver]` + `destroy_engine` set; + `ResolveModuleCallback` is 3-arg only. Consumed by this repo's own bindings in lockstep. +- **Java:** `getInstance()`/`defaultInstance` removed — `ScriptRuntime` is purely handle-addressed. +- **Node:** removes the legacy `dw_napi_run_script` path (and any JS export surfacing it). +- **Python:** **public API unchanged** — `DataWeave(...)`, `initialize`, `run`, `run_streaming`, + `run_callback`, `run_transform`, `cleanup`, and the module-level functions keep signatures and + behavior. Only `native.py`'s internal ABI changes. +- **Docs:** update the 2026-08-07 consolidated design's Python notes: singleton removed; all + execution handle-addressed; both bindings on one engine ABI. + +## 11. Testing strategy + +- **Java unit** — two `ScriptRuntime` instances with different in-memory resolvers each resolve only + their own module; `destroy(handle)` removes one. Delete/adjust tests referencing `getInstance()`. +- **Python unit** (fake/mocked lib, no dwlib) — migrate `test_native.py` off `run_script_with_resolver` + to the engine ABI; cover refcount create/reuse/last-release-teardown, ctx→resolver trampoline + dispatch (two handles → two resolvers), `cleanup()` idempotency + double-cleanup, and the + `create_engine`-failure rollback releasing the isolate ref. +- **Python integration** (real dwlib) — the core W-23692110 regression (two instances, different + resolvers, one process, no cross-talk); multi-instance teardown via a refcount proxy (after all + instances clean up, a fresh raw engine call fails "not initialized"); synchronous `run()` with a + resolver resolves custom modules; streaming/transform still stream; streaming custom-module + resolution fails closed (parity); **TCK conformance stays green**. +- **Node** — existing suite stays green; remove the `dw_napi_run_script` test with its entrypoint. +- **Build** — `native-lib:nativeCompile` green with the 3 legacy `@CEntryPoint`s removed (confirm no + SPI/reflection config references them). +- **CI** — the currently-red Python module-resolver tests pass, because Python now calls + `create_engine_with_resolver` instead of the removed `run_script_with_resolver`. + +## 12. Follow-Up Work + +- Optional Python-specific enhancement: support custom-module resolution during streaming/transform + (feasible under the GIL; deliberately out of scope here for cross-binding parity). +- Fold the two multi-engine design docs' shared concepts into a single reference if they drift. + +## References + +| Item | Location | +|------|----------| +| GUS ticket | W-23692110 | +| Node multi-engine design (extended here) | `docs/superpowers/specs/2026-08-07-native-lib-multi-engine-design.md` | +| Java engine registry / entrypoints | `native-lib/src/main/java/org/mule/weave/lib/{ScriptRuntime,NativeLib,NativeCallbacks}.java` | +| Node addon (legacy path to remove) | `native-lib/node/src/addon.c` (`dw_napi_run_script`) | +| Python glue to rewrite | `native-lib/python/src/dataweave/{native,runtime,models}.py` | +| Current Python isolate-per-instance model | `native-lib/python/src/dataweave/native.py` (`graal_create_isolate` in `NativeRuntime.initialize`) | From 1f96418ff99c4555d8367aa595a16604c68fa7b4 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 15:34:45 -0300 Subject: [PATCH 138/216] refactor(native-lib): remove ScriptRuntime singleton and legacy C entrypoints (W-23692110) Co-Authored-By: Claude Sonnet 5 --- .../java/org/mule/weave/lib/NativeLib.java | 114 +----------------- .../org/mule/weave/lib/ScriptRuntime.java | 22 ---- .../org/mule/weave/lib/ScriptRuntimeTest.java | 32 ++--- 3 files changed, 21 insertions(+), 147 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 f635ccf0..cb94e4c4 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 @@ -31,26 +31,7 @@ public class NativeLib { 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. - * - * @param thread the isolate thread (automatically provided by GraalVM) - * @param script the DataWeave script to execute (C string pointer) - * @param inputsJson JSON string containing the inputs map with content (base64 encoded), mimeType, properties and charset for each binding - * @return the script execution result base64 encoded (C string pointer) - */ - @CEntryPoint(name = "run_script") - public static CCharPointer runDwScriptEncoded(IsolateThread thread, CCharPointer script, CCharPointer inputsJson) { - String dwScript = CTypeConversion.toJavaString(script); - String inputs = CTypeConversion.toJavaString(inputsJson); - - ScriptRuntime runtime = ScriptRuntime.getInstance(); - String result = runtime.run(dwScript, inputs); - return toUnmanagedCString(result); - } - - /** - * Frees a C string previously returned by {@link #runDwScriptEncoded(IsolateThread, CCharPointer, CCharPointer)}. + * Frees a C string previously returned by engine entrypoints. * * @param thread the isolate thread (automatically provided by GraalVM) * @param pointer the pointer to the unmanaged C string to free; if null, this is a no-op @@ -68,44 +49,7 @@ public static void freeCString(IsolateThread thread, CCharPointer pointer) { private static final int CALLBACK_BUFFER_SIZE = 8 * 1024; /** - * Executes a DataWeave script and streams the result to a caller-supplied write callback. - * - *

Instead of the session-based open/read/close cycle, the caller passes a - * {@code WriteCallback} function pointer. The Java side reads the output stream in chunks - * and invokes the callback for each chunk until the stream is exhausted.

- * - *

The returned C string is a JSON object with the execution metadata: - *

    - *
  • On success: {@code {"success":true,"mimeType":"...","charset":"...","binary":true/false}}
  • - *
  • On error: {@code {"success":false,"error":"..."}}
  • - *
- * The caller must free the returned pointer with {@link #freeCString}.

- * - * @param thread the isolate thread - * @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") - public static CCharPointer runScriptCallback( - IsolateThread thread, - CCharPointer script, - CCharPointer inputsJson, - NativeCallbacks.WriteCallback writeCallback, - PointerBase ctx) { - - String dwScript = CTypeConversion.toJavaString(script); - 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 + * Runs the streaming write-callback loop shared by the per-engine entrypoint * ({@link #runScriptCallbackEngine}). */ private static CCharPointer streamToWriteCallback( @@ -151,55 +95,7 @@ private static CCharPointer streamToWriteCallback( } /** - * Executes a DataWeave script whose output is streamed via a write callback, and whose - * input named {@code inputName} is fed via a read callback. - * - *

The read callback is invoked on a background thread to pull input data while the - * output is pushed to the write callback on the calling thread. This allows fully - * callback-driven input and output streaming in a single call.

- * - *

The returned C string follows the same JSON schema as - * {@link #runScriptCallback}.

- * - * @param thread the isolate thread - * @param script the DataWeave script (C string) - * @param inputsJson JSON-encoded inputs map (C string), may be null; entries for - * {@code inputName} are ignored since the read callback supplies that input - * @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 for UTF-8 - * @param readCallback function pointer invoked to read the next chunk; must return bytes written, - * 0 on EOF, or -1 on error - * @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") - public static CCharPointer runScriptInputOutputCallback( - IsolateThread thread, - CCharPointer script, - CCharPointer inputsJson, - CCharPointer inputName, - CCharPointer inputMimeType, - CCharPointer inputCharset, - NativeCallbacks.ReadCallback readCallback, - NativeCallbacks.WriteCallback writeCallback, - PointerBase ctx) { - - 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); - - 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 + * Runs the input-feeder + output-streaming loop shared by the per-engine entrypoint * ({@link #runScriptInputOutputCallbackEngine}). */ private static CCharPointer transformViaCallbacks( @@ -437,7 +333,7 @@ public static CCharPointer runScriptEngine( /** * Executes a DataWeave script against a specific engine, streaming the result to a - * caller-supplied write callback. See {@link #runScriptCallback} for the callback contract. + * caller-supplied write callback. See {@link #streamToWriteCallback} for the callback contract. * *

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

@@ -465,7 +361,7 @@ public static CCharPointer runScriptCallbackEngine( /** * Executes a DataWeave script against a specific engine, with a callback-supplied input - * and callback-streamed output. See {@link #runScriptInputOutputCallback} for the callback + * and callback-streamed output. See {@link #transformViaCallbacks} for the callback * contract. * *

If {@code handle} does not identify a live engine, returns 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 d8db13ba..977dcc29 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 @@ -58,28 +58,6 @@ public static boolean destroy(long handle) { return REGISTRY.remove(handle) != null; } - // ── Legacy singleton (ClassLoader-only) for Python entrypoints ──────── - private static volatile ScriptRuntime defaultInstance = null; - - /** - * Returns the process-wide legacy singleton instance (ClassLoader-only resolver). - * - * @return the shared {@link ScriptRuntime} - */ - public static ScriptRuntime getInstance() { - 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; 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 bf35264a..5b9ac2dd 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 @@ -19,7 +19,7 @@ class ScriptRuntimeTest { @Test void runSimpleScript() { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Running sqrt(144) 10 times with timing:"); System.out.println("=".repeat(50)); @@ -39,7 +39,7 @@ void runSimpleScript() { @Test void runParseError() { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Running sqrt(144) 10 times with timing:"); System.out.println("=".repeat(50)); @@ -55,7 +55,7 @@ void runParseError() { @Test void runWithInputs() { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing runWithInputs with two integer numbers:"); System.out.println("=".repeat(50)); @@ -129,7 +129,7 @@ private String encode(Object value) { @Test void runWithXmlInput() { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing runWithInputs with XML input to calculate average age:"); System.out.println("=".repeat(50)); @@ -181,7 +181,7 @@ void runWithXmlInput() { @Test void runWithJsonObjectInput() { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing runWithInputs with JSON object input:"); System.out.println("=".repeat(50)); @@ -216,7 +216,7 @@ void runWithJsonObjectInput() { @Test void runWithBinaryResult() { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Running fromBase64 10 times with timing:"); System.out.println("=".repeat(50)); @@ -239,7 +239,7 @@ void runWithBinaryResult() { @Test void runWithInputProperties() { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); String encodedIn0 = Base64.getEncoder().encodeToString("1234567".getBytes()); Result result = Result.parse(runtime.run("in0.column_1[0] as Number", "{\"in0\": " + @@ -252,7 +252,7 @@ void runWithInputProperties() { @Test void streamSimpleScript() throws IOException { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing streaming simple script:"); System.out.println("=".repeat(50)); @@ -279,7 +279,7 @@ void streamSimpleScript() throws IOException { @Test void streamWithInputs() throws IOException { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing streaming with inputs:"); System.out.println("=".repeat(50)); @@ -310,7 +310,7 @@ void streamWithInputs() throws IOException { @Test void streamChunkedRead() throws IOException { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing streaming chunked read:"); System.out.println("=".repeat(50)); @@ -341,7 +341,7 @@ void streamChunkedRead() throws IOException { @Test void streamWithStreamingInput() throws Exception { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing streaming with streaming input:"); System.out.println("=".repeat(50)); @@ -396,7 +396,7 @@ void streamWithStreamingInput() throws Exception { @Test void streamWithLargeStreamingInput() throws Exception { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing streaming with large streaming input:"); System.out.println("=".repeat(50)); @@ -455,7 +455,7 @@ void streamWithLargeStreamingInput() throws Exception { @Test void streamErrorSession() { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing streaming error session:"); System.out.println("=".repeat(50)); @@ -474,7 +474,7 @@ void streamErrorSession() { @Test void callbackOutputStreaming() throws IOException { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing callback-based output streaming:"); System.out.println("=".repeat(50)); @@ -505,7 +505,7 @@ void callbackOutputStreaming() throws IOException { @Test void callbackInputOutputStreaming() throws Exception { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing callback-based input+output streaming:"); System.out.println("=".repeat(50)); @@ -566,7 +566,7 @@ void callbackInputOutputStreaming() throws Exception { @Test void callbackOutputStreamingError() { - ScriptRuntime runtime = ScriptRuntime.getInstance(); + ScriptRuntime runtime = new ScriptRuntime(null); System.out.println("Testing callback-based output streaming with error:"); System.out.println("=".repeat(50)); From ffc3231ad3ec59458be28a9437e65e27a35f56b8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 15:48:12 -0300 Subject: [PATCH 139/216] feat(python): add ctx argument to RESOLVE_MODULE_CALLBACK for per-engine dispatch (W-23692110) Co-Authored-By: Claude Sonnet 5 --- native-lib/python/src/dataweave/models.py | 3 ++- native-lib/python/tests/unit/test_models.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/native-lib/python/src/dataweave/models.py b/native-lib/python/src/dataweave/models.py index d5e90c9a..a44d1af3 100644 --- a/native-lib/python/src/dataweave/models.py +++ b/native-lib/python/src/dataweave/models.py @@ -30,8 +30,9 @@ class DataWeaveLibraryNotFoundError(Exception): WRITE_CALLBACK = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int) # int (*ReadCallback)(void *ctx, char *buffer, int bufferSize) READ_CALLBACK = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int) -# char *resolve_module(void *isolate_thread, const char *module_path) +# char *resolve_module(void *isolate_thread, void *ctx, const char *module_path) RESOLVE_MODULE_CALLBACK = ctypes.CFUNCTYPE( + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p, diff --git a/native-lib/python/tests/unit/test_models.py b/native-lib/python/tests/unit/test_models.py index b337896d..eea1e8ae 100644 --- a/native-lib/python/tests/unit/test_models.py +++ b/native-lib/python/tests/unit/test_models.py @@ -1,5 +1,6 @@ import base64 +import ctypes import pytest import dataweave @@ -18,6 +19,17 @@ def test_public_models_are_exported_from_models_module(): assert models.WRITE_CALLBACK is dataweave.WRITE_CALLBACK +@pytest.mark.unit +def test_resolve_module_callback_has_ctx_argument(): + # thread, ctx, module_path + assert models.RESOLVE_MODULE_CALLBACK._argtypes_ == ( + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_char_p, + ) + assert models.RESOLVE_MODULE_CALLBACK._restype_ is ctypes.c_void_p + + @pytest.mark.unit def test_input_value_encodes_text_with_its_charset(): value = dataweave.InputValue("caf\u00e9", charset="latin-1") From 2832feaef463462301d57cc9ce203f847437763e Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 16:16:16 -0300 Subject: [PATCH 140/216] feat(python): shared refcounted isolate with handle-addressed engines (W-23692110) Co-Authored-By: Claude Sonnet 5 --- native-lib/python/src/dataweave/native.py | 348 ++++---- native-lib/python/tests/unit/conftest.py | 24 + native-lib/python/tests/unit/test_native.py | 848 +++----------------- 3 files changed, 315 insertions(+), 905 deletions(-) create mode 100644 native-lib/python/tests/unit/conftest.py diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index d9d010d8..5f43207a 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -26,6 +26,129 @@ class graal_isolatethread_t(ctypes.Structure): GraalIsolateThreadPointer = ctypes.POINTER(graal_isolatethread_t) +# ── Process-wide shared isolate (one per process, N handle-addressed engines) ── +# All mutations happen under _isolate_lock. Invariant: _isolate_ref_count equals +# the number of live engines across all DataWeave instances, and _isolate is not +# None iff the count > 0. +_isolate_lock = Lock() +_lib = None +_lib_path = None +_isolate = None +_isolate_thread = None # the main attached IsolateThread (GraalIsolateThreadPointer) +_isolate_owner_thread = None # the Python threading.Thread that created the isolate +_isolate_ref_count = 0 + + +def _bind_abi(lib) -> None: + """Binds argtypes/restypes for the engine ABI and lifecycle exports (once).""" + for name in ("graal_create_isolate", "graal_attach_thread", "graal_detach_thread", + "graal_tear_down_isolate", "free_cstring", + "create_engine", "create_engine_with_resolver", "destroy_engine", + "run_script_engine", "run_script_callback_engine", + "run_script_input_output_callback_engine"): + if not hasattr(lib, name): + raise DataWeaveError(f"Native library does not export {name}") + + lib.graal_create_isolate.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(GraalIsolatePointer), + ctypes.POINTER(GraalIsolateThreadPointer), + ] + lib.graal_create_isolate.restype = ctypes.c_int + lib.graal_attach_thread.argtypes = [GraalIsolatePointer, ctypes.POINTER(GraalIsolateThreadPointer)] + lib.graal_attach_thread.restype = ctypes.c_int + lib.graal_detach_thread.argtypes = [GraalIsolateThreadPointer] + lib.graal_detach_thread.restype = ctypes.c_int + lib.graal_tear_down_isolate.argtypes = [GraalIsolateThreadPointer] + lib.graal_tear_down_isolate.restype = ctypes.c_int + lib.free_cstring.argtypes = [GraalIsolateThreadPointer, ctypes.c_void_p] + lib.free_cstring.restype = None + + lib.create_engine.argtypes = [GraalIsolateThreadPointer] + lib.create_engine.restype = ctypes.c_int64 + lib.create_engine_with_resolver.argtypes = [ + GraalIsolateThreadPointer, RESOLVE_MODULE_CALLBACK, ctypes.c_void_p, + ] + lib.create_engine_with_resolver.restype = ctypes.c_int64 + lib.destroy_engine.argtypes = [GraalIsolateThreadPointer, ctypes.c_int64] + lib.destroy_engine.restype = None + lib.run_script_engine.argtypes = [ + GraalIsolateThreadPointer, ctypes.c_int64, ctypes.c_char_p, ctypes.c_char_p, + ] + lib.run_script_engine.restype = ctypes.c_void_p + lib.run_script_callback_engine.argtypes = [ + GraalIsolateThreadPointer, ctypes.c_int64, ctypes.c_char_p, ctypes.c_char_p, + WRITE_CALLBACK, ctypes.c_void_p, + ] + lib.run_script_callback_engine.restype = ctypes.c_void_p + lib.run_script_input_output_callback_engine.argtypes = [ + GraalIsolateThreadPointer, ctypes.c_int64, ctypes.c_char_p, ctypes.c_char_p, + ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, + READ_CALLBACK, WRITE_CALLBACK, ctypes.c_void_p, + ] + lib.run_script_input_output_callback_engine.restype = ctypes.c_void_p + + +def _acquire_isolate(lib_path: str): + """Returns (lib, isolate, thread, owner_thread), creating the shared isolate on + the first reference. Increments the refcount only on success.""" + global _lib, _lib_path, _isolate, _isolate_thread, _isolate_owner_thread, _isolate_ref_count + with _isolate_lock: + if _isolate is None: + try: + lib = ctypes.CDLL(lib_path) + except OSError as error: + raise DataWeaveError(f"Failed to load library from {lib_path}: {error}") + _bind_abi(lib) + isolate = GraalIsolatePointer() + thread = GraalIsolateThreadPointer() + try: + result = lib.graal_create_isolate(None, ctypes.byref(isolate), ctypes.byref(thread)) + except Exception as error: + raise DataWeaveError(f"Failed to create GraalVM isolate: {error}") from error + if result != 0: + raise DataWeaveError(f"Failed to create GraalVM isolate. Error code: {result}") + _lib = lib + _lib_path = lib_path + _isolate = isolate + _isolate_thread = thread + _isolate_owner_thread = current_thread() + _isolate_ref_count += 1 + return _lib, _isolate, _isolate_thread, _isolate_owner_thread + + +def _release_isolate() -> None: + """Decrements the refcount; tears the isolate down and nulls globals on 0.""" + global _lib, _lib_path, _isolate, _isolate_thread, _isolate_owner_thread, _isolate_ref_count + with _isolate_lock: + if _isolate_ref_count == 0: + return + _isolate_ref_count -= 1 + if _isolate_ref_count > 0: + return + # Last release: tear down from the owner thread if we are on it, else a + # fresh attached thread. Then clear globals regardless. + lib, isolate, main_thread, owner = _lib, _isolate, _isolate_thread, _isolate_owner_thread + try: + if current_thread() is owner: + _tear_down(lib, main_thread) + else: + worker = GraalIsolateThreadPointer() + if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: + raise DataWeaveError("Failed to attach thread for isolate teardown") + _tear_down(lib, worker) + finally: + _lib = _lib_path = _isolate = _isolate_thread = _isolate_owner_thread = None + + +def _tear_down(lib, thread) -> None: + if thread is None: + return + result = lib.graal_tear_down_isolate(thread) + if result != 0: + raise DataWeaveError(f"Failed to tear down GraalVM isolate. Error code: {result}") + + def candidate_library_paths() -> list[Path]: paths: list[Path] = [] env_value = (os.environ.get(_ENV_NATIVE_LIB) or "").strip() @@ -64,102 +187,45 @@ def __init__(self, lib_path: Optional[str] = None): self.lib = None self.isolate = None self.thread = None + self._owner_thread = None + self.handle = 0 self.initialized = False - self.has_callback_streaming = False - self.has_callback_input_output = False - self.has_module_resolver = False - self._module_resolver = None - self._module_resolver_callback = None + # Every engine supports every API now (single unified ABI). + self.has_callback_streaming = True + self.has_callback_input_output = True + self.has_module_resolver = True + self._resolver = None + self._resolver_callback = None + self._resolver_token = 0 self._resolver_buffers = [] self._resolver_active = False + self._resolver_active_ident = None self._resolver_lock = Lock() self._execution_owner = None - self._owner_thread = None def initialize(self) -> None: if self.initialized: return + self.lib, self.isolate, self.thread, self._owner_thread = _acquire_isolate(self.lib_path) try: - self.lib = ctypes.CDLL(self.lib_path) - except OSError as error: - raise DataWeaveError(f"Failed to load library from {self.lib_path}: {error}") - isolate_created = False - try: - self._create_isolate() - isolate_created = True - self._owner_thread = current_thread() - self._setup_functions() - self.initialized = True + self.handle = self._create_engine() except Exception: - if isolate_created: - self._tear_down_isolate(suppress_errors=True) - self._reset() + # Roll back the ref we just took so a failed init leaks nothing. + self.lib = self.isolate = self.thread = self._owner_thread = None + _release_isolate() raise + self.initialized = True - def _create_isolate(self) -> None: - self._require_export("graal_create_isolate") - self.lib.graal_create_isolate.argtypes = [ - ctypes.c_void_p, - ctypes.POINTER(GraalIsolatePointer), - ctypes.POINTER(GraalIsolateThreadPointer), - ] - self.lib.graal_create_isolate.restype = ctypes.c_int - self.isolate = GraalIsolatePointer() - self.thread = GraalIsolateThreadPointer() + def _create_engine(self) -> int: + # Engines without a resolver are created here; the resolver variant is + # installed by install_resolver() (Task 4) before this is called. try: - result = self.lib.graal_create_isolate(None, ctypes.byref(self.isolate), ctypes.byref(self.thread)) + handle = self.lib.create_engine(self.thread) except Exception as error: - raise DataWeaveError(f"Failed to create GraalVM isolate: {error}") from error - if result != 0: - raise DataWeaveError(f"Failed to create GraalVM isolate. Error code: {result}") - - def _setup_functions(self) -> None: - self._require_export("run_script") - self._require_export("free_cstring") - self._require_export("graal_tear_down_isolate") - self.lib.run_script.argtypes = [GraalIsolateThreadPointer, ctypes.c_char_p, ctypes.c_char_p] - self.lib.run_script.restype = ctypes.c_void_p - self.lib.free_cstring.argtypes = [GraalIsolateThreadPointer, ctypes.c_void_p] - self.lib.free_cstring.restype = None - self.lib.graal_tear_down_isolate.argtypes = [GraalIsolateThreadPointer] - self.lib.graal_tear_down_isolate.restype = ctypes.c_int - self._setup_thread_lifecycle_functions() - if hasattr(self.lib, "run_script_with_resolver"): - self.lib.run_script_with_resolver.argtypes = [ - GraalIsolateThreadPointer, - ctypes.c_char_p, - ctypes.c_char_p, - RESOLVE_MODULE_CALLBACK, - ] - self.lib.run_script_with_resolver.restype = ctypes.c_void_p - self.has_module_resolver = True - if hasattr(self.lib, "run_script_callback"): - self._require_streaming_lifecycle_exports("run_script_callback") - self.lib.run_script_callback.argtypes = [GraalIsolateThreadPointer, ctypes.c_char_p, ctypes.c_char_p, WRITE_CALLBACK, ctypes.c_void_p] - self.lib.run_script_callback.restype = ctypes.c_void_p - self.has_callback_streaming = True - if hasattr(self.lib, "run_script_input_output_callback"): - self._require_streaming_lifecycle_exports("run_script_input_output_callback") - self.lib.run_script_input_output_callback.argtypes = [GraalIsolateThreadPointer, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, READ_CALLBACK, WRITE_CALLBACK, ctypes.c_void_p] - self.lib.run_script_input_output_callback.restype = ctypes.c_void_p - self.has_callback_input_output = True - - def _require_export(self, name: str) -> None: - if not hasattr(self.lib, name): - raise DataWeaveError(f"Native library does not export {name}") - - def _require_streaming_lifecycle_exports(self, callback_name: str) -> None: - for name in ("free_cstring", "graal_attach_thread", "graal_detach_thread"): - if not hasattr(self.lib, name): - raise DataWeaveError(f"{callback_name} requires native export {name}") - - def _setup_thread_lifecycle_functions(self) -> None: - self._require_export("graal_attach_thread") - self._require_export("graal_detach_thread") - self.lib.graal_attach_thread.argtypes = [GraalIsolatePointer, ctypes.POINTER(GraalIsolateThreadPointer)] - self.lib.graal_attach_thread.restype = ctypes.c_int - self.lib.graal_detach_thread.argtypes = [GraalIsolateThreadPointer] - self.lib.graal_detach_thread.restype = ctypes.c_int + raise DataWeaveError(f"Failed to create DataWeave engine: {error}") from error + if not handle: + raise DataWeaveError("Native create_engine returned a null handle") + return handle def attach_thread(self): worker_thread = GraalIsolateThreadPointer() @@ -196,18 +262,43 @@ def decode_and_free(self, ptr, thread=None) -> str: if primary_error is None: raise - def run_script(self, thread, script: bytes, inputs: bytes): + def run_engine_and_decode(self, script: bytes, inputs: bytes) -> str: + with self._serialized_native_operation(): + with self._current_thread_attachment(self.thread) as thread: + with self._resolver_scope(): + return self.decode_and_free( + self.lib.run_script_engine(thread, self.handle, script, inputs), + thread, + ) + + def run_callback_engine_and_decode(self, thread, script: bytes, inputs: bytes, write_callback) -> str: with self._serialized_native_operation(): - return self.lib.run_script(thread, script, inputs) + with self._current_thread_attachment(thread) as current: + return self.decode_and_free( + self.lib.run_script_callback_engine( + current, self.handle, script, inputs, write_callback, None + ), + current, + ) - def run_script_and_decode(self, thread, script: bytes, inputs: bytes) -> str: + def run_input_output_callback_engine_and_decode( + self, thread, script: bytes, inputs: bytes, input_name: bytes, + input_mime_type: bytes, input_charset: Optional[bytes], read_callback, write_callback, + ) -> str: with self._serialized_native_operation(): - with self._current_thread_attachment(thread) as current_thread: + with self._current_thread_attachment(thread) as current: return self.decode_and_free( - self.lib.run_script(current_thread, script, inputs), - current_thread, + self.lib.run_script_input_output_callback_engine( + current, self.handle, script, inputs, input_name, + input_mime_type, input_charset, read_callback, write_callback, None, + ), + current, ) + @contextmanager + def _resolver_scope(self): + yield + def run_script_with_resolver(self, thread, script: bytes, inputs: bytes, resolver: ModuleResolver): with self._serialized_native_operation(): return self._run_script_with_resolver(thread, script, inputs, resolver) @@ -268,53 +359,24 @@ def resolve(_thread, module_path): return RESOLVE_MODULE_CALLBACK(resolve) - def run_script_callback(self, thread, script: bytes, inputs: bytes, write_callback): - with self._serialized_native_operation(): - return self.lib.run_script_callback(thread, script, inputs, write_callback, None) - - def run_script_callback_and_decode(self, thread, script: bytes, inputs: bytes, write_callback) -> str: - with self._serialized_native_operation(): - with self._current_thread_attachment(thread) as current_thread: - return self.decode_and_free( - self.lib.run_script_callback(current_thread, script, inputs, write_callback, None), - current_thread, - ) - - def run_script_input_output_callback(self, thread, script: bytes, inputs: bytes, input_name: bytes, input_mime_type: bytes, input_charset: Optional[bytes], read_callback, write_callback): - with self._serialized_native_operation(): - return self.lib.run_script_input_output_callback( - thread, script, inputs, input_name, input_mime_type, input_charset, read_callback, write_callback, None, - ) - - def run_script_input_output_callback_and_decode(self, thread, script: bytes, inputs: bytes, input_name: bytes, input_mime_type: bytes, input_charset: Optional[bytes], read_callback, write_callback) -> str: - with self._serialized_native_operation(): - with self._current_thread_attachment(thread) as current_thread: - return self.decode_and_free( - self.lib.run_script_input_output_callback( - current_thread, script, inputs, input_name, input_mime_type, input_charset, read_callback, write_callback, None, - ), - current_thread, - ) - def cleanup(self) -> None: with self._serialized_native_operation(): if not self.initialized: return - if current_thread() is getattr(self, "_owner_thread", current_thread()): - self._tear_down_isolate() - self._reset() - return - - attached_thread = self.attach_thread() + self.initialized = False try: - self._tear_down_isolate(attached_thread) - except Exception: - try: - self.detach_thread(attached_thread) - except Exception: - pass - raise - self._reset() + if self.handle: + self.lib.destroy_engine(self.thread, self.handle) + finally: + # Release the isolate ref even if destroy_engine throws, so a + # throwing destroy cannot strand the isolate. + self.lib = self.isolate = self.thread = self._owner_thread = None + self._resolver = None + self._resolver_callback = None + self._resolver_buffers = [] + self._resolver_active = False + self._resolver_active_ident = None + _release_isolate() @contextmanager def _serialized_native_operation(self): @@ -351,31 +413,3 @@ def _current_thread_attachment(self, thread): if primary_error is None: raise - def _tear_down_isolate(self, thread=None, suppress_errors: bool = False) -> None: - isolate_thread = thread or self.thread - if isolate_thread is None: - return - try: - result = self.lib.graal_tear_down_isolate(isolate_thread) - if result != 0: - raise DataWeaveError(f"Failed to tear down GraalVM isolate. Error code: {result}") - except DataWeaveError: - if not suppress_errors: - raise - except Exception as error: - if not suppress_errors: - raise DataWeaveError(f"Failed to tear down GraalVM isolate: {error}") from error - - def _reset(self) -> None: - self.initialized = False - self._owner_thread = None - self.thread = None - self.isolate = None - self.lib = None - self.has_callback_streaming = False - self.has_callback_input_output = False - self.has_module_resolver = False - self._module_resolver = None - self._module_resolver_callback = None - self._resolver_buffers = [] - self._resolver_active = False diff --git a/native-lib/python/tests/unit/conftest.py b/native-lib/python/tests/unit/conftest.py new file mode 100644 index 00000000..da271586 --- /dev/null +++ b/native-lib/python/tests/unit/conftest.py @@ -0,0 +1,24 @@ +import pytest + +from dataweave import native + + +@pytest.fixture(autouse=True) +def _reset_shared_isolate(): + """Every unit test starts and ends with no shared isolate held. + + The isolate/lib/refcount now live at module scope in dataweave.native, so a + test that leaves a ref behind would leak into the next test. Tests fake the + library, so tearing down here is just clearing globals -- no real native call. + """ + _clear() + yield + _clear() + + +def _clear(): + native._lib = None + native._isolate = None + native._isolate_thread = None + native._isolate_owner_thread = None + native._isolate_ref_count = 0 diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 78c0f80d..174ed2f3 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -1,6 +1,6 @@ from pathlib import Path import ctypes -from threading import current_thread, Event, get_ident, Thread +from threading import current_thread, get_ident, Thread import pytest @@ -24,10 +24,13 @@ class FakeLibrary: run_script = Function() free_cstring = Function() - def __init__(self, *, resolver_export=False): + def __init__(self, *, resolver_export=True): self.attach_calls = [] self.detach_calls = [] self.tear_down_threads = [] + self.created_engines = [] + self.destroyed_engines = [] + self._next_handle = 1 self.graal_create_isolate = CallableFunction(lambda _params, _isolate, _thread: 0) self.graal_attach_thread = CallableFunction(self._attach_thread) self.graal_detach_thread = CallableFunction( @@ -36,8 +39,27 @@ def __init__(self, *, resolver_export=False): self.graal_tear_down_isolate = CallableFunction( lambda thread: self.tear_down_threads.append(thread) or 0 ) - if resolver_export: - self.run_script_with_resolver = Function() + self.free_cstring = Function() + self.create_engine = CallableFunction(self._create_engine) + self.create_engine_with_resolver = CallableFunction(self._create_engine_with_resolver) + self.destroy_engine = CallableFunction( + lambda _thread, handle: self.destroyed_engines.append(handle) + ) + self.run_script_engine = Function() + self.run_script_callback_engine = Function() + self.run_script_input_output_callback_engine = Function() + + def _create_engine(self, _thread): + handle = self._next_handle + self._next_handle += 1 + self.created_engines.append((handle, None, None)) + return handle + + def _create_engine_with_resolver(self, _thread, callback, ctx): + handle = self._next_handle + self._next_handle += 1 + self.created_engines.append((handle, callback, ctx)) + return handle def _attach_thread(self, _isolate, thread): worker_thread = native.GraalIsolateThreadPointer() @@ -49,6 +71,57 @@ def _attach_thread(self, _isolate, thread): return 0 +@pytest.mark.unit +def test_shared_isolate_is_created_once_and_torn_down_on_last_release(monkeypatch): + library = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + a = native.NativeRuntime("/tmp/dwlib") + b = native.NativeRuntime("/tmp/dwlib") + a.initialize() + b.initialize() + + # One shared isolate, two engines, refcount == live engines. + assert native._isolate_ref_count == 2 + assert len(library.tear_down_threads) == 0 + assert [h for h, _cb, _ctx in library.created_engines] == [a.handle, b.handle] + assert a.handle != b.handle + + a.cleanup() + assert native._isolate_ref_count == 1 + assert library.destroyed_engines == [a.handle] + assert len(library.tear_down_threads) == 0 # isolate stays for b + + b.cleanup() + assert native._isolate_ref_count == 0 + assert library.destroyed_engines == [a.handle, b.handle] + assert len(library.tear_down_threads) == 1 # last release tears down + + # Idempotent double-cleanup releases the ref only once. + b.cleanup() + assert native._isolate_ref_count == 0 + assert len(library.tear_down_threads) == 1 + + +@pytest.mark.unit +def test_engine_create_failure_releases_isolate_ref(monkeypatch): + library = FakeLibrary() + library.create_engine = CallableFunction( + lambda _thread: (_ for _ in ()).throw(RuntimeError("boom")) + ) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + runtime = native.NativeRuntime("/tmp/dwlib") + with pytest.raises(native.DataWeaveError): + runtime.initialize() + + # Failed init must leak nothing: the isolate it created is torn down. + assert native._isolate_ref_count == 0 + assert native._isolate is None + assert len(library.tear_down_threads) == 1 + assert runtime.initialized is False + + @pytest.mark.unit def test_parse_native_response_rejects_malformed_json(): result = dataweave._parse_native_encoded_response("not json") @@ -112,14 +185,15 @@ def test_decode_and_free_preserves_decode_failure_when_free_also_fails(monkeypat @pytest.mark.unit def test_native_runtime_registers_abi_and_cleans_up_idempotently(monkeypatch): library = FakeLibrary() - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) runtime = native.NativeRuntime("/tmp/dwlib") runtime.initialize() runtime.cleanup() runtime.cleanup() - assert library.run_script.argtypes[1:] == [native.ctypes.c_char_p, native.ctypes.c_char_p] + assert library.run_script_engine.argtypes[1:] == [ + native.ctypes.c_int64, native.ctypes.c_char_p, native.ctypes.c_char_p, + ] assert library.free_cstring.argtypes[1] is native.ctypes.c_void_p assert len(library.tear_down_threads) == 1 assert runtime.initialized is False @@ -130,8 +204,8 @@ def test_buffered_worker_execution_uses_one_current_thread_attachment_for_run_de calls = [] buffer = ctypes.create_string_buffer(b"result") library = FakeLibrary() - library.run_script = CallableFunction( - lambda thread, _script, _inputs: calls.append(("run", get_ident(), thread)) + library.run_script_engine = CallableFunction( + lambda thread, _handle, _script, _inputs: calls.append(("run", get_ident(), thread)) or ctypes.addressof(buffer) ) library.free_cstring = CallableFunction( @@ -145,7 +219,7 @@ def test_buffered_worker_execution_uses_one_current_thread_attachment_for_run_de worker = Thread( target=lambda: outcomes.append( - (get_ident(), runtime.run_script_and_decode(runtime.thread, b"script", b"{}")) + (get_ident(), runtime.run_engine_and_decode(b"script", b"{}")) ) ) worker.start() @@ -175,8 +249,8 @@ def test_buffered_worker_execution_uses_one_current_thread_attachment_for_run_de def test_distinct_thread_object_attaches_when_python_thread_ident_is_reused(monkeypatch): buffer = ctypes.create_string_buffer(b"result") library = FakeLibrary() - library.run_script = CallableFunction( - lambda _thread, _script, _inputs: ctypes.addressof(buffer) + library.run_script_engine = CallableFunction( + lambda _thread, _handle, _script, _inputs: ctypes.addressof(buffer) ) library.free_cstring = CallableFunction(lambda _thread, _ptr: None) monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) @@ -189,7 +263,7 @@ def test_distinct_thread_object_attaches_when_python_thread_ident_is_reused(monk worker = Thread( target=lambda: ( observed_threads.append(current_thread()), - runtime.run_script_and_decode(runtime.thread, b"script", b"{}"), + runtime.run_engine_and_decode(b"script", b"{}"), ) ) worker.start() @@ -215,6 +289,7 @@ def test_cleanup_clears_owner_thread_reference(monkeypatch): runtime.cleanup() assert runtime._owner_thread is None + assert native._isolate_ref_count == 0 @pytest.mark.unit @@ -223,7 +298,7 @@ def test_buffered_worker_execution_detaches_current_thread_after_failure(monkeyp buffer = ctypes.create_string_buffer(b"result") library = FakeLibrary() - def run_script(_thread, _script, _inputs): + def run_script_engine(_thread, _handle, _script, _inputs): if failure == "run": raise RuntimeError("run failed") return ctypes.addressof(buffer) @@ -232,7 +307,7 @@ def free_cstring(_thread, _ptr): if failure == "free": raise RuntimeError("free failed") - library.run_script = CallableFunction(run_script) + library.run_script_engine = CallableFunction(run_script_engine) library.free_cstring = CallableFunction(free_cstring) if failure == "detach": library.graal_detach_thread = CallableFunction( @@ -248,7 +323,7 @@ def free_cstring(_thread, _ptr): worker = Thread( target=lambda: _capture_error( errors, - lambda: runtime.run_script_and_decode(runtime.thread, b"script", b"{}"), + lambda: runtime.run_engine_and_decode(b"script", b"{}"), ) ) worker.start() @@ -264,61 +339,6 @@ def free_cstring(_thread, _ptr): assert library.detach_calls[0][0] == library.attach_calls[0][0] -@pytest.mark.unit -@pytest.mark.parametrize( - ("method_name", "native_name", "extra_args"), - [ - ("run_script", "run_script", ()), - ( - "run_script_with_resolver", - "run_script_with_resolver", - (lambda _path: "module source",), - ), - ("run_script_callback", "run_script_callback", (object(),)), - ( - "run_script_input_output_callback", - "run_script_input_output_callback", - (b"payload", b"application/json", None, object(), object()), - ), - ], -) -def test_raw_pointer_calls_use_supplied_thread_without_automatic_attachment( - monkeypatch, method_name, native_name, extra_args -): - observed_threads = [] - library = FakeLibrary(resolver_export=method_name == "run_script_with_resolver") - setattr( - library, - native_name, - CallableFunction( - lambda thread, *_args: observed_threads.append(thread) or 123 - ), - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - runtime.has_callback_streaming = True - runtime.has_callback_input_output = True - supplied_thread = runtime.thread - outcomes = [] - - worker = Thread( - target=lambda: outcomes.append( - getattr(runtime, method_name)( - supplied_thread, b"script", b"{}", *extra_args - ) - ) - ) - worker.start() - worker.join(1) - - assert not worker.is_alive() - assert outcomes == [123] - assert observed_threads == [supplied_thread] - assert library.attach_calls == [] - assert library.detach_calls == [] - - def _capture_error(errors, invoke): try: invoke() @@ -326,549 +346,6 @@ def _capture_error(errors, invoke): errors.append(error) -@pytest.mark.unit -def test_native_runtime_registers_optional_module_resolver_export(monkeypatch): - library = FakeLibrary(resolver_export=True) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - assert runtime.has_module_resolver is True - assert library.run_script_with_resolver.argtypes == [ - native.GraalIsolateThreadPointer, - native.ctypes.c_char_p, - native.ctypes.c_char_p, - dataweave.RESOLVE_MODULE_CALLBACK, - ] - assert library.run_script_with_resolver.restype is native.ctypes.c_void_p - - -@pytest.mark.unit -def test_native_runtime_initializes_without_optional_module_resolver_export(monkeypatch): - library = FakeLibrary() - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - assert runtime.has_module_resolver is False - - -@pytest.mark.unit -def test_run_script_with_resolver_adapts_path_and_retains_source_buffer(monkeypatch): - observed = [] - resolver_paths = [] - library = FakeLibrary(resolver_export=True) - - def invoke(_thread, _script, _inputs, callback): - address = callback(None, b"/org/test/lib.dwl") - observed.append(ctypes.string_at(address).decode("utf-8")) - assert library.runtime._resolver_buffers - return 0 - - library.run_script_with_resolver = CallableFunction(invoke) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - library.runtime = runtime - runtime.initialize() - stale_buffer = ctypes.create_string_buffer(b"stale") - runtime._resolver_buffers.append(stale_buffer) - - resolver = lambda path: resolver_paths.append(path) or "module source" - result = runtime.run_script_with_resolver("thread", b"script", b"{}", resolver) - - assert result == 0 - assert resolver_paths == ["org/test/lib.dwl"] - assert observed == ["module source"] - assert runtime._resolver_buffers == [] - - -@pytest.mark.unit -@pytest.mark.parametrize( - ("module_path", "resolver"), - [ - (b"/missing.dwl", lambda _path: None), - (b"/invalid.dwl", lambda _path: 42), - (b"\xff", lambda _path: "unreachable"), - ], -) -def test_resolver_callback_returns_null_for_unresolved_or_invalid_values( - monkeypatch, module_path, resolver -): - addresses = [] - library = FakeLibrary(resolver_export=True) - library.run_script_with_resolver = CallableFunction( - lambda _thread, _script, _inputs, callback: addresses.append( - callback(None, module_path) - ) or 0 - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - runtime.run_script_with_resolver("thread", b"script", b"{}", resolver) - - assert addresses == [None] - assert runtime._resolver_buffers == [] - - -@pytest.mark.unit -def test_resolver_callback_contains_exceptions_and_hides_details_by_default( - monkeypatch, capsys -): - addresses = [] - library = FakeLibrary(resolver_export=True) - library.run_script_with_resolver = CallableFunction( - lambda _thread, _script, _inputs, callback: addresses.append( - callback(None, b"/org/test/lib.dwl") - ) or 0 - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - monkeypatch.delenv("DATAWEAVE_RESOLVER_DEBUG", raising=False) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - def resolver(_path): - raise RuntimeError("secret /private/path") - - runtime.run_script_with_resolver("thread", b"script", b"{}", resolver) - - captured = capsys.readouterr() - assert addresses == [None] - assert "DataWeave module resolver callback failed." in captured.err - assert "secret" not in captured.err - assert "/private/path" not in captured.err - - -@pytest.mark.unit -def test_resolver_callback_prints_exception_details_in_debug_mode( - monkeypatch, capsys -): - library = FakeLibrary(resolver_export=True) - library.run_script_with_resolver = CallableFunction( - lambda _thread, _script, _inputs, callback: callback( - None, b"/org/test/lib.dwl" - ) or 0 - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - monkeypatch.setenv("DATAWEAVE_RESOLVER_DEBUG", "1") - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - def resolver(_path): - raise RuntimeError("secret /private/path") - - runtime.run_script_with_resolver("thread", b"script", b"{}", resolver) - - captured = capsys.readouterr() - assert "RuntimeError: secret /private/path" in captured.err - - -@pytest.mark.unit -def test_resolver_callback_contains_base_exceptions(monkeypatch, capsys): - class ResolverExit(BaseException): - pass - - addresses = [] - library = FakeLibrary(resolver_export=True) - library.run_script_with_resolver = CallableFunction( - lambda _thread, _script, _inputs, callback: addresses.append( - callback(None, b"/org/test/lib.dwl") - ) or 0 - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - monkeypatch.delenv("DATAWEAVE_RESOLVER_DEBUG", raising=False) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - def resolver(_path): - raise ResolverExit("secret /private/path") - - runtime.run_script_with_resolver("thread", b"script", b"{}", resolver) - - captured = capsys.readouterr() - assert addresses == [None] - assert "DataWeave module resolver callback failed." in captured.err - assert "secret" not in captured.err - assert "/private/path" not in captured.err - - -@pytest.mark.unit -def test_resolver_callback_contains_diagnostic_writer_failures(monkeypatch): - addresses = [] - library = FakeLibrary(resolver_export=True) - library.run_script_with_resolver = CallableFunction( - lambda _thread, _script, _inputs, callback: addresses.append( - callback(None, b"/org/test/lib.dwl") - ) or 0 - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - monkeypatch.delenv("DATAWEAVE_RESOLVER_DEBUG", raising=False) - monkeypatch.setattr( - native.sys, - "stderr", - type( - "FailingStderr", - (), - {"write": lambda _self, _value: (_ for _ in ()).throw(SystemExit(9))}, - )(), - ) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - def resolver(_path): - raise KeyboardInterrupt("secret /private/path") - - runtime.run_script_with_resolver("thread", b"script", b"{}", resolver) - - assert addresses == [None] - - -@pytest.mark.unit -def test_run_script_with_resolver_clears_buffers_when_native_call_fails(monkeypatch): - library = FakeLibrary(resolver_export=True) - - def invoke(_thread, _script, _inputs, callback): - assert callback(None, b"/org/test/lib.dwl") - raise RuntimeError("native failure") - - library.run_script_with_resolver = CallableFunction(invoke) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - with pytest.raises(RuntimeError, match="native failure"): - runtime.run_script_with_resolver( - "thread", b"script", b"{}", lambda _path: "module source" - ) - - assert runtime._resolver_buffers == [] - - -@pytest.mark.unit -def test_run_script_with_resolver_serializes_calls_and_buffer_cleanup(monkeypatch): - first_entered = Event() - release_first = Event() - second_entered = Event() - errors = [] - library = FakeLibrary(resolver_export=True) - - def invoke(_thread, script, _inputs, callback): - address = callback(None, b"/org/test/lib.dwl") - if script == b"first": - first_entered.set() - if not release_first.wait(1): - raise AssertionError("first invocation was not released") - assert ctypes.string_at(address) == b"module source" - assert len(library.runtime._resolver_buffers) == 1 - else: - second_entered.set() - return 0 - - library.run_script_with_resolver = CallableFunction(invoke) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - library.runtime = runtime - runtime.initialize() - resolver = lambda _path: "module source" - - def run(script): - try: - runtime.run_script_with_resolver("thread", script, b"{}", resolver) - except Exception as error: - errors.append(error) - - first = Thread(target=run, args=(b"first",)) - second = Thread(target=run, args=(b"second",)) - first.start() - assert first_entered.wait(1) - second.start() - - assert not second_entered.wait(0.1) - release_first.set() - first.join(1) - second.join(1) - - assert not first.is_alive() - assert not second.is_alive() - assert second_entered.is_set() - assert errors == [] - assert runtime._resolver_buffers == [] - - -@pytest.mark.unit -def test_native_runtime_reentrant_execution_fails_without_deadlocking(monkeypatch): - completed = Event() - nested_errors = [] - library = FakeLibrary() - - def invoke(thread, script, inputs): - if script == b"outer": - try: - library.runtime.run_script(thread, b"nested", inputs) - except Exception as error: - nested_errors.append(error) - return 0 - - library.run_script = CallableFunction(invoke) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - library.runtime = runtime - runtime.initialize() - - worker = Thread( - target=lambda: (runtime.run_script("thread", b"outer", b"{}"), completed.set()), - daemon=True, - ) - worker.start() - - assert completed.wait(1), "reentrant native execution deadlocked" - assert len(nested_errors) == 1 - assert isinstance(nested_errors[0], dataweave.DataWeaveError) - assert "reentrant" in str(nested_errors[0]).lower() - - -@pytest.mark.unit -def test_resolver_callback_translates_reentrant_execution_to_null(monkeypatch): - completed = Event() - callback_results = [] - library = FakeLibrary(resolver_export=True) - library.run_script = CallableFunction(lambda _thread, _script, _inputs: 0) - library.run_script_with_resolver = CallableFunction( - lambda thread, _script, inputs, callback: callback_results.append( - callback(thread, b"/org/test/lib.dwl") - ) or 0 - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - def resolver(_path): - runtime.run_script("thread", b"nested", b"{}") - return "unreachable" - - worker = Thread( - target=lambda: ( - runtime.run_script_with_resolver("thread", b"outer", b"{}", resolver), - completed.set(), - ), - daemon=True, - ) - worker.start() - - assert completed.wait(1), "resolver callback re-entry deadlocked" - assert callback_results == [None] - - -@pytest.mark.unit -def test_cleanup_waits_for_resolver_aware_call(monkeypatch): - run_entered = Event() - release_run = Event() - teardown_entered = Event() - library = FakeLibrary(resolver_export=True) - - def invoke(_thread, _script, _inputs, callback): - assert callback(None, b"/org/test/lib.dwl") - run_entered.set() - assert release_run.wait(1) - return 0 - - library.run_script_with_resolver = CallableFunction(invoke) - library.graal_tear_down_isolate = CallableFunction( - lambda _thread: teardown_entered.set() or 0 - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - run_thread = Thread( - target=runtime.run_script_with_resolver, - args=("thread", b"script", b"{}", lambda _path: "module source"), - ) - cleanup_thread = Thread(target=runtime.cleanup) - run_thread.start() - assert run_entered.wait(1) - cleanup_thread.start() - - assert not teardown_entered.wait(0.1) - release_run.set() - run_thread.join(1) - cleanup_thread.join(1) - - assert not run_thread.is_alive() - assert not cleanup_thread.is_alive() - assert teardown_entered.is_set() - - -@pytest.mark.unit -@pytest.mark.parametrize( - "invoke", - [ - lambda runtime: runtime.run_script_and_decode("thread", b"script", b"{}"), - lambda runtime: runtime.run_script_with_resolver_and_decode( - "thread", b"script", b"{}", lambda _path: "module source" - ), - lambda runtime: runtime.run_script_callback_and_decode( - "thread", b"script", b"{}", object() - ), - lambda runtime: runtime.run_script_input_output_callback_and_decode( - "thread", - b"script", - b"{}", - b"payload", - b"application/json", - None, - object(), - object(), - ), - ], -) -def test_cleanup_waits_until_native_result_is_decoded_and_freed(monkeypatch, invoke): - native_returned = Event() - release_decode = Event() - freed = Event() - teardown_entered = Event() - errors = [] - buffer = ctypes.create_string_buffer(b"result") - pointer = ctypes.addressof(buffer) - library = FakeLibrary(resolver_export=True) - return_pointer = lambda *_args: native_returned.set() or pointer - library.run_script = CallableFunction(return_pointer) - library.run_script_with_resolver = CallableFunction(return_pointer) - library.run_script_callback = CallableFunction(return_pointer) - library.run_script_input_output_callback = CallableFunction(return_pointer) - library.graal_attach_thread = CallableFunction(lambda _isolate, _thread: 0) - library.graal_detach_thread = CallableFunction(lambda _thread: 0) - library.free_cstring = CallableFunction( - lambda _thread, _ptr: release_decode.wait(1) and freed.set() - ) - library.graal_tear_down_isolate = CallableFunction( - lambda _thread: teardown_entered.set() or 0 - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - runtime.has_callback_streaming = True - runtime.has_callback_input_output = True - - def run(): - try: - invoke(runtime) - except Exception as error: - errors.append(error) - - run_thread = Thread(target=run) - cleanup_thread = Thread(target=runtime.cleanup) - run_thread.start() - assert native_returned.wait(1) - cleanup_thread.start() - - assert not teardown_entered.wait(0.1) - release_decode.set() - assert freed.wait(1) - run_thread.join(1) - cleanup_thread.join(1) - - assert not run_thread.is_alive() - assert not cleanup_thread.is_alive() - assert teardown_entered.is_set() - assert errors == [] - - -@pytest.mark.unit -def test_run_script_with_resolver_rejects_missing_native_export(monkeypatch): - library = FakeLibrary() - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - - with pytest.raises( - dataweave.DataWeaveError, - match=r"Native library does not support module resolver API \(run_script_with_resolver not found\)\.", - ): - runtime.run_script_with_resolver( - "thread", b"script", b"{}", lambda _path: "module source" - ) - - -@pytest.mark.unit -def test_native_runtime_retains_one_resolver_callback_until_teardown(monkeypatch): - retained_during_teardown = [] - library = FakeLibrary(resolver_export=True) - library.run_script_with_resolver = CallableFunction( - lambda _thread, _script, _inputs, _callback: 0 - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - library.graal_tear_down_isolate = CallableFunction( - lambda _thread: retained_during_teardown.append( - runtime._module_resolver_callback is not None - ) or 0 - ) - runtime.initialize() - resolver = lambda _path: "module source" - - runtime.run_script_with_resolver("thread", b"script", b"{}", resolver) - callback = runtime._module_resolver_callback - runtime.run_script_with_resolver("thread", b"script", b"{}", resolver) - - assert runtime._module_resolver_callback is callback - with pytest.raises(dataweave.DataWeaveError): - runtime.run_script_with_resolver( - "thread", b"script", b"{}", lambda _path: "other source" - ) - - runtime.cleanup() - - assert retained_during_teardown == [True] - assert runtime._module_resolver_callback is None - assert runtime._module_resolver is None - - -@pytest.mark.unit -def test_cleanup_failure_preserves_runtime_state_for_successful_retry(monkeypatch): - library = FakeLibrary(resolver_export=True) - library.run_script_with_resolver = CallableFunction( - lambda _thread, _script, _inputs, _callback: 0 - ) - tear_down_results = iter((7, 0)) - library.graal_tear_down_isolate = CallableFunction( - lambda _thread: next(tear_down_results) - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - isolate = runtime.isolate - thread = runtime.thread - resolver = lambda _path: "module source" - runtime.run_script_with_resolver("thread", b"script", b"{}", resolver) - callback = runtime._module_resolver_callback - - with pytest.raises( - dataweave.DataWeaveError, - match="Failed to tear down GraalVM isolate. Error code: 7", - ): - runtime.cleanup() - - assert runtime.initialized is True - assert runtime.lib is library - assert runtime.isolate is isolate - assert runtime.thread is thread - assert runtime.has_module_resolver is True - assert runtime._module_resolver is resolver - assert runtime._module_resolver_callback is callback - - runtime.cleanup() - - assert runtime.initialized is False - assert runtime.lib is None - assert runtime.isolate is None - assert runtime.thread is None - assert runtime._module_resolver is None - assert runtime._module_resolver_callback is None - - @pytest.mark.unit def test_cleanup_from_worker_uses_current_thread_for_isolate_teardown(monkeypatch): library = FakeLibrary() @@ -896,55 +373,6 @@ def test_cleanup_from_worker_uses_current_thread_for_isolate_teardown(monkeypatc assert runtime.initialized is False -@pytest.mark.unit -def test_failed_cleanup_from_worker_detaches_and_preserves_state_for_owner_retry(monkeypatch): - library = FakeLibrary() - tear_down_results = iter((7, 0)) - library.graal_tear_down_isolate = CallableFunction( - lambda _thread: next(tear_down_results) - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - errors = [] - - worker = Thread(target=lambda: _capture_error(errors, runtime.cleanup)) - worker.start() - worker.join(1) - - assert not worker.is_alive() - assert len(errors) == 1 - assert runtime.initialized is True - assert len(library.attach_calls) == 1 - assert len(library.detach_calls) == 1 - - runtime.cleanup() - - assert runtime.initialized is False - - -@pytest.mark.unit -def test_failed_worker_cleanup_preserves_teardown_error_when_detach_also_fails(monkeypatch): - library = FakeLibrary() - library.graal_tear_down_isolate = CallableFunction(lambda _thread: 7) - library.graal_detach_thread = CallableFunction( - lambda _thread: (_ for _ in ()).throw(RuntimeError("detach failed")) - ) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - runtime.initialize() - errors = [] - - worker = Thread(target=lambda: _capture_error(errors, runtime.cleanup)) - worker.start() - worker.join(1) - - assert not worker.is_alive() - assert len(errors) == 1 - assert str(errors[0]) == "Failed to tear down GraalVM isolate. Error code: 7" - assert runtime.initialized is True - - @pytest.mark.unit def test_native_runtime_wraps_library_load_errors(monkeypatch): monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: (_ for _ in ()).throw(OSError("bad image"))) @@ -955,11 +383,8 @@ def test_native_runtime_wraps_library_load_errors(monkeypatch): @pytest.mark.unit def test_initialize_resets_state_when_isolate_creation_fails(monkeypatch): - class Function: - def __call__(self, *_args): - return 9 - - library = type("Native", (), {"graal_create_isolate": Function()})() + library = FakeLibrary() + library.graal_create_isolate = CallableFunction(lambda _params, _isolate, _thread: 9) monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) runtime = native.NativeRuntime("/tmp/dwlib") @@ -970,6 +395,8 @@ def __call__(self, *_args): assert runtime.isolate is None assert runtime.thread is None assert runtime.initialized is False + assert native._isolate_ref_count == 0 + assert native._isolate is None @pytest.mark.unit @@ -982,75 +409,17 @@ def test_initialize_requires_create_isolate_export(monkeypatch): @pytest.mark.unit def test_initialize_wraps_create_isolate_exception(monkeypatch): - class Function: - def __call__(self, *_args): - raise RuntimeError("native create failure") - - library = type("Native", (), {"graal_create_isolate": Function()})() + library = FakeLibrary() + library.graal_create_isolate = CallableFunction( + lambda _params, _isolate, _thread: (_ for _ in ()).throw(RuntimeError("native create failure")) + ) monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) with pytest.raises(dataweave.DataWeaveError, match="Failed to create GraalVM isolate: native create failure"): native.NativeRuntime("/tmp/dwlib").initialize() - -@pytest.mark.unit -def test_cleanup_wraps_teardown_exception(): - runtime = native.NativeRuntime.__new__(native.NativeRuntime) - runtime.initialized = True - runtime.thread = object() - runtime.isolate = object() - runtime.lib = type("Native", (), {"graal_tear_down_isolate": lambda _self, _thread: (_ for _ in ()).throw(RuntimeError("native teardown failure"))})() - - with pytest.raises(dataweave.DataWeaveError, match="Failed to tear down GraalVM isolate: native teardown failure"): - runtime.cleanup() - - -@pytest.mark.unit -def test_initialize_tears_down_isolate_when_required_export_is_missing(monkeypatch): - class Function: - def __init__(self, callback): - self.callback = callback - - def __call__(self, *args): - return self.callback(*args) - - torn_down = [] - library = type("Native", (), {})() - library.graal_create_isolate = Function(lambda _params, _isolate, _thread: 0) - library.graal_tear_down_isolate = Function(lambda thread: torn_down.append(thread) or 0) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - runtime = native.NativeRuntime("/tmp/dwlib") - - with pytest.raises(dataweave.DataWeaveError, match="Native library does not export run_script"): - runtime.initialize() - - assert len(torn_down) == 1 - assert runtime.lib is None - assert runtime.isolate is None - assert runtime.thread is None - - -@pytest.mark.unit -def test_initialize_rejects_streaming_export_without_required_lifecycle_symbols(monkeypatch): - class Function: - def __init__(self, callback=lambda *_args: 0): - self.callback = callback - - def __call__(self, *args): - return self.callback(*args) - - torn_down = [] - library = type("Native", (), {})() - library.graal_create_isolate = Function() - library.graal_tear_down_isolate = Function(lambda thread: torn_down.append(thread) or 0) - library.run_script = Function() - library.run_script_callback = Function() - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - - with pytest.raises(dataweave.DataWeaveError, match="Native library does not export free_cstring"): - native.NativeRuntime("/tmp/dwlib").initialize() - - assert len(torn_down) == 1 + assert native._isolate_ref_count == 0 + assert native._isolate is None @pytest.mark.unit @@ -1075,23 +444,6 @@ def __call__(self, *_args): native.NativeRuntime("/tmp/dwlib").initialize() -@pytest.mark.unit -def test_cleanup_surfaces_native_teardown_error_code(monkeypatch): - runtime = native.NativeRuntime.__new__(native.NativeRuntime) - runtime.initialized = True - runtime.thread = object() - runtime.isolate = object() - runtime.lib = type("Native", (), {"graal_tear_down_isolate": lambda _self, _thread: 7})() - - with pytest.raises(dataweave.DataWeaveError, match="Failed to tear down GraalVM isolate. Error code: 7"): - runtime.cleanup() - - assert runtime.initialized is True - assert runtime.lib is not None - assert runtime.thread is not None - assert runtime.isolate is not None - - @pytest.mark.unit @pytest.mark.parametrize( ("method_name", "error_message"), From 7e3ef9a0cf6858e9ca745ff1669ba075c3ee443a Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 16:59:40 -0300 Subject: [PATCH 141/216] fix(python): route engine create/destroy through per-OS-thread isolate attachment (W-23692110) Co-Authored-By: Claude Sonnet 5 --- native-lib/python/src/dataweave/native.py | 20 +++-- native-lib/python/tests/unit/test_native.py | 91 +++++++++++++++++++-- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index 5f43207a..67ca45dd 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -137,6 +137,14 @@ def _release_isolate() -> None: if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: raise DataWeaveError("Failed to attach thread for isolate teardown") _tear_down(lib, worker) + except BaseException: + print( + "DataWeave: GraalVM isolate teardown failed; the isolate reference " + "has been cleared and a fresh isolate will be created on the next " + "initialize().", + file=sys.stderr, + ) + raise finally: _lib = _lib_path = _isolate = _isolate_thread = _isolate_owner_thread = None @@ -219,10 +227,11 @@ def initialize(self) -> None: def _create_engine(self) -> int: # Engines without a resolver are created here; the resolver variant is # installed by install_resolver() (Task 4) before this is called. - try: - handle = self.lib.create_engine(self.thread) - except Exception as error: - raise DataWeaveError(f"Failed to create DataWeave engine: {error}") from error + with self._current_thread_attachment(self.thread) as thread: + try: + handle = self.lib.create_engine(thread) + except Exception as error: + raise DataWeaveError(f"Failed to create DataWeave engine: {error}") from error if not handle: raise DataWeaveError("Native create_engine returned a null handle") return handle @@ -366,7 +375,8 @@ def cleanup(self) -> None: self.initialized = False try: if self.handle: - self.lib.destroy_engine(self.thread, self.handle) + with self._current_thread_attachment(self.thread) as thread: + self.lib.destroy_engine(thread, self.handle) finally: # Release the isolate ref even if destroy_engine throws, so a # throwing destroy cannot strand the isolate. diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 174ed2f3..d2760a05 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -24,12 +24,14 @@ class FakeLibrary: run_script = Function() free_cstring = Function() - def __init__(self, *, resolver_export=True): + def __init__(self): self.attach_calls = [] self.detach_calls = [] self.tear_down_threads = [] self.created_engines = [] self.destroyed_engines = [] + self.create_engine_threads = [] + self.destroy_engine_threads = [] self._next_handle = 1 self.graal_create_isolate = CallableFunction(lambda _params, _isolate, _thread: 0) self.graal_attach_thread = CallableFunction(self._attach_thread) @@ -43,22 +45,27 @@ def __init__(self, *, resolver_export=True): self.create_engine = CallableFunction(self._create_engine) self.create_engine_with_resolver = CallableFunction(self._create_engine_with_resolver) self.destroy_engine = CallableFunction( - lambda _thread, handle: self.destroyed_engines.append(handle) + lambda thread, handle: ( + self.destroy_engine_threads.append(thread), + self.destroyed_engines.append(handle), + ) ) self.run_script_engine = Function() self.run_script_callback_engine = Function() self.run_script_input_output_callback_engine = Function() - def _create_engine(self, _thread): + def _create_engine(self, thread): handle = self._next_handle self._next_handle += 1 self.created_engines.append((handle, None, None)) + self.create_engine_threads.append(thread) return handle - def _create_engine_with_resolver(self, _thread, callback, ctx): + def _create_engine_with_resolver(self, thread, callback, ctx): handle = self._next_handle self._next_handle += 1 self.created_engines.append((handle, callback, ctx)) + self.create_engine_threads.append(thread) return handle def _attach_thread(self, _isolate, thread): @@ -365,11 +372,21 @@ def test_cleanup_from_worker_uses_current_thread_for_isolate_teardown(monkeypatc assert not worker.is_alive() worker_ident, teardown_thread = teardown_calls[0] assert worker_ident != owner_ident + # Off-owner cleanup now attaches/detaches its own thread for destroy_engine + # (attach_calls[0]), then the isolate teardown attaches a second, separate + # thread for graal_tear_down_isolate (attach_calls[1]); teardown itself + # never explicitly detaches (tearing down the isolate implicitly does). + assert len(library.attach_calls) == 2 assert library.attach_calls[0][0] == worker_ident + assert library.attach_calls[1][0] == worker_ident assert ctypes.cast(teardown_thread, ctypes.c_void_p).value == ctypes.cast( + library.attach_calls[1][1], ctypes.c_void_p + ).value + assert len(library.detach_calls) == 1 + assert library.detach_calls[0][0] == worker_ident + assert ctypes.cast(library.detach_calls[0][1], ctypes.c_void_p).value == ctypes.cast( library.attach_calls[0][1], ctypes.c_void_p ).value - assert library.detach_calls == [] assert runtime.initialized is False @@ -469,3 +486,67 @@ def graal_detach_thread(self, _thread): runtime.attach_thread() else: runtime.detach_thread(object()) + + +@pytest.mark.unit +def test_engine_create_and_destroy_off_owner_thread_use_an_attached_thread(monkeypatch): + library = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + # A initializes on THIS (owner) thread -> create uses the owner isolate thread. + a = native.NativeRuntime("/tmp/dwlib") + a.initialize() + owner_thread_ptr = native._isolate_thread + assert library.create_engine_threads[0] is owner_thread_ptr + + # B initializes on a DIFFERENT OS thread -> must attach a fresh thread. + errors = [] + b = native.NativeRuntime("/tmp/dwlib") + + def init_b(): + try: + b.initialize() + except BaseException as error: # pragma: no cover - surfaced via assert + errors.append(error) + + t = Thread(target=init_b) + t.start() + t.join(2) + assert not errors + assert library.create_engine_threads[1] is not owner_thread_ptr + + # Destroy B from a non-owner thread -> likewise attaches, not owner ptr. + def cleanup_b(): + try: + b.cleanup() + except BaseException as error: # pragma: no cover + errors.append(error) + + t2 = Thread(target=cleanup_b) + t2.start() + t2.join(2) + assert not errors + assert library.destroy_engine_threads[-1] is not owner_thread_ptr + + a.cleanup() + + +@pytest.mark.unit +def test_failed_isolate_teardown_surfaces_and_clears_state_for_retry(monkeypatch): + library = FakeLibrary() + library.graal_tear_down_isolate = CallableFunction(lambda _thread: 1) # non-zero == failure + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + a = native.NativeRuntime("/tmp/dwlib") + a.initialize() + with pytest.raises(native.DataWeaveError): + a.cleanup() # last release -> teardown fails -> raises + # State cleared regardless, so a fresh isolate is creatable. + assert native._isolate is None + assert native._isolate_ref_count == 0 + b = native.NativeRuntime("/tmp/dwlib") + b.initialize() # must succeed against a fresh isolate + assert native._isolate is not None + # Restore a passing teardown so b.cleanup() doesn't raise on the way out. + library.graal_tear_down_isolate = CallableFunction(lambda _thread: 0) + b.cleanup() From 731ec38c3ce014682a2a4791d05e64841110e0ee Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 17:14:51 -0300 Subject: [PATCH 142/216] feat(python): per-engine resolver ctx trampoline with owner-thread fail-closed guard (W-23692110) Co-Authored-By: Claude Sonnet 5 --- native-lib/python/src/dataweave/native.py | 115 ++++++++++++-------- native-lib/python/tests/unit/test_native.py | 65 +++++++++++ 2 files changed, 133 insertions(+), 47 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index 67ca45dd..173b5ef7 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -39,6 +39,21 @@ class graal_isolatethread_t(ctypes.Structure): _isolate_ref_count = 0 +# Per-engine resolver dispatch. The ctx passed to create_engine_with_resolver is +# a Python-allocated monotonic token (NOT the Java handle) so it is known before +# the engine exists -- no resolve callback can fire for an unregistered ctx. +_resolver_lock_global = Lock() +_resolver_registry = {} # token(int) -> NativeRuntime +_resolver_token_seq = 0 + + +def _next_resolver_token() -> int: + global _resolver_token_seq + with _resolver_lock_global: + _resolver_token_seq += 1 + return _resolver_token_seq + + def _bind_abi(lib) -> None: """Binds argtypes/restypes for the engine ABI and lifecycle exports (once).""" for name in ("graal_create_isolate", "graal_attach_thread", "graal_detach_thread", @@ -225,11 +240,19 @@ def initialize(self) -> None: self.initialized = True def _create_engine(self) -> int: - # Engines without a resolver are created here; the resolver variant is - # installed by install_resolver() (Task 4) before this is called. with self._current_thread_attachment(self.thread) as thread: try: - handle = self.lib.create_engine(thread) + if self._resolver is not None: + # Pass the bare int token; the declared c_void_p argtype on the + # real ABI call converts it automatically. (Wrapping it in + # ctypes.c_void_p(...) here would produce an unhashable Python + # object, breaking the FakeLibrary-recorded ctx round-trip used + # in tests -- and offers no benefit for the real ctypes call.) + handle = self.lib.create_engine_with_resolver( + thread, self._resolver_callback, self._resolver_token + ) + else: + handle = self.lib.create_engine(thread) except Exception as error: raise DataWeaveError(f"Failed to create DataWeave engine: {error}") from error if not handle: @@ -304,57 +327,37 @@ def run_input_output_callback_engine_and_decode( current, ) - @contextmanager - def _resolver_scope(self): - yield - - def run_script_with_resolver(self, thread, script: bytes, inputs: bytes, resolver: ModuleResolver): - with self._serialized_native_operation(): - return self._run_script_with_resolver(thread, script, inputs, resolver) - - def run_script_with_resolver_and_decode(self, thread, script: bytes, inputs: bytes, resolver: ModuleResolver) -> str: - with self._serialized_native_operation(): - with self._current_thread_attachment(thread) as current_thread: - return self.decode_and_free( - self._run_script_with_resolver(current_thread, script, inputs, resolver), - current_thread, - ) - - def _run_script_with_resolver(self, thread, script: bytes, inputs: bytes, resolver: ModuleResolver): - if not self.has_module_resolver: - raise DataWeaveError( - "Native library does not support module resolver API " - "(run_script_with_resolver not found)." - ) - if self._module_resolver is None: - self._module_resolver = resolver - self._module_resolver_callback = self._create_module_resolver_callback(resolver) - elif self._module_resolver is not resolver: - raise DataWeaveError("Native runtime already has a different module resolver") - - self._resolver_buffers.clear() - self._resolver_active = True - try: - return self.lib.run_script_with_resolver( - thread, script, inputs, self._module_resolver_callback - ) - finally: - self._resolver_active = False - self._resolver_buffers.clear() - - def _create_module_resolver_callback(self, resolver: ModuleResolver): - def resolve(_thread, module_path): + def install_resolver(self, resolver: ModuleResolver) -> None: + """Binds a module resolver to this engine. Must be called before initialize().""" + if self.initialized: + raise DataWeaveError("Cannot install a resolver after initialize().") + self._resolver = resolver + self._resolver_token = _next_resolver_token() + self._resolver_callback = self._make_trampoline() + with _resolver_lock_global: + _resolver_registry[self._resolver_token] = self + + def _make_trampoline(self): + token = self._resolver_token + def resolve(_thread, _ctx, module_path): try: - if not self._resolver_active: + entry = _resolver_registry.get(token) + if entry is None: + return None + # Fail-closed guard: resolve only during a synchronous run on the + # thread that installed the scope. Streaming workers run on other + # threads and never enter the scope -> return None without calling + # the Python resolver (preserves calls == calls_after_install). + if not entry._resolver_active or get_ident() != entry._resolver_active_ident: return None path = module_path.decode("utf-8") if path.startswith("/"): path = path[1:] - source = resolver(path) + source = entry._resolver(path) if not isinstance(source, str): return None buffer = ctypes.create_string_buffer(source.encode("utf-8")) - self._resolver_buffers.append(buffer) + entry._resolver_buffers.append(buffer) return ctypes.addressof(buffer) except BaseException: try: @@ -365,9 +368,23 @@ def resolve(_thread, module_path): except BaseException: pass return None - return RESOLVE_MODULE_CALLBACK(resolve) + @contextmanager + def _resolver_scope(self): + if self._resolver is None: + yield + return + self._resolver_buffers = [] + self._resolver_active = True + self._resolver_active_ident = get_ident() + try: + yield + finally: + self._resolver_active = False + self._resolver_active_ident = None + self._resolver_buffers = [] + def cleanup(self) -> None: with self._serialized_native_operation(): if not self.initialized: @@ -386,6 +403,10 @@ def cleanup(self) -> None: self._resolver_buffers = [] self._resolver_active = False self._resolver_active_ident = None + if self._resolver_token: + with _resolver_lock_global: + _resolver_registry.pop(self._resolver_token, None) + self._resolver_token = 0 _release_isolate() @contextmanager diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index d2760a05..a925e13d 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -550,3 +550,68 @@ def test_failed_isolate_teardown_surfaces_and_clears_state_for_retry(monkeypatch # Restore a passing teardown so b.cleanup() doesn't raise on the way out. library.graal_tear_down_isolate = CallableFunction(lambda _thread: 0) b.cleanup() + + +@pytest.mark.unit +def test_two_engines_dispatch_to_their_own_resolver(monkeypatch): + library = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + a = native.NativeRuntime("/tmp/dwlib") + a.install_resolver(lambda path: f"A:{path}") + a.initialize() + b = native.NativeRuntime("/tmp/dwlib") + b.install_resolver(lambda path: f"B:{path}") + b.initialize() + + # ctx tokens are distinct and registered. + _ha, cb_a, ctx_a = next(e for e in library.created_engines if e[0] == a.handle) + _hb, cb_b, ctx_b = next(e for e in library.created_engines if e[0] == b.handle) + assert ctx_a != ctx_b + assert native._resolver_registry[ctx_a] is a + assert native._resolver_registry[ctx_b] is b + + # Simulate a synchronous resolve on each engine's owner thread. + with a._resolver_scope(): + ptr_a = cb_a(None, ctx_a, b"org/x.dwl") + assert ctypes.string_at(ptr_a) == b"A:org/x.dwl" + + with b._resolver_scope(): + ptr_b = cb_b(None, ctx_b, b"org/x.dwl") + assert ctypes.string_at(ptr_b) == b"B:org/x.dwl" + + a.cleanup() + assert ctx_a not in native._resolver_registry + b.cleanup() + + +@pytest.mark.unit +def test_resolver_fails_closed_off_the_owner_thread_without_invoking_python(monkeypatch): + library = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + calls = [] + a = native.NativeRuntime("/tmp/dwlib") + a.install_resolver(lambda path: calls.append(path) or "src") + a.initialize() + _h, callback, ctx = library.created_engines[0] + + # Not inside a synchronous resolver scope (mirrors a streaming worker): must + # return None WITHOUT invoking the Python resolver. + assert callback(None, ctx, b"org/x.dwl") is None + assert calls == [] + + # Inside the scope but on a different Python thread ident: still fail-closed. + results = [] + def worker(): + with a._resolver_scope(): + # Overwrite the active ident to the worker's, but call from... actually + # _resolver_scope records THIS thread's ident, so a same-thread call + # resolves. Assert the positive to anchor the guard semantics. + results.append(callback(None, ctx, b"org/y.dwl")) + import threading + t = threading.Thread(target=worker) + t.start(); t.join(2) + assert results and ctypes.string_at(results[0]) == b"src" + + a.cleanup() From e4ab709b34d9b84b005710fb71af0a3b495ceefd Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 17:40:10 -0300 Subject: [PATCH 143/216] feat(python): route DataWeave execution through the engine handle; resolver bound at init (W-23692110) Co-Authored-By: Claude Sonnet 5 --- native-lib/python/src/dataweave/runtime.py | 24 ++++----- native-lib/python/tests/unit/test_facade.py | 18 +++---- native-lib/python/tests/unit/test_runtime.py | 54 +++++++++++++++++++ .../python/tests/unit/test_streaming.py | 41 ++++++++------ 4 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 native-lib/python/tests/unit/test_runtime.py diff --git a/native-lib/python/src/dataweave/runtime.py b/native-lib/python/src/dataweave/runtime.py index cb66b1a1..641e2cf4 100644 --- a/native-lib/python/src/dataweave/runtime.py +++ b/native-lib/python/src/dataweave/runtime.py @@ -41,6 +41,8 @@ def __init__( self._cleaning_up = False def initialize(self): + if self._resolve_module is not None: + self._native.install_resolver(self._resolve_module) self._native.initialize() def cleanup(self): @@ -86,17 +88,9 @@ def _inputs_json(inputs: Optional[Dict[str, Any]]) -> bytes: def run(self, script: str, inputs: Optional[Dict[str, Any]] = None, raise_on_error: bool = False) -> ExecutionResult: self._require_initialized(True, "script execution") try: - encoded_script = script.encode("utf-8") - encoded_inputs = self._inputs_json(inputs) - if self._resolve_module is None: - raw = self._native.run_script_and_decode(self._native.thread, encoded_script, encoded_inputs) - else: - raw = self._native.run_script_with_resolver_and_decode( - self._native.thread, - encoded_script, - encoded_inputs, - self._resolve_module, - ) + raw = self._native.run_engine_and_decode( + script.encode("utf-8"), self._inputs_json(inputs) + ) result = parse_native_encoded_response(raw) except Exception as error: raise DataWeaveError(f"Failed to execute script: {error}") @@ -113,7 +107,7 @@ def write_cb(_context, buffer, length): except Exception: return -1 try: - raw = self._native.run_script_callback_and_decode(self._native.thread, script.encode("utf-8"), self._inputs_json(inputs), write_cb) + raw = self._native.run_callback_engine_and_decode(self._native.thread, script.encode("utf-8"), self._inputs_json(inputs), write_cb) return parse_streaming_result(json.loads(raw) if raw else {"success": False, "error": "Empty response"}) except Exception as error: raise DataWeaveError(f"Failed to execute callback streaming: {error}") @@ -202,7 +196,7 @@ def run_streaming(self, script: str, inputs: Optional[Dict[str, Any]] = None) -> self._require_initialized(self._native.has_callback_streaming, "callback streaming API (run_script_callback not found)") cancelled = Event() encoded_inputs = self._inputs_json(inputs) - stream = Stream(self._stream_worker(lambda thread, write_cb: self._native.run_script_callback_and_decode(thread, script.encode("utf-8"), encoded_inputs, write_cb), cancelled)) + stream = Stream(self._stream_worker(lambda thread, write_cb: self._native.run_callback_engine_and_decode(thread, script.encode("utf-8"), encoded_inputs, write_cb), cancelled)) stream._on_close = cancelled.set stream._cancelled = cancelled return stream @@ -239,7 +233,7 @@ def run_transform(self, script: str, input_stream: Iterable[bytes], input_name: read_cb = self._chunk_reader(input_stream) encoded_inputs = self._inputs_json(inputs) def invoke(thread, write_cb): - return self._native.run_script_input_output_callback_and_decode(thread, script.encode("utf-8"), encoded_inputs, input_name.encode("utf-8"), input_mime_type.encode("utf-8"), input_charset.encode("utf-8") if input_charset else None, read_cb, write_cb) + return self._native.run_input_output_callback_engine_and_decode(thread, script.encode("utf-8"), encoded_inputs, input_name.encode("utf-8"), input_mime_type.encode("utf-8"), input_charset.encode("utf-8") if input_charset else None, read_cb, write_cb) stream = Stream(self._stream_worker(invoke, cancelled)) stream._on_close = cancelled.set stream._cancelled = cancelled @@ -266,7 +260,7 @@ def write_cb(_context, buffer, length): except Exception: return -1 try: - raw = self._native.run_script_input_output_callback_and_decode(self._native.thread, script.encode("utf-8"), self._inputs_json(inputs), input_name.encode("utf-8"), input_mime_type.encode("utf-8"), input_charset.encode("utf-8") if input_charset else None, read_cb, write_cb) + raw = self._native.run_input_output_callback_engine_and_decode(self._native.thread, script.encode("utf-8"), self._inputs_json(inputs), input_name.encode("utf-8"), input_mime_type.encode("utf-8"), input_charset.encode("utf-8") if input_charset else None, read_cb, write_cb) return parse_streaming_result(json.loads(raw) if raw else {"success": False, "error": "Empty response"}) except Exception as error: raise DataWeaveError(f"Failed to execute callback input/output streaming: {error}") diff --git a/native-lib/python/tests/unit/test_facade.py b/native-lib/python/tests/unit/test_facade.py index 3191e032..3e2504bc 100644 --- a/native-lib/python/tests/unit/test_facade.py +++ b/native-lib/python/tests/unit/test_facade.py @@ -12,12 +12,8 @@ def __init__(self): self.thread = "thread" self.calls = [] - def run_script_and_decode(self, *args): - self.calls.append(("run_script_and_decode", args)) - return self._result() - - def run_script_with_resolver_and_decode(self, *args): - self.calls.append(("run_script_with_resolver_and_decode", args)) + def run_engine_and_decode(self, *args): + self.calls.append(("run_engine_and_decode", args)) return self._result() @staticmethod @@ -91,7 +87,7 @@ def test_dataweave_constructor_stores_keyword_only_module_resolver(monkeypatch): @pytest.mark.unit -def test_run_dispatches_to_resolver_aware_native_execution(): +def test_run_uses_engine_execution_regardless_of_resolver(): resolver = lambda _path: "source" instance = configured_runtime(resolver) @@ -102,19 +98,17 @@ def test_run_dispatches_to_resolver_aware_native_execution(): ) assert instance._native.calls == [ ( - "run_script_with_resolver_and_decode", + "run_engine_and_decode", ( - "thread", b"payload", b'{"value": {"content": "MQ==", "mimeType": "application/json", "charset": "utf-8"}}', - resolver, ), ) ] @pytest.mark.unit -def test_run_without_resolver_preserves_native_execution_path(): +def test_run_without_resolver_routes_through_engine(): instance = configured_runtime() result = instance.run("payload") @@ -123,7 +117,7 @@ def test_run_without_resolver_preserves_native_execution_path(): True, "SGVsbG8=", None, False, "text/plain", "utf-8" ) assert instance._native.calls == [ - ("run_script_and_decode", ("thread", b"payload", b"{}")) + ("run_engine_and_decode", (b"payload", b"{}")) ] diff --git a/native-lib/python/tests/unit/test_runtime.py b/native-lib/python/tests/unit/test_runtime.py new file mode 100644 index 00000000..865dc547 --- /dev/null +++ b/native-lib/python/tests/unit/test_runtime.py @@ -0,0 +1,54 @@ +import pytest + +from dataweave import native, runtime +from dataweave.runtime import DataWeave + + +class _FakeNative: + def __init__(self, lib_path=None): + self.installed_resolver = None + self.initialized = False + self.handle = 0 + self.thread = object() + self.has_callback_streaming = True + self.has_callback_input_output = True + self.cleaned = 0 + self.runs = [] + + def install_resolver(self, resolver): + assert not self.initialized, "resolver must be installed before initialize()" + self.installed_resolver = resolver + + def initialize(self): + self.initialized = True + self.handle = 7 + + def run_engine_and_decode(self, script, inputs): + self.runs.append((script, inputs)) + return '{"success":true,"result":"","binary":false,"mimeType":"application/json","charset":"UTF-8"}' + + def cleanup(self): + self.cleaned += 1 + self.initialized = False + + +@pytest.mark.unit +def test_dataweave_installs_resolver_before_initialize(monkeypatch): + monkeypatch.setattr(runtime, "NativeRuntime", _FakeNative) + resolver = lambda path: None + dw = DataWeave(resolve_module=resolver) + dw.initialize() + assert dw._native.installed_resolver is resolver + assert dw._native.initialized is True + dw.cleanup() + assert dw._native.cleaned == 1 + + +@pytest.mark.unit +def test_run_routes_through_engine(monkeypatch): + monkeypatch.setattr(runtime, "NativeRuntime", _FakeNative) + dw = DataWeave() + dw.initialize() + dw.run("1 + 1") + assert dw._native.runs == [(b"1 + 1", b"{}")] + dw.cleanup() diff --git a/native-lib/python/tests/unit/test_streaming.py b/native-lib/python/tests/unit/test_streaming.py index 8f68a2ee..c6e4e7b2 100644 --- a/native-lib/python/tests/unit/test_streaming.py +++ b/native-lib/python/tests/unit/test_streaming.py @@ -1,6 +1,6 @@ import ctypes from queue import Full, Queue -from threading import current_thread, Event, Thread +from threading import current_thread, Event, Lock, Thread from time import sleep import pytest @@ -40,14 +40,14 @@ def _response_pointer(self): self._buffers.append(buffer) return ctypes.addressof(buffer) - def run_script_callback(self, _thread, _script, _inputs, write_callback, _context): + def run_script_callback_engine(self, _thread, _handle, _script, _inputs, write_callback, _context): if self.emit: buffer = ctypes.create_string_buffer(self.emit) self.write_status = write_callback(None, ctypes.addressof(buffer), len(self.emit)) return self._response_pointer() - def run_script_input_output_callback( - self, _thread, _script, _inputs, _input_name, _mime_type, _charset, read_callback, write_callback, _context, + def run_script_input_output_callback_engine( + self, _thread, _handle, _script, _inputs, _input_name, _mime_type, _charset, read_callback, write_callback, _context, ): if self.consume_input: buffer = ctypes.create_string_buffer(3) @@ -66,6 +66,9 @@ def run_script_input_output_callback( assert write_callback(None, ctypes.addressof(buffer), len(self.emit)) == 0 return self._response_pointer() + def destroy_engine(self, _thread, _handle): + self.destroyed_handle = _handle + def configured_runtime(native): runtime = dataweave.DataWeave.__new__(dataweave.DataWeave) @@ -77,6 +80,15 @@ def configured_runtime(native): native_runtime.isolate = object() native_runtime.thread = object() native_runtime._owner_thread = current_thread() + native_runtime.handle = 1 + native_runtime._resolver = None + native_runtime._resolver_callback = None + native_runtime._resolver_token = 0 + native_runtime._resolver_buffers = [] + native_runtime._resolver_active = False + native_runtime._resolver_active_ident = None + native_runtime._resolver_lock = Lock() + native_runtime._execution_owner = None runtime._native = native_runtime return runtime @@ -234,7 +246,7 @@ def __init__(self): self.first_chunk_written = Event() self.cancelled = None - def run_script_callback(self, _thread, _script, _inputs, write_callback, _context): + def run_script_callback_engine(self, _thread, _handle, _script, _inputs, write_callback, _context): first = ctypes.create_string_buffer(b"first") assert write_callback(None, ctypes.addressof(first), 5) == 0 self.first_chunk_written.set() @@ -259,8 +271,8 @@ def run_script_callback(self, _thread, _script, _inputs, write_callback, _contex @pytest.mark.unit def test_run_input_output_callback_rejects_oversized_read_chunk_without_truncating(): class OversizedInputNative(FakeNative): - def run_script_input_output_callback( - self, _thread, _script, _inputs, _input_name, _mime_type, _charset, read_callback, _write_callback, _context, + def run_script_input_output_callback_engine( + self, _thread, _handle, _script, _inputs, _input_name, _mime_type, _charset, read_callback, _write_callback, _context, ): buffer = ctypes.create_string_buffer(3) self.read_status = read_callback(None, ctypes.addressof(buffer), len(buffer)) @@ -298,7 +310,7 @@ def __init__(self): self.queue_full = Event() self.cancelled = None - def run_script_callback(self, _thread, _script, _inputs, write_callback, _context): + def run_script_callback_engine(self, _thread, _handle, _script, _inputs, write_callback, _context): first = ctypes.create_string_buffer(b"first") assert write_callback(None, ctypes.addressof(first), 5) == 0 second = ctypes.create_string_buffer(b"second") @@ -334,7 +346,7 @@ def test_runtime_module_owns_dataweave_orchestration(): @pytest.mark.unit def test_run_streaming_reports_worker_timeout_when_native_call_produces_no_output(monkeypatch): class BlockingFakeNative(FakeNative): - def run_script_callback(self, _thread, _script, _inputs, _write_callback, _context): + def run_script_callback_engine(self, _thread, _handle, _script, _inputs, _write_callback, _context): sleep(0.05) return self._response_pointer() @@ -348,7 +360,6 @@ def run_script_callback(self, _thread, _script, _inputs, _write_callback, _conte @pytest.mark.unit def test_stream_worker_start_failure_does_not_block_cleanup(monkeypatch): native = FakeNative('{"success": true}') - native.graal_tear_down_isolate = lambda _thread: 0 runtime = configured_runtime(native) def fail_start(_worker): @@ -364,7 +375,7 @@ def fail_start(_worker): @pytest.mark.unit def test_stream_finalization_does_not_raise_when_a_native_worker_cannot_cancel(monkeypatch): class UncancellableNative(FakeNative): - def run_script_callback(self, _thread, _script, _inputs, _write_callback, _context): + def run_script_callback_engine(self, _thread, _handle, _script, _inputs, _write_callback, _context): sleep(0.1) return self._response_pointer() @@ -384,14 +395,13 @@ def __init__(self): self.release = Event() self.torn_down = False - def run_script_callback(self, _thread, _script, _inputs, _write_callback, _context): + def run_script_callback_engine(self, _thread, _handle, _script, _inputs, _write_callback, _context): self.started.set() self.release.wait() return self._response_pointer() - def graal_tear_down_isolate(self, _thread): + def destroy_engine(self, _thread, _handle): self.torn_down = True - return 0 monkeypatch.setattr(runtime_module, "_WORKER_JOIN_TIMEOUT_SECONDS", 0.001) native = BlockingNative() @@ -430,10 +440,9 @@ def __init__(self): self.cleanup_started = Event() self.release_cleanup = Event() - def graal_tear_down_isolate(self, _thread): + def destroy_engine(self, _thread, _handle): self.cleanup_started.set() self.release_cleanup.wait() - return 0 native = BlockingCleanupNative() runtime = configured_runtime(native) From b8ad180d81fbf9db09bb40b6c8bd41cfe166ffea Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 17:50:12 -0300 Subject: [PATCH 144/216] test(python): multi-instance refcount teardown regression (W-23692110) Co-Authored-By: Claude Sonnet 5 --- .../tests/integration/test_module_resolver.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/native-lib/python/tests/integration/test_module_resolver.py b/native-lib/python/tests/integration/test_module_resolver.py index 8f9a510c..f28b4c40 100644 --- a/native-lib/python/tests/integration/test_module_resolver.py +++ b/native-lib/python/tests/integration/test_module_resolver.py @@ -318,3 +318,31 @@ def run(): ], "errors": [], } + + +@pytest.mark.integration +def test_shared_isolate_survives_until_the_last_instance_cleans_up(): + from dataweave import native + + a = dataweave.DataWeave(resolve_module=dataweave.modules_from_map({ + "org/test/lib.dwl": "%dw 2.0\nfun answer() = 1", + })) + b = dataweave.DataWeave(resolve_module=dataweave.modules_from_map({ + "org/test/lib.dwl": "%dw 2.0\nfun answer() = 2", + })) + a.initialize() + b.initialize() + try: + assert native._isolate is not None + assert native._isolate_ref_count == 2 + assert a.run(IMPORT_LIB_SCRIPT).get_string() == "1" + assert b.run(IMPORT_LIB_SCRIPT).get_string() == "2" + + a.cleanup() + assert native._isolate is not None # b still holds a ref + assert native._isolate_ref_count == 1 + assert b.run(IMPORT_LIB_SCRIPT).get_string() == "2" # b unaffected + finally: + b.cleanup() + assert native._isolate_ref_count == 0 + assert native._isolate is None # last release tore it down From a9b6c9d11bc194d20d487c0d825bb710507b359b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 18:01:17 -0300 Subject: [PATCH 145/216] refactor(node): remove legacy run_script path; engine ABI only (W-23692110) Co-Authored-By: Claude Sonnet 5 --- native-lib/node/src/addon.c | 103 +----------------------------------- native-lib/node/src/ffi.ts | 5 -- 2 files changed, 2 insertions(+), 106 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 2861a740..6a1ecd4c 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -10,13 +10,10 @@ typedef int (*graal_create_isolate_fn)(void*, void**, void**); typedef int (*graal_attach_thread_fn)(void*, void**); typedef int (*graal_detach_thread_fn)(void*); typedef int (*graal_tear_down_isolate_fn)(void*); -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, 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*); // 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 @@ -48,10 +45,7 @@ static graal_create_isolate_fn fn_create_isolate = NULL; static graal_attach_thread_fn fn_attach_thread = NULL; static graal_detach_thread_fn fn_detach_thread = NULL; static graal_tear_down_isolate_fn fn_tear_down_isolate = NULL; -static run_script_fn fn_run_script = NULL; 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; // Per-engine entrypoints static create_engine_fn fn_create_engine = NULL; @@ -475,15 +469,11 @@ static void init_thread_fn(void* arg) { uv_dlsym(&g_lib, "graal_attach_thread", (void**)&fn_attach_thread); uv_dlsym(&g_lib, "graal_detach_thread", (void**)&fn_detach_thread); uv_dlsym(&g_lib, "graal_tear_down_isolate", (void**)&fn_tear_down_isolate); - uv_dlsym(&g_lib, "run_script", (void**)&fn_run_script); uv_dlsym(&g_lib, "free_cstring", (void**)&fn_free_cstring); - 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. 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. + // load-time required, not optional. 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); @@ -491,7 +481,7 @@ static void init_thread_fn(void* arg) { 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) { + if (!fn_create_isolate || !fn_free_cstring) { snprintf(args->error, sizeof(args->error), "Missing required symbols in library"); args->result = -2; return; @@ -769,92 +759,6 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { return NULL; } -// --- Helper: run any GraalVM call on a dedicated thread --- - -struct script_call_args { - const char* script; - const char* inputs_json; - char* result; -}; - -static void run_script_thread_fn(void* arg) { - struct script_call_args* a = (struct script_call_args*)arg; - - void* thread = NULL; - int rc = fn_attach_thread(g_isolate, &thread); - if (rc != 0) { - a->result = strdup("{\"success\":false,\"error\":\"Failed to attach GraalVM thread\"}"); - return; - } - - void* ptr = fn_run_script(thread, a->script, a->inputs_json); - if (ptr) { - a->result = strdup((const char*)ptr); - fn_free_cstring(thread, ptr); - } else { - a->result = strdup(""); - } - - fn_detach_thread(thread); -} - -// --- runScript (synchronous from JS, but runs GraalVM on a thread) --- - -static napi_value dw_napi_run_script(napi_env env, napi_callback_info info) { - if (!g_initialized) { - napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); - return NULL; - } - - size_t argc = 2; - napi_value argv[2]; - napi_get_cb_info(env, info, &argc, argv, NULL, NULL); - - if (argc < 2) { - napi_throw_error(env, NULL, "runScript requires (script, inputsJson)"); - return NULL; - } - - 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); - - char* script = malloc(script_len + 1); - char* inputs = malloc(inputs_len + 1); - napi_get_value_string_utf8(env, argv[0], script, script_len + 1, NULL); - napi_get_value_string_utf8(env, argv[1], inputs, inputs_len + 1, NULL); - - struct script_call_args call_args; - call_args.script = script; - call_args.inputs_json = inputs; - call_args.result = NULL; - - uv_thread_t tid; - 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, 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); - free(inputs); - - napi_value result; - if (call_args.result) { - napi_create_string_utf8(env, call_args.result, strlen(call_args.result), &result); - free(call_args.result); - } else { - napi_create_string_utf8(env, "", 0, &result); - } - return result; -} - // --- Streaming output --- // Round-9 (#2): static terminal-error JSON used when a worker thread cannot @@ -2906,9 +2810,6 @@ static napi_value Init(napi_env env, napi_value exports) { napi_create_function(env, "initialize", NAPI_AUTO_LENGTH, napi_initialize, NULL, &fn); napi_set_named_property(env, exports, "initialize", fn); - 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, "createEngine", NAPI_AUTO_LENGTH, napi_create_engine, NULL, &fn); napi_set_named_property(env, exports, "createEngine", fn); diff --git a/native-lib/node/src/ffi.ts b/native-lib/node/src/ffi.ts index ef5c8cd1..30c3e85b 100644 --- a/native-lib/node/src/ffi.ts +++ b/native-lib/node/src/ffi.ts @@ -3,7 +3,6 @@ import type { ModuleResolver } from "./resolver"; interface NativeAddon { initialize(libPath: string): void; - runScript(script: string, inputsJson: string): string; createEngine(): number; createEngineWithResolver(resolver: ModuleResolver): number; destroyEngine(handle: number): void; @@ -40,10 +39,6 @@ export function initialize(libPath: string, addonPath?: string): void { getAddon(addonPath).initialize(libPath); } -export function runScript(script: string, inputsJson: string): string { - return getAddon().runScript(script, inputsJson); -} - export function createEngine(): number { return getAddon().createEngine(); } From 2cfeda3424c4703b4356715bb6e324e9ac1d1fa4 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 18:10:15 -0300 Subject: [PATCH 146/216] docs: correct Python notes + add shared engine-lifecycle contract (W-23692110) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-08-07-native-lib-multi-engine-design.md | 55 +++++++++++++------ ...-python-multi-engine-unification-design.md | 18 ++++++ 2 files changed, 56 insertions(+), 17 deletions(-) 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 index 5c031910..854951d1 100644 --- 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 @@ -15,6 +15,18 @@ > `DataWeave` class is pre-GA, so several internal contracts (async `cleanup()`, the removed > `*_with_resolver` C ABI) changed during hardening without a compatibility ceremony. +> **Superseded Python notes (update 2026-08-26).** This document is the *Node* design and +> assumed the Python binding would stay on the old isolate-per-instance model behind the +> retained legacy singleton entrypoints (`ScriptRuntime.getInstance()` + `run_script` / +> `run_script_callback` / `run_script_input_output_callback`). That is no longer true: the +> `ScriptRuntime` singleton and all three legacy C entrypoints have been **removed**, and both +> the Node and Python bindings now drive the *same* shared-isolate + handle-addressed-engine +> model through the identical `*_engine` C ABI. Python uses a module-level reference-counted +> isolate with one engine handle per `DataWeave` instance. The Python-specific claims flagged +> inline below are corrected in place; see +> [docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md](./2026-08-26-python-multi-engine-unification-design.md) +> for the design that superseded them. The Node sections remain accurate as shipped. + ## 1. Goal Let multiple `DataWeave` instances coexist in one Node process, each with its own module @@ -37,9 +49,10 @@ resolver-backed `run()` first won. (`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." The limitation was specific to `native-lib`'s deliberate Java static singleton plus -the Node C addon's global resolver bridge. +Python binding — which *at the time of this design* used one GraalVM isolate per `DataWeave()` +instance — got resolver isolation "for free." The limitation was specific to `native-lib`'s +deliberate Java static singleton plus the Node C addon's global resolver bridge. (Python has +since been unified onto the shared-isolate + handle model; see the superseded-notes banner above.) ## 3. Scope @@ -51,9 +64,10 @@ the Node C addon's global resolver bridge. - Node TypeScript layer (`ffi.ts`, `dataweave.ts`, `stream.ts`, `reader.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. +**Out of scope (as of this Node design):** +- Python binding changes. Python already achieved isolation via one isolate per instance; + unifying it onto the same handle-based API was left as a follow-up. **(Since completed — + see the superseded-notes banner above and the 2026-08-26 Python unification design.)** - Separate GraalVM isolates per engine — rejected as the isolation mechanism (see §5). - Solving streaming/transform + **custom-module** resolution across the background-thread boundary. Streaming against a resolver-backed engine still fails closed (returns "not found") @@ -339,8 +353,10 @@ because a boolean cannot represent the window during which `cleanup()` has start `AtomicLong` handle allocator. The resolver is bound once at construction (immutable for the instance's lifetime); the `static setResolver` write-once mutation is removed. `compositeResolver()` / `createModuleComponentsFactory()` become instance methods. - `getInstance()` is **kept** returning a lazily-created default (ClassLoader-only, handle-less) - instance so the resolver-less legacy entrypoints used by Python are untouched. + *(As originally shipped, `getInstance()` was kept returning a lazily-created default + instance so the resolver-less legacy entrypoints used by Python stayed untouched. Both + `getInstance()` and those legacy entrypoints have since been **removed** — Python now uses + the handle-addressed `*_engine` ABI. See the superseded-notes banner above.)* - **`CallbackWeaveResourceResolver.java`** — stores a `PointerBase ctx` alongside the callback, forwarded on every `callback.invoke(...)`; constructor `(ResolveModuleCallback, PointerBase ctx)`. - **`NativeCallbacks.java`** — `ResolveModuleCallback` gains a `ctx` parameter @@ -350,9 +366,11 @@ because a boolean cannot represent the window during which `cleanup()` has start - **`NativeLib.java`** — adds handle-based lifecycle + execution entrypoints (`create_engine`, `create_engine_with_resolver`, `destroy_engine`, `run_script_engine`, `run_script_callback_engine`, `run_script_input_output_callback_engine`) resolving via - `ScriptRuntime.get(handle)`. The legacy singleton entrypoints (`run_script`, - `run_script_callback`, `run_script_input_output_callback`) are **preserved unchanged** for - Python. The old `*_with_resolver` entrypoints are **removed** (see §9). + `ScriptRuntime.get(handle)`. *(As originally shipped, the legacy singleton entrypoints + (`run_script`, `run_script_callback`, `run_script_input_output_callback`) were preserved + unchanged for Python; they have since been **removed** — Python now consumes the `*_engine` + set too. See the superseded-notes banner above.)* The old `*_with_resolver` entrypoints are + **removed** (see §9). ### Layer 2 — C addon (`native-lib/node/src/addon.c`) @@ -417,8 +435,10 @@ dwB.run(...) → resolves via resolver B only; A's cache untouched; no cross-t the JSON string for sync `run()`), never an NPE. - **Admission / argument / allocation failures:** synchronous `napi_throw_error` (generic Error); worker-thread OOM → terminal error JSON. Never `napi_reject_deferred` (absent from `addon.c`). -- **Python binding:** zero changes — it never called the removed `*_with_resolver` entrypoints and - continues on `getInstance()`. +- **Python binding:** *(as of this Node design)* zero changes — it never called the removed + `*_with_resolver` entrypoints and continued on `getInstance()`. **(No longer true: Python has + since been ported to the handle-addressed `*_engine` ABI and `getInstance()` is gone — see the + superseded-notes banner above.)** - **Node, resolver-less / single-resolver usage:** behaves identically; the new code path is a functional superset. - **Intended breaking changes (pre-GA, no shims):** the dwlib C ABI drops the exported @@ -460,10 +480,11 @@ dwB.run(...) → resolves via resolver B only; A's cache untouched; no cross-t ## 11. Follow-Up Work -- **Python binding parity:** port the handle-based `create_engine`/`run_script_engine` API to the - Python binding so both bindings share one mental model (child GUS item under W-23692110). The - broad Python-binding modernization currently riding along in this PR is acknowledged as a - scope-bundling and deferred to its own follow-up PR rather than split mid-review. +- **Python binding parity: DONE (2026-08-26).** The handle-based + `create_engine`/`run_script_engine` API has been ported to the Python binding so both + bindings share one mental model, and the `ScriptRuntime` singleton plus the three legacy C + entrypoints have been removed. See + [docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md](./2026-08-26-python-multi-engine-unification-design.md). - **Streaming/transform + custom-module resolution** across the background-thread boundary remains a separate, not-yet-scoped effort (unrelated to the singleton fix). diff --git a/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md b/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md index 65b82942..4ff431d4 100644 --- a/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md +++ b/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md @@ -279,3 +279,21 @@ dwB.cleanup() → join workers; destroy_engine(handleB); ref 1→0 → detach m | Node addon (legacy path to remove) | `native-lib/node/src/addon.c` (`dw_napi_run_script`) | | Python glue to rewrite | `native-lib/python/src/dataweave/{native,runtime,models}.py` | | Current Python isolate-per-instance model | `native-lib/python/src/dataweave/native.py` (`graal_create_isolate` in `NativeRuntime.initialize`) | + +## Engine lifecycle contract (shared by both bindings) + +These invariants are the shared artifact both `native-lib/node/src/addon.c` and +`native-lib/python/src/dataweave/native.py` implement. Any binding on the `*_engine` C ABI must +uphold all six: + +1. One process-wide isolate; engines are handle-addressed objects in the Java registry. +2. The isolate is reference-counted; the refcount equals the number of live engines; the isolate + exists iff the refcount > 0. +3. Create-on-first-ref, tear-down-on-last-release; the binding calls + `graal_create_isolate` / `graal_tear_down_isolate` from *outside* the isolate. +4. Each engine handle is created by `create_engine` / `create_engine_with_resolver` and destroyed + by `destroy_engine`. +5. Resolver dispatch is per-engine via the opaque `ctx` echoed to the 3-arg `ResolveModuleCallback`; + custom-module resolution fails closed off the engine's owner thread. +6. A failed engine-create rolls back the isolate ref; a throwing `destroy_engine` still releases + the ref. From 056b5a5d07f825711a24ee42162dcd66f663d0f4 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 19:29:12 -0300 Subject: [PATCH 147/216] fix(python): detach isolate bootstrap thread + attach-on-demand so cross-thread teardown cannot hang; unregister resolver token on failed init (W-23692110) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/native.py | 68 +++--- .../tests/integration/test_module_resolver.py | 34 +++ native-lib/python/tests/unit/conftest.py | 2 - native-lib/python/tests/unit/test_native.py | 219 ++++++++++++++---- .../python/tests/unit/test_streaming.py | 8 +- 5 files changed, 258 insertions(+), 73 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index 173b5ef7..a8ea403f 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -3,7 +3,7 @@ import os from pathlib import Path import sys -from threading import current_thread, get_ident, Lock +from threading import get_ident, Lock import traceback from typing import Optional @@ -34,8 +34,6 @@ class graal_isolatethread_t(ctypes.Structure): _lib = None _lib_path = None _isolate = None -_isolate_thread = None # the main attached IsolateThread (GraalIsolateThreadPointer) -_isolate_owner_thread = None # the Python threading.Thread that created the isolate _isolate_ref_count = 0 @@ -105,9 +103,9 @@ def _bind_abi(lib) -> None: def _acquire_isolate(lib_path: str): - """Returns (lib, isolate, thread, owner_thread), creating the shared isolate on - the first reference. Increments the refcount only on success.""" - global _lib, _lib_path, _isolate, _isolate_thread, _isolate_owner_thread, _isolate_ref_count + """Returns (lib, isolate), creating the shared isolate on the first reference. + Increments the refcount only on success.""" + global _lib, _lib_path, _isolate, _isolate_ref_count with _isolate_lock: if _isolate is None: try: @@ -123,35 +121,41 @@ def _acquire_isolate(lib_path: str): raise DataWeaveError(f"Failed to create GraalVM isolate: {error}") from error if result != 0: raise DataWeaveError(f"Failed to create GraalVM isolate. Error code: {result}") + # Detach the bootstrap thread immediately. A thread left attached to the + # isolate blocks graal_tear_down_isolate forever when the last release + # runs on a different OS thread (e.g. the atexit cleanup thread). Every + # subsequent native call attaches its own thread on demand and detaches + # when done; teardown attaches a fresh thread. Mirrors the Node/Go bindings. + detach_result = lib.graal_detach_thread(thread) + if detach_result != 0: + raise DataWeaveError( + f"Failed to detach GraalVM isolate bootstrap thread. Error code: {detach_result}" + ) _lib = lib _lib_path = lib_path _isolate = isolate - _isolate_thread = thread - _isolate_owner_thread = current_thread() _isolate_ref_count += 1 - return _lib, _isolate, _isolate_thread, _isolate_owner_thread + return _lib, _isolate def _release_isolate() -> None: """Decrements the refcount; tears the isolate down and nulls globals on 0.""" - global _lib, _lib_path, _isolate, _isolate_thread, _isolate_owner_thread, _isolate_ref_count + global _lib, _lib_path, _isolate, _isolate_ref_count with _isolate_lock: if _isolate_ref_count == 0: return _isolate_ref_count -= 1 if _isolate_ref_count > 0: return - # Last release: tear down from the owner thread if we are on it, else a - # fresh attached thread. Then clear globals regardless. - lib, isolate, main_thread, owner = _lib, _isolate, _isolate_thread, _isolate_owner_thread + # Last release: no thread is persistently attached (the bootstrap was + # detached at create and every op detaches its own thread), so attach a + # fresh thread and tear down. Then clear globals regardless. + lib, isolate = _lib, _isolate try: - if current_thread() is owner: - _tear_down(lib, main_thread) - else: - worker = GraalIsolateThreadPointer() - if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: - raise DataWeaveError("Failed to attach thread for isolate teardown") - _tear_down(lib, worker) + worker = GraalIsolateThreadPointer() + if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: + raise DataWeaveError("Failed to attach thread for isolate teardown") + _tear_down(lib, worker) except BaseException: print( "DataWeave: GraalVM isolate teardown failed; the isolate reference " @@ -161,7 +165,7 @@ def _release_isolate() -> None: ) raise finally: - _lib = _lib_path = _isolate = _isolate_thread = _isolate_owner_thread = None + _lib = _lib_path = _isolate = None def _tear_down(lib, thread) -> None: @@ -210,7 +214,6 @@ def __init__(self, lib_path: Optional[str] = None): self.lib = None self.isolate = None self.thread = None - self._owner_thread = None self.handle = 0 self.initialized = False # Every engine supports every API now (single unified ABI). @@ -229,12 +232,19 @@ def __init__(self, lib_path: Optional[str] = None): def initialize(self) -> None: if self.initialized: return - self.lib, self.isolate, self.thread, self._owner_thread = _acquire_isolate(self.lib_path) + self.lib, self.isolate = _acquire_isolate(self.lib_path) try: self.handle = self._create_engine() except Exception: # Roll back the ref we just took so a failed init leaks nothing. - self.lib = self.isolate = self.thread = self._owner_thread = None + self.lib = self.isolate = None + # Finding #2: install_resolver() registered a token BEFORE this call. + # A failed init must unregister it, or it leaks: self.initialized stays + # False, so a later cleanup() returns early and never reaches the pop. + if self._resolver_token: + with _resolver_lock_global: + _resolver_registry.pop(self._resolver_token, None) + self._resolver_token = 0 _release_isolate() raise self.initialized = True @@ -397,7 +407,7 @@ def cleanup(self) -> None: finally: # Release the isolate ref even if destroy_engine throws, so a # throwing destroy cannot strand the isolate. - self.lib = self.isolate = self.thread = self._owner_thread = None + self.lib = self.isolate = self.thread = None self._resolver = None self._resolver_callback = None self._resolver_buffers = [] @@ -425,11 +435,13 @@ def _serialized_native_operation(self): @contextmanager def _current_thread_attachment(self, thread): - owner = getattr(self, "_owner_thread", current_thread()) - if current_thread() is owner or thread is not self.thread: + # A non-None thread is one the caller already attached (a streaming worker + # passes its own); use it as-is. Otherwise (self.thread is None for every + # synchronous call) attach a fresh thread on demand and detach when done -- + # no thread is persistently attached, so cross-thread teardown never blocks. + if thread is not None: yield thread return - attached_thread = self.attach_thread() primary_error = None try: diff --git a/native-lib/python/tests/integration/test_module_resolver.py b/native-lib/python/tests/integration/test_module_resolver.py index f28b4c40..fb305b0b 100644 --- a/native-lib/python/tests/integration/test_module_resolver.py +++ b/native-lib/python/tests/integration/test_module_resolver.py @@ -4,6 +4,7 @@ from pathlib import Path import subprocess import sys +import threading from zipfile import ZipFile import pytest @@ -346,3 +347,36 @@ def test_shared_isolate_survives_until_the_last_instance_cleans_up(): b.cleanup() assert native._isolate_ref_count == 0 assert native._isolate is None # last release tore it down + + +@pytest.mark.integration +def test_last_release_on_a_foreign_thread_does_not_hang(): + from dataweave import native + + # Initialize on a worker thread so the isolate's creating thread differs from + # the main thread that runs the final cleanup (the atexit-style ordering that + # deadlocked before Finding #1's fix). + holder = {} + def make(): + dw = dataweave.DataWeave() + dw.initialize() + holder["dw"] = dw + t = threading.Thread(target=make) + t.start() + t.join(30) + assert not t.is_alive() + dw = holder["dw"] + try: + assert native._isolate_ref_count == 1 + assert dw.run("%dw 2.0\noutput application/json\n---\n1 + 1").get_string() == "2" + finally: + # Last release runs HERE on the main thread (foreign to the creating + # worker). On the unfixed code graal_tear_down_isolate blocks forever; + # run the cleanup on a joinable thread with a timeout so a regression is a + # bounded failure instead of hanging the suite. + done = threading.Thread(target=dw.cleanup) + done.start() + done.join(30) + assert not done.is_alive(), "cleanup() hung: last-release teardown blocked on a foreign thread" + assert native._isolate_ref_count == 0 + assert native._isolate is None diff --git a/native-lib/python/tests/unit/conftest.py b/native-lib/python/tests/unit/conftest.py index da271586..49a8732f 100644 --- a/native-lib/python/tests/unit/conftest.py +++ b/native-lib/python/tests/unit/conftest.py @@ -19,6 +19,4 @@ def _reset_shared_isolate(): def _clear(): native._lib = None native._isolate = None - native._isolate_thread = None - native._isolate_owner_thread = None native._isolate_ref_count = 0 diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index a925e13d..32af3490 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -222,6 +222,11 @@ def test_buffered_worker_execution_uses_one_current_thread_attachment_for_run_de runtime = native.NativeRuntime("/tmp/dwlib") runtime.initialize() owner_ident = get_ident() + # Snapshot right after initialize(): the bootstrap create/detach and the + # attach-on-demand engine create already added entries, so we assert the + # DELTA the worker run adds rather than a brittle absolute count. + attach_count_after_init = len(library.attach_calls) + detach_count_after_init = len(library.detach_calls) outcomes = [] worker = Thread( @@ -236,9 +241,15 @@ def test_buffered_worker_execution_uses_one_current_thread_attachment_for_run_de worker_ident, result = outcomes[0] assert worker_ident != owner_ident assert result == "result" - assert runtime._owner_thread is current_thread() - assert len(library.attach_calls) == 1 - assert library.attach_calls[0][0] == worker_ident + # Exactly one attach and one detach for the whole run+decode+free, both on + # the worker's OS thread -- no owner fast-path, one attachment shared by + # run and free. + assert len(library.attach_calls) - attach_count_after_init == 1 + assert len(library.detach_calls) - detach_count_after_init == 1 + new_attach = library.attach_calls[attach_count_after_init] + new_detach = library.detach_calls[detach_count_after_init] + assert new_attach[0] == worker_ident + assert new_detach[0] == worker_ident worker_pointer = ctypes.cast(calls[0][2], ctypes.c_void_p).value assert [(name, ident) for name, ident, _thread in calls] == [ ("run", worker_ident), @@ -248,12 +259,17 @@ def test_buffered_worker_execution_uses_one_current_thread_attachment_for_run_de ctypes.cast(thread, ctypes.c_void_p).value == worker_pointer for _name, _ident, thread in calls ) - assert library.detach_calls[0][0] == worker_ident - assert ctypes.cast(library.detach_calls[0][1], ctypes.c_void_p).value == worker_pointer + assert ctypes.cast(new_attach[1], ctypes.c_void_p).value == worker_pointer + assert ctypes.cast(new_detach[1], ctypes.c_void_p).value == worker_pointer @pytest.mark.unit -def test_distinct_thread_object_attaches_when_python_thread_ident_is_reused(monkeypatch): +def test_attach_on_demand_does_not_cache_by_thread_ident(monkeypatch): + # This guarded the OLD owner-reuse-by-thread-object branch (comparing + # `current_thread() is owner` under a spoofed get_ident so a reused ident + # could not be mistaken for the owner). That branch is gone entirely: every + # call attaches on demand regardless of ident. Keep the get_ident spoof to + # prove there is no ident-keyed cache anywhere in the new path. buffer = ctypes.create_string_buffer(b"result") library = FakeLibrary() library.run_script_engine = CallableFunction( @@ -265,12 +281,15 @@ def test_distinct_thread_object_attaches_when_python_thread_ident_is_reused(monk runtime = native.NativeRuntime("/tmp/dwlib") runtime.initialize() owner_thread = current_thread() + attach_count_after_init = len(library.attach_calls) + detach_count_after_init = len(library.detach_calls) observed_threads = [] + outcomes = [] worker = Thread( target=lambda: ( observed_threads.append(current_thread()), - runtime.run_engine_and_decode(b"script", b"{}"), + outcomes.append(runtime.run_engine_and_decode(b"script", b"{}")), ) ) worker.start() @@ -279,24 +298,25 @@ def test_distinct_thread_object_attaches_when_python_thread_ident_is_reused(monk assert not worker.is_alive() assert observed_threads == [worker] assert observed_threads[0] is not owner_thread - assert runtime._owner_thread is owner_thread - assert len(library.attach_calls) == 1 - assert len(library.detach_calls) == 1 + assert outcomes == ["result"] + assert len(library.attach_calls) - attach_count_after_init == 1 + assert len(library.detach_calls) - detach_count_after_init == 1 @pytest.mark.unit -def test_cleanup_clears_owner_thread_reference(monkeypatch): +def test_cleanup_releases_isolate_ref(monkeypatch): + # _owner_thread no longer exists (attach-on-demand for every call); the + # remaining intent this test guards is that cleanup() releases the shared + # isolate ref and, on the last release, clears the module-level isolate. library = FakeLibrary() monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) runtime = native.NativeRuntime("/tmp/dwlib") runtime.initialize() - assert runtime._owner_thread is current_thread() - runtime.cleanup() - assert runtime._owner_thread is None assert native._isolate_ref_count == 0 + assert native._isolate is None @pytest.mark.unit @@ -316,15 +336,22 @@ def free_cstring(_thread, _ptr): library.run_script_engine = CallableFunction(run_script_engine) library.free_cstring = CallableFunction(free_cstring) - if failure == "detach": - library.graal_detach_thread = CallableFunction( - lambda _thread: (_ for _ in ()).throw(RuntimeError("detach failed")) - ) monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) if failure == "decode": monkeypatch.setattr(native.ctypes, "string_at", lambda _ptr: b"\xff") runtime = native.NativeRuntime("/tmp/dwlib") runtime.initialize() + # Snapshot right after initialize(): the bootstrap create/detach and the + # attach-on-demand engine create already used the library's default + # (working) detach, so the "detach" failure below is installed AFTER + # initialize() -- it must only break the worker run's own detach, not the + # unrelated bootstrap-detach call inside _acquire_isolate. + attach_count_after_init = len(library.attach_calls) + detach_count_after_init = len(library.detach_calls) + if failure == "detach": + library.graal_detach_thread = CallableFunction( + lambda _thread: (_ for _ in ()).throw(RuntimeError("detach failed")) + ) errors = [] worker = Thread( @@ -340,10 +367,10 @@ def free_cstring(_thread, _ptr): assert len(errors) == 1 if failure == "detach": assert "detach failed" in str(errors[0]) - assert len(library.attach_calls) == 1 + assert len(library.attach_calls) - attach_count_after_init == 1 if failure != "detach": - assert len(library.detach_calls) == 1 - assert library.detach_calls[0][0] == library.attach_calls[0][0] + assert len(library.detach_calls) - detach_count_after_init == 1 + assert library.detach_calls[detach_count_after_init][0] == library.attach_calls[attach_count_after_init][0] def _capture_error(errors, invoke): @@ -364,6 +391,12 @@ def test_cleanup_from_worker_uses_current_thread_for_isolate_teardown(monkeypatc runtime = native.NativeRuntime("/tmp/dwlib") runtime.initialize() owner_ident = get_ident() + # Snapshot right after initialize(): the bootstrap create/detach and the + # attach-on-demand engine create already added entries on this (main) + # thread's ident, so we assert the DELTA the worker cleanup() adds rather + # than a brittle absolute count. + attach_count_after_init = len(library.attach_calls) + detach_count_after_init = len(library.detach_calls) worker = Thread(target=runtime.cleanup) worker.start() @@ -372,20 +405,27 @@ def test_cleanup_from_worker_uses_current_thread_for_isolate_teardown(monkeypatc assert not worker.is_alive() worker_ident, teardown_thread = teardown_calls[0] assert worker_ident != owner_ident - # Off-owner cleanup now attaches/detaches its own thread for destroy_engine - # (attach_calls[0]), then the isolate teardown attaches a second, separate - # thread for graal_tear_down_isolate (attach_calls[1]); teardown itself + # This is the structural guard for Finding #1: the isolate was created on + # the main thread, but the bootstrap thread was detached immediately after + # create, so teardown on a completely different (worker) thread does not + # block on a phantom attachment. Off-owner cleanup attaches/detaches its + # own thread for destroy_engine (the first post-init attach), then the + # isolate teardown attaches a second, separate thread for + # graal_tear_down_isolate (the second post-init attach); teardown itself # never explicitly detaches (tearing down the isolate implicitly does). - assert len(library.attach_calls) == 2 - assert library.attach_calls[0][0] == worker_ident - assert library.attach_calls[1][0] == worker_ident + assert len(library.attach_calls) - attach_count_after_init == 2 + destroy_attach = library.attach_calls[attach_count_after_init] + teardown_attach = library.attach_calls[attach_count_after_init + 1] + assert destroy_attach[0] == worker_ident + assert teardown_attach[0] == worker_ident assert ctypes.cast(teardown_thread, ctypes.c_void_p).value == ctypes.cast( - library.attach_calls[1][1], ctypes.c_void_p + teardown_attach[1], ctypes.c_void_p ).value - assert len(library.detach_calls) == 1 - assert library.detach_calls[0][0] == worker_ident - assert ctypes.cast(library.detach_calls[0][1], ctypes.c_void_p).value == ctypes.cast( - library.attach_calls[0][1], ctypes.c_void_p + assert len(library.detach_calls) - detach_count_after_init == 1 + new_detach = library.detach_calls[detach_count_after_init] + assert new_detach[0] == worker_ident + assert ctypes.cast(new_detach[1], ctypes.c_void_p).value == ctypes.cast( + destroy_attach[1], ctypes.c_void_p ).value assert runtime.initialized is False @@ -490,16 +530,37 @@ def graal_detach_thread(self, _thread): @pytest.mark.unit def test_engine_create_and_destroy_off_owner_thread_use_an_attached_thread(monkeypatch): + # native._isolate_thread is gone: there is no persistent bootstrap thread to + # compare against anymore (it is detached immediately after + # graal_create_isolate). The new intent is that engine create/destroy always + # run on a freshly *attached* thread, and different OS threads use different + # attachments. + # + # NOTE on comparison strategy: FakeLibrary's graal_attach_thread stub writes + # a brand-new, always-NULL ctypes pointer into its out-param on every call + # (there is no real native memory backing it here), so casting to + # ctypes.c_void_p and comparing .value is always None == None / None != None + # is always False -- vacuous regardless of correctness. Object identity + # (`is`/`is not`) IS meaningful here: attach_thread() allocates a distinct + # Python pointer object on every invocation, so two *different* attaches are + # guaranteed to be different objects, while the (now-removed) bug reused the + # exact SAME object across calls. We anchor on identity + OS thread ident. library = FakeLibrary() monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - # A initializes on THIS (owner) thread -> create uses the owner isolate thread. + # A initializes on THIS thread -> create_engine runs on a freshly attached + # thread (the bootstrap thread from graal_create_isolate was already + # detached inside _acquire_isolate and is never reused for engine create; + # exactly one attach is added by a.initialize(), used for the create call). a = native.NativeRuntime("/tmp/dwlib") a.initialize() - owner_thread_ptr = native._isolate_thread - assert library.create_engine_threads[0] is owner_thread_ptr + owner_ident = get_ident() + assert len(library.attach_calls) == 1 + assert library.attach_calls[0][0] == owner_ident + a_create_thread = library.create_engine_threads[0] - # B initializes on a DIFFERENT OS thread -> must attach a fresh thread. + # B initializes on a DIFFERENT OS thread -> attaches its own fresh thread + # there, distinct from A's. errors = [] b = native.NativeRuntime("/tmp/dwlib") @@ -513,9 +574,17 @@ def init_b(): t.start() t.join(2) assert not errors - assert library.create_engine_threads[1] is not owner_thread_ptr - - # Destroy B from a non-owner thread -> likewise attaches, not owner ptr. + assert len(library.attach_calls) == 2 + assert library.attach_calls[1][0] == t.ident + assert library.attach_calls[1][0] != owner_ident + b_create_thread = library.create_engine_threads[1] + # A freshly attached thread is never the SAME object as a previous one -- + # this is exactly how the (now-removed) reused-bootstrap/owner-thread bug + # would have shown up: B's create thread being the literal object A used. + assert b_create_thread is not a_create_thread + + # Destroy B from yet another non-owner thread -> attaches its own thread, + # matching that thread's ident, and it is a fresh object too. def cleanup_b(): try: b.cleanup() @@ -526,7 +595,10 @@ def cleanup_b(): t2.start() t2.join(2) assert not errors - assert library.destroy_engine_threads[-1] is not owner_thread_ptr + assert len(library.attach_calls) == 3 + assert library.attach_calls[2][0] == t2.ident + assert library.destroy_engine_threads[-1] is not a_create_thread + assert library.destroy_engine_threads[-1] is not b_create_thread a.cleanup() @@ -615,3 +687,70 @@ def worker(): assert results and ctypes.string_at(results[0]) == b"src" a.cleanup() + + +@pytest.mark.unit +def test_bootstrap_thread_is_detached_after_isolate_create(monkeypatch): + # Regression (final review Finding #1): the isolate's bootstrap thread must be + # detached immediately after graal_create_isolate, before anything attaches a + # fresh thread for engine creation. So a last release on a different OS + # thread can tear down without blocking on a phantom attachment. + # + # NOTE on comparison strategy: FakeLibrary's stubs write NULL pointers into + # their out-params (there is no real native memory backing them here), so + # pointer VALUES (and even object identity, since nothing ever aliases the + # bootstrap thread object across calls) cannot distinguish "the bootstrap + # thread" from a later attach. What CAN be checked -- and is exactly what + # Finding #1 is about -- is call ORDER: detach must happen immediately + # after create_isolate, strictly before the attach used for engine create. + library = FakeLibrary() + events = [] + + def create_isolate(_params, _isolate, _thread_out): + events.append("create_isolate") + return 0 + + def detach_thread(_thread): + events.append("detach") + return 0 + + def attach_thread(isolate, thread_out): + events.append("attach") + return library._attach_thread(isolate, thread_out) + + library.graal_create_isolate = CallableFunction(create_isolate) + library.graal_detach_thread = CallableFunction(detach_thread) + library.graal_attach_thread = CallableFunction(attach_thread) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + runtime = native.NativeRuntime("/tmp/dwlib") + runtime.initialize() + + assert events[:2] == ["create_isolate", "detach"], ( + "bootstrap thread was not detached immediately after graal_create_isolate" + ) + assert "attach" in events[2:], "engine create never attached its own thread" + assert events.index("attach") > events.index("detach"), ( + "engine create attached before the bootstrap thread was detached" + ) + runtime.cleanup() + + +@pytest.mark.unit +def test_failed_init_with_resolver_unregisters_the_token(monkeypatch): + library = FakeLibrary() + library.create_engine_with_resolver = CallableFunction( + lambda _thread, _cb, _ctx: (_ for _ in ()).throw(RuntimeError("boom")) + ) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + runtime = native.NativeRuntime("/tmp/dwlib") + runtime.install_resolver(lambda path: "src") + token = runtime._resolver_token + assert native._resolver_registry.get(token) is runtime + with pytest.raises(native.DataWeaveError): + runtime.initialize() + + assert token not in native._resolver_registry + assert native._isolate_ref_count == 0 + assert native._isolate is None diff --git a/native-lib/python/tests/unit/test_streaming.py b/native-lib/python/tests/unit/test_streaming.py index c6e4e7b2..ee00ad78 100644 --- a/native-lib/python/tests/unit/test_streaming.py +++ b/native-lib/python/tests/unit/test_streaming.py @@ -1,6 +1,6 @@ import ctypes from queue import Full, Queue -from threading import current_thread, Event, Lock, Thread +from threading import Event, Lock, Thread from time import sleep import pytest @@ -78,8 +78,10 @@ def configured_runtime(native): native_runtime.has_callback_input_output = True native_runtime.lib = native native_runtime.isolate = object() - native_runtime.thread = object() - native_runtime._owner_thread = current_thread() + # No persistent attachment (attach-on-demand): every synchronous call -- + # including cleanup() -- attaches its own thread via the FakeNative's + # graal_attach_thread/graal_detach_thread. + native_runtime.thread = None native_runtime.handle = 1 native_runtime._resolver = None native_runtime._resolver_callback = None From 1d996e66288225db9bdee85513c84aa0d85ecc37 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 19:33:06 -0300 Subject: [PATCH 148/216] docs: align teardown-failure + Python-isolation notes with the shipped impl (final review #3) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-08-07-native-lib-multi-engine-design.md | 3 ++- .../2026-08-26-python-multi-engine-unification-design.md | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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 index 854951d1..2683d8a7 100644 --- 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 @@ -90,7 +90,8 @@ since been unified onto the shared-isolate + handle model; see the superseded-no ## 5. Alternatives Considered (isolation mechanism) **Separate GraalVM isolates per engine (rejected).** The most complete isolation (own heap, own -JIT, own Java statics), and what Python does per-instance. Rejected for Node because `addon.c` +JIT, own Java statics), and what Python originally did per-instance (before the 2026-08-26 +unification moved Python onto this shared-isolate model too). Rejected for Node because `addon.c` assumed exactly one isolate as global state; supporting N isolates means restructuring all of that into per-handle structs, and isolate teardown is fragile (`graal_tear_down_isolate` blocks until every attached thread reaches a safepoint). It is unnecessarily heavy for the actual need: diff --git a/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md b/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md index 4ff431d4..9e343adb 100644 --- a/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md +++ b/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md @@ -227,9 +227,11 @@ dwB.cleanup() → join workers; destroy_engine(handleB); ref 1→0 → detach m cleared); the C layer never sees a stale handle. - **`destroy_engine` throws during `cleanup()`** → still release the isolate ref (so a throwing destroy cannot strand the isolate), then re-raise — mirrors Node's `doCleanup()`. -- **`graal_tear_down_isolate` returns nonzero** → raise `DataWeaveError`, but leave `_isolate` set - with count 0; the next `initialize()` reuses that live isolate (count 0→1). No retry flag needed - — there is no event loop to defer to. +- **`graal_tear_down_isolate` returns nonzero** → surface a warning and re-raise `DataWeaveError`, + and clear the isolate globals (`_lib`/`_lib_path`/`_isolate` → None, count already 0) so the next + `initialize()` builds a fresh isolate rather than reusing one whose teardown just failed. No retry + flag needed — there is no event loop to defer to. (The implementation nulls the globals in a + `finally` after re-raising; the failed teardown is reported on `stderr`.) ## 10. Backward compatibility (all intended, pre-GA, no shims) From ee802eb2b4459dc392f8317bc0a47b1f0bd2eb64 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Wed, 26 Aug 2026 20:12:28 -0300 Subject: [PATCH 149/216] docs: consolidate Node + Python multi-engine designs into one spec Fold the Python-unification design (2026-08-26) into the canonical 2026-08-07 native-lib multi-engine design, retitled to cover both bindings. Removes the superseded/no-longer-true Python hedges and states the unified shipped reality directly: shared refcounted isolate + N handle-addressed engines driven by both bindings through the identical *_engine C ABI. Adds first-class Python sections (unification rationale, reuse boundary, attach-on-demand lifecycle & teardown reflecting the final-review bootstrap-detach fix, error handling, testing) and the shared six-invariant engine lifecycle contract. Deletes the now-merged 2026-08-26 doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-08-07-native-lib-multi-engine-design.md | 450 ++++++++++++------ ...-python-multi-engine-unification-design.md | 301 ------------ 2 files changed, 305 insertions(+), 446 deletions(-) delete mode 100644 docs/superpowers/specs/2026-08-26-python-multi-engine-unification-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 index 2683d8a7..2afaaaad 100644 --- 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 @@ -1,40 +1,34 @@ -# Design: Multiple Isolated DataWeave Engines per Process (native-lib, Node) +# Design: Multiple Isolated DataWeave Engines per Process (native-lib — Node & Python) -**Date:** 2026-08-07 (consolidated 2026-08-25) +**Date:** 2026-08-07 (consolidated 2026-08-25; unified Node + Python 2026-08-26) **Status:** Approved and implemented on `w-23692110-multi-engine-design` (PR #157) **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) -> **About this document.** This is the single, consolidated design for the multi-engine Node -> binding. It describes the **final state** of the feature as shipped on PR #157. The core -> feature (object-level engines behind opaque handles) is unchanged from the original design; -> the substantial addition is the **concurrency & lifecycle model** (§6), which was hardened -> across a long series of code reviews. Those hardening decisions are folded into the relevant -> sections here rather than kept as separate per-round documents; a provenance map for git -> archaeology lives in the [Appendix](#appendix-hardening-provenance). The product-facing -> `DataWeave` class is pre-GA, so several internal contracts (async `cleanup()`, the removed -> `*_with_resolver` C ABI) changed during hardening without a compatibility ceremony. - -> **Superseded Python notes (update 2026-08-26).** This document is the *Node* design and -> assumed the Python binding would stay on the old isolate-per-instance model behind the -> retained legacy singleton entrypoints (`ScriptRuntime.getInstance()` + `run_script` / -> `run_script_callback` / `run_script_input_output_callback`). That is no longer true: the -> `ScriptRuntime` singleton and all three legacy C entrypoints have been **removed**, and both -> the Node and Python bindings now drive the *same* shared-isolate + handle-addressed-engine -> model through the identical `*_engine` C ABI. Python uses a module-level reference-counted -> isolate with one engine handle per `DataWeave` instance. The Python-specific claims flagged -> inline below are corrected in place; see -> [docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md](./2026-08-26-python-multi-engine-unification-design.md) -> for the design that superseded them. The Node sections remain accurate as shipped. +> **About this document.** This is the single, consolidated design for the multi-engine +> `native-lib` feature across **both** consumer bindings — Node and Python. It describes the +> **final state** as shipped on PR #157. The core feature (object-level engines behind opaque +> handles, one shared GraalVM isolate) is common to both bindings and is driven through the +> identical `*_engine` C ABI. Two substantial bodies of work are folded in here rather than kept +> as separate documents: the Node **concurrency & lifecycle model** (§6), hardened across a long +> series of code reviews, and the **Python unification** (§7) that removed the `ScriptRuntime` +> singleton and moved Python off its former isolate-per-instance model onto the shared model. +> A provenance map for git archaeology lives in the [Appendix](#appendix-hardening-provenance). +> The product-facing `DataWeave` classes are pre-GA, so several internal contracts (async Node +> `cleanup()`, the removed `*_with_resolver` and legacy-singleton C ABI) changed during this work +> without a compatibility ceremony. ## 1. 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. Before this change the -second `new DataWeave({ resolveModule })` in a process silently kept the first instance's -resolver. The isolation must hold with instances living in different Worker threads and being -created, run, and torn down concurrently, without leaking native resources or wedging the -shared GraalVM isolate. +Let multiple `DataWeave` instances coexist in one process — in **either** binding — each with its +own module resolver and script cache, so that different resolvers never collide. Before this +change the second `new DataWeave({ resolveModule })` in a Node process silently kept the first +instance's resolver, and Python achieved isolation only by paying for a whole GraalVM isolate per +instance. The isolation must hold with instances created, run, and torn down concurrently +(including across Node Worker threads and Python worker threads), without leaking native resources +or wedging the shared GraalVM isolate. A secondary goal, realized by the 2026-08-26 unification, is +that both bindings drive **one** shared Java engine layer through the **same** C ABI, so there is a +single mental model and a single source of truth to maintain. ## 2. Background @@ -49,68 +43,94 @@ resolver-backed `run()` first won. (`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 — which *at the time of this design* used one GraalVM isolate per `DataWeave()` -instance — got resolver isolation "for free." The limitation was specific to `native-lib`'s -deliberate Java static singleton plus the Node C addon's global resolver bridge. (Python has -since been unified onto the shared-isolate + handle model; see the superseded-notes banner above.) +Python binding — which **originally** used one GraalVM isolate per `DataWeave()` instance — got +resolver isolation "for free." The limitation was specific to `native-lib`'s deliberate Java static +singleton plus the Node C addon's global resolver bridge. + +**Why unification followed.** The Node change (this PR's original scope) fixed Node but, for +backward compatibility, kept the `ScriptRuntime` singleton (`getInstance()`/`defaultInstance`) and +three legacy singleton C entrypoints (`run_script`, `run_script_callback`, +`run_script_input_output_callback`) because the Python binding still used them. A later rebase +exposed a collision: master had shipped a Python module-resolver feature calling a +`run_script_with_resolver` entrypoint that this branch's ABI redesign removed. Rather than maintain +two isolation models (Python isolate-per-instance vs. Node shared-isolate + handles) and a +compatibility shim, the maintainer chose to **unify**: remove the singleton entirely and put both +bindings on the shared-isolate + handle-addressed-engine model through the identical `*_engine` +ABI (§7). ## 3. 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. + registry of instances, each with its own engine + resolver; **remove** `getInstance()` / + `defaultInstance` so `ScriptRuntime` is purely handle-addressed. - Node C addon (`native-lib/node/src/addon.c`): per-handle resolver bridge state instead of one process-global bridge, plus the concurrency & lifecycle machinery in §6. - Node TypeScript layer (`ffi.ts`, `dataweave.ts`, `stream.ts`, `reader.ts`): each `DataWeave` instance owns an engine handle for its whole lifecycle. +- Python layer (`native-lib/python/src/dataweave/{native,runtime,models}.py`): move off + isolate-per-instance and off the legacy singleton onto a module-level reference-counted shared + isolate with one engine handle per `DataWeave` instance (§7). The public Python API is unchanged. -**Out of scope (as of this Node design):** -- Python binding changes. Python already achieved isolation via one isolate per instance; - unifying it onto the same handle-based API was left as a follow-up. **(Since completed — - see the superseded-notes banner above and the 2026-08-26 Python unification design.)** +**Out of scope:** - Separate GraalVM isolates per engine — rejected as the isolation mechanism (see §5). - Solving streaming/transform + **custom-module** resolution across the background-thread boundary. Streaming against a resolver-backed engine still fails closed (returns "not found") - for custom modules reached from a background worker thread; built-in modules continue to - resolve normally in all cases. This is a pre-existing, documented hazard, not introduced here. + for custom modules reached from a background worker thread, in **both** bindings; built-in + modules continue to resolve normally in all cases. This is a pre-existing, documented hazard, + deliberately kept identical across bindings, not introduced here. ## 4. Definitions -- **Isolate** — the single process-wide GraalVM isolate. All engines share it. Its lifetime is - governed by `g_ref_count` (§6.1). +- **Isolate** — the single process-wide GraalVM isolate. All engines share it. In Node its lifetime + is governed by `g_ref_count` (§6.1); in Python by `_isolate_ref_count` (§7). - **Engine** — a `ScriptRuntime` Java object (own resolver + compiled-script cache) addressed by an opaque `long long` handle. Many engines per isolate. -- **Init reference** — the `g_ref_count` unit an env acquires on each `initialize()` and releases - on the matching `cleanup()` (or on env death). Distinct from an engine handle. +- **Init reference / isolate ref** — the reference-count unit a binding acquires per engine on + `initialize()` and releases on `cleanup()` (or on owner death). Distinct from an engine handle. - **Op** — one in-flight `run()`/`runStreaming()`/`runTransform()` native call. -- **Owner env / owner thread** — the `napi_env` (and its JS thread) that created a given engine +- **Owner env / owner thread** — the `napi_env` (and its JS thread) that created a given Node engine or init reference. `napi_env`/`napi_ref`/`napi_deferred`/`napi_threadsafe_function` are - thread-affine; env-affine calls only ever happen on the owner thread. + thread-affine; env-affine calls only ever happen on the owner thread. (Python has no `napi_env`; + its thread model is §7.) ## 5. Alternatives Considered (isolation mechanism) **Separate GraalVM isolates per engine (rejected).** The most complete isolation (own heap, own -JIT, own Java statics), and what Python originally did per-instance (before the 2026-08-26 -unification moved Python onto this shared-isolate model too). Rejected for Node because `addon.c` -assumed exactly one isolate as global state; supporting N isolates means restructuring all of -that into per-handle structs, and isolate teardown is fragile (`graal_tear_down_isolate` blocks -until every attached thread reaches a safepoint). It is unnecessarily heavy for the actual need: -independent module resolution and script caching, not full JVM-level sandboxing. - -**Chosen: object-level engines in one shared isolate.** Multiple `ScriptRuntime` Java objects, -each with its own resolver and compiled-script cache, all in the single existing GraalVM isolate, -addressed by an opaque handle. Mirrors what `native-cli` already does and requires no change to -isolate lifecycle management for the *feature* — though it does require the careful -reference-and-teardown coordination in §6, because now the isolate is shared by independently -created and destroyed engines across threads. - -## 6. Concurrency & Lifecycle Model - -This section is the heart of the design. It governs how the shared isolate, per-engine registry -entries, and in-flight ops coordinate so that no thread ever attaches to, executes on, or -resolves a module against a torn-down isolate or a freed engine record, and no native resource -leaks — under concurrent creation, execution, abandonment (env death without `cleanup()`), and -teardown across Worker threads. +JIT, own Java statics), and what Python originally did per-instance. Rejected as the unifying model +for two reasons: + +- **Node cannot cheaply move to isolate-per-engine.** `addon.c` assumed exactly one isolate as + global state; supporting N isolates means restructuring all of that into per-handle structs, and + isolate teardown is fragile — `graal_tear_down_isolate` blocks until every attached thread + reaches a safepoint, and Node's streaming workers deliver chunks via a `napi_threadsafe_function` + that needs the libuv event loop to keep running. Multiplying that per-isolate is strictly worse + and discards the hardening work in §6. +- It is unnecessarily heavy for the actual need: independent module resolution and script caching, + not full JVM-level sandboxing. + +**Chosen: object-level engines in one shared isolate (for both bindings).** Multiple `ScriptRuntime` +Java objects, each with its own resolver and compiled-script cache, all in the single existing +GraalVM isolate, addressed by an opaque handle. Mirrors what `native-cli` already does and requires +no change to isolate lifecycle management for the *feature*. Node requires the careful +reference-and-teardown coordination in §6 because the isolate is shared by independently created and +destroyed engines across threads. **Python can adopt the same model trivially**: its ctypes calls +are synchronous and it owns its stream-worker threads directly, so it needs none of Node's +`PENDING_WAIT`/adoption/retry machinery — just a reference count and a synchronous +drain-before-teardown (§7). + +**Accepted trade-off (Python).** Python instances in one process now share one isolate's heap +instead of having separate heaps. This is weaker memory isolation, relevant only if +mutually-untrusted scripts run in one process expecting heap-level separation. The maintainer +accepted this in exchange for a single maintained model. + +## 6. Node Concurrency & Lifecycle Model + +This section governs how the shared isolate, per-engine registry entries, and in-flight ops +coordinate in the **Node** binding so that no thread ever attaches to, executes on, or resolves a +module against a torn-down isolate or a freed engine record, and no native resource leaks — under +concurrent creation, execution, abandonment (env death without `cleanup()`), and teardown across +Worker threads. (Python's simpler model is §7.) All shared C state is read and written **only under `g_mutex`**, with two documented exceptions: the cheap top-of-function `!g_initialized` fast-path read (a benign optimization; the @@ -345,35 +365,112 @@ because a boolean cannot represent the window during which `cleanup()` has start `startRejected` boolean, not a value sentinel, so `Promise.reject(undefined)` propagates correctly. -## 7. Architecture (layer map) - -### Layer 1 — Java (`native-lib/src/main/java/org/mule/weave/lib/`) +## 7. Python Lifecycle & Teardown Model + +Python drives the **same** shared Java engine layer and the **same** `*_engine` C ABI as Node, but +its isolate/thread glue (`native-lib/python/src/dataweave/native.py`) is much simpler than §6: +ctypes calls are synchronous and Python owns its stream-worker threads directly, so it needs none +of Node's `PENDING_WAIT`/adoption/retry machinery — just a reference count and a synchronous +drain-before-teardown. The **public Python API is unchanged** by the unification. + +### 7.1 Shared state and the reference-count invariant + +Module-level state in `native.py`, all mutations under one module lock (`_isolate_lock`): +`_lib`, `_lib_path`, `_isolate` (the single process-wide isolate, or None), `_isolate_ref_count`. + +> **Invariant:** `_isolate_ref_count` == number of live engines across all `DataWeave` instances, +> and the isolate exists iff the count > 0. + +Each `DataWeave` instance owns exactly one engine handle and contributes exactly one to the +refcount. The module lock guards only isolate refcount/create/teardown; it is **not** held during +script execution, so one engine's long-running script never blocks another engine's +`initialize()`/`run()`. Different instances can run concurrently, each on its own attached thread in +the shared isolate. + +### 7.2 No persistent isolate-thread attachment (attach-on-demand) + +`graal_tear_down_isolate` blocks forever waiting for every *other* GraalVM-attached thread to reach +a safepoint. If the isolate's creating ("bootstrap") thread stayed attached for the isolate's life, +a last-release teardown running on a *different* OS thread — e.g. an `atexit`/interpreter-shutdown +cleanup on the main thread after the first `run()` happened on a worker, or two instances torn down +from different threads — would block forever. The binding therefore holds **no persistent +attachment**, mirroring the Node and Go bindings: + +- **`_acquire_isolate`** (first ref): `graal_create_isolate`, then **immediately + `graal_detach_thread` on the bootstrap thread** (a nonzero return is surfaced as a + `DataWeaveError`). No IsolateThread is retained. +- **Every synchronous native call** (`run`, `run_callback`, `run_input_output_callback`, + `create_engine[_with_resolver]`, `destroy_engine`) attaches a **fresh** thread on demand, uses it + for the whole call, and detaches it when done (`_current_thread_attachment`). A stream-worker + thread that has already attached its own IsolateThread passes it through unchanged. +- **`_release_isolate`** (last ref): attaches a fresh thread solely to call + `graal_tear_down_isolate`, then clears the globals. + +Because nothing stays attached between calls, teardown never blocks on a phantom attachment +regardless of which OS thread performs the last release. + +### 7.3 Instance lifecycle + +- **`initialize()`** — under the lock, `_acquire_isolate` (create-on-first-ref + bootstrap detach, + `_isolate_ref_count += 1`); then `create_engine()` or `create_engine_with_resolver(ctx, trampoline)`, + storing the returned `handle` on the instance. If `create_engine` fails after the isolate ref was + taken, the instance releases the ref (tearing down if it was the only one) and — when a resolver + was installed before `initialize()` — unregisters its resolver token, so a failed init leaks + nothing (neither an isolate ref nor a `_resolver_registry` entry). +- **`run` / `run_streaming` / `run_callback` / `run_transform`** — route through the `*_engine` + entrypoints with the instance's `handle`, per §7.2's attachment rules. Per-instance execution is + serialized (`_serialized_native_operation`); different instances run concurrently. +- **`cleanup()`** — drain *this instance's* stream workers (signal cancel + **join** the threads; + synchronous, Python owns them, so no event loop and no deadlock); `destroy_engine(handle)`; remove + the resolver-map entry; clear the instance handle; then release the isolate ref (`-= 1`), tearing + the isolate down on the last release. `cleanup()` on an uninitialized/already-cleaned instance is a + no-op; double-`cleanup()` releases the ref only once (guarded by the instance handle being + cleared). If `destroy_engine` throws, the isolate ref is still released so a throwing destroy + cannot strand the isolate; the error is re-raised after the release. + +**Why this stays simple:** teardown happens only on the *last* release, by which point every +instance has already joined its own workers, so the isolate has no attached worker threads when +`graal_tear_down_isolate` runs. + +### 7.4 Resolver dispatch and the streaming/resolver hazard + +- `create_engine_with_resolver` passes an opaque `ctx` (a Python-allocated monotonic token + registered in `_resolver_registry[token] = self` *before* the create call, so no resolve callback + can fire for a handle before its map entry exists). Python registers **one** C trampoline + (`RESOLVE_MODULE_CALLBACK`); GraalVM calls it with `(thread, ctx, module_path)`, and it dispatches + to the engine's Python resolver via the registered token, returning the source-buffer pointer. + This is the Python analog of Node's per-handle bridge — same `ctx` concept, identical Java/ABI + side. +- **Streaming / transform + custom modules — parity with Node (out of scope):** the trampoline + resolves custom modules only when invoked on the engine's owner thread and **fails closed** + ("not found") on a background stream-worker thread. Built-in modules resolve normally everywhere; + synchronous `run()` with a resolver resolves custom modules fully. This is a conservative parity + choice (identical behavior across bindings), not a hard Python limitation. + +## 8. Architecture (layer map) + +### Layer 1 — Java (`native-lib/src/main/java/org/mule/weave/lib/`) — shared by both bindings - **`ScriptRuntime.java`** — from static singleton to per-instance + a `ConcurrentHashMap` registry with `register`/`get`/`destroy` and an `AtomicLong` handle allocator. The resolver is bound once at construction (immutable for the instance's lifetime); the `static setResolver` write-once mutation is removed. `compositeResolver()` / `createModuleComponentsFactory()` become instance methods. - *(As originally shipped, `getInstance()` was kept returning a lazily-created default - instance so the resolver-less legacy entrypoints used by Python stayed untouched. Both - `getInstance()` and those legacy entrypoints have since been **removed** — Python now uses - the handle-addressed `*_engine` ABI. See the superseded-notes banner above.)* + `getInstance()` / `defaultInstance` are **removed** — `ScriptRuntime` is purely handle-addressed. - **`CallbackWeaveResourceResolver.java`** — stores a `PointerBase ctx` alongside the callback, forwarded on every `callback.invoke(...)`; constructor `(ResolveModuleCallback, PointerBase ctx)`. -- **`NativeCallbacks.java`** — `ResolveModuleCallback` gains a `ctx` parameter +- **`NativeCallbacks.java`** — `ResolveModuleCallback` is the 3-arg ctx form (`invoke(IsolateThread, PointerBase ctx, CCharPointer modulePath)`), mirroring the existing `WriteCallback`/`ReadCallback` ctx idiom. This is what lets one shared native callback dispatch - to the correct per-handle JS resolver on the C side. -- **`NativeLib.java`** — adds handle-based lifecycle + execution entrypoints (`create_engine`, - `create_engine_with_resolver`, `destroy_engine`, `run_script_engine`, + to the correct per-handle resolver on the C/Python side. The old 2-arg form is gone. +- **`NativeLib.java`** — exposes only the handle-based lifecycle + execution entrypoints + (`create_engine`, `create_engine_with_resolver`, `destroy_engine`, `run_script_engine`, `run_script_callback_engine`, `run_script_input_output_callback_engine`) resolving via - `ScriptRuntime.get(handle)`. *(As originally shipped, the legacy singleton entrypoints - (`run_script`, `run_script_callback`, `run_script_input_output_callback`) were preserved - unchanged for Python; they have since been **removed** — Python now consumes the `*_engine` - set too. See the superseded-notes banner above.)* The old `*_with_resolver` entrypoints are - **removed** (see §9). + `ScriptRuntime.get(handle)`. The three legacy singleton entrypoints (`run_script`, + `run_script_callback`, `run_script_input_output_callback`) and the old `*_with_resolver` + entrypoints are **removed** (see §10). -### Layer 2 — C addon (`native-lib/node/src/addon.c`) +### Layer 2 — Node C addon (`native-lib/node/src/addon.c`) - Per-handle resolver bridge state in `g_bridges` (§6.3) instead of a process-global bridge. - **Resolver dispatch:** `createEngineWithResolver` passes the bridge record's address as the @@ -384,15 +481,15 @@ because a boolean cannot represent the window during which `cleanup()` has start found" if `resolve_module_callback` is reached from a non-owner thread (e.g. a streaming worker). - All of §6's machinery: `g_active_ops`, the `TEARDOWN_*` state machine, `g_teardown_cancelled`, `g_teardown_needed`, the per-env `g_env_recs` list, the `g_bridges` list, admission pinning, and - the split finalize. + the split finalize. The legacy `dw_napi_run_script` path and its `run_script` dlsym are removed. - N-API methods: `createEngine`, `createEngineWithResolver`, `destroyEngine`, and handle-taking `runScriptEngine`, `runScriptStreamingEngine`, `runScriptTransformEngine`. ### Layer 3 — Node TypeScript (`native-lib/node/src/`) - **`ffi.ts`** — `createEngine`, `createEngineWithResolver`, `destroyEngine`, and handle-taking - `runScriptEngine`, `runScriptStreamingEngine`, `runScriptTransformEngine`. `runWithResolver` - removed. + `runScriptEngine`, `runScriptStreamingEngine`, `runScriptTransformEngine`. `runScript` / + `runWithResolver` removed. - **`dataweave.ts`** — `DataWeave` owns a `private engineHandle`, the three-state lifecycle machine, and the module-level singleton/exit-hook/coalescing logic (§6.4). `initialize()` calls `ffi.createEngineWithResolver(this.resolveModule)` or `ffi.createEngine()`; run methods route @@ -402,8 +499,20 @@ because a boolean cannot represent the window during which `cleanup()` has start `createChunkReader` pre-buffers async inputs (the native read callback is synchronous and cannot await), which is why `runTransform` re-checks readiness after it. -## 8. Data Flow +### Layer 4 — Python (`native-lib/python/src/dataweave/`) + +- **`native.py` (`NativeRuntime`)** — the shared-model glue (§7): module-level refcounted isolate, + attach-on-demand thread handling, the 3-arg ctx resolver trampoline + `_resolver_registry`, and + the `*_engine` + `create_engine[_with_resolver]` + `destroy_engine` symbol bindings. +- **`runtime.py` (`DataWeave`)** — `initialize()` acquires an isolate ref + creates one engine and + stores its `handle`; run methods route through the `*_engine` entrypoints with that handle; + `cleanup()` drains this instance's stream workers, `destroy_engine(handle)`, releases the ref. + The public API surface is unchanged. +- **`models.py`** — `RESOLVE_MODULE_CALLBACK` ctypes signature carries the `ctx` argument. + +## 9. Data Flow +**Node:** ``` new DataWeave({ resolveModule: A }).initialize() → ffi.initialize() // env init record for this env: init_refs 0→1, g_ref_count++ @@ -425,69 +534,115 @@ new DataWeave({ resolveModule: B }).initialize() → handle_B, bridge_B (indep dwB.run(...) → resolves via resolver B only; A's cache untouched; no cross-talk ``` -## 9. Error Handling & Backward Compatibility +**Python:** +``` +dwA = DataWeave(resolve_module=A); dwA.initialize() + → lock: _isolate None → graal_create_isolate() + detach bootstrap thread; ref 0→1 + → create_engine_with_resolver(ctx=tokenA, trampoline); registry[tokenA]=dwA; dwA._handle = handleA + +dwB = DataWeave(resolve_module=B); dwB.initialize() + → lock: _isolate exists → reuse; ref 1→2 + → create_engine_with_resolver(ctx=tokenB, trampoline); registry[tokenB]=dwB -- **Module not found / resolver throws:** resolver returns `null` → composite resolver falls - through → standard DataWeave "unable to resolve module" error (unchanged, scoped per-handle). -- **Wrong-thread resolver invocation:** per-handle `owner` check fails closed to "not found" - rather than touching `napi_env` cross-thread. +dwA.run("... import custom/lib ...") + → attach a fresh thread on demand → run_script_engine(handleA, script, inputs) → detach + → Java engine A: ClassLoader miss → callback(thread, ctx=tokenA, "custom/lib") + → trampoline: registry[tokenA] → resolver A → source; A's cache used, B untouched + +dwA.cleanup() → join dwA workers; destroy_engine(handleA); ref 2→1 (isolate stays) +dwB.cleanup() → join workers; destroy_engine(handleB); ref 1→0 → attach fresh thread + graal_tear_down_isolate(); _isolate=None +``` + +## 10. Error Handling & Backward Compatibility + +- **Module not found / resolver throws:** resolver returns `null`/non-str → composite resolver + falls through → standard DataWeave "unable to resolve module" error (unchanged, scoped + per-handle). +- **Wrong-thread resolver invocation:** per-handle/per-token `owner` check fails closed to "not + found" rather than touching the host callback cross-thread — identical in both bindings. - **Invalid/unknown/destroyed handle:** `ScriptRuntime.get(handle)` returns null → the entrypoint returns `{"success":false,"error":"Unknown engine handle"}` (resolved for async ops, returned as - the JSON string for sync `run()`), never an NPE. -- **Admission / argument / allocation failures:** synchronous `napi_throw_error` (generic Error); - worker-thread OOM → terminal error JSON. Never `napi_reject_deferred` (absent from `addon.c`). -- **Python binding:** *(as of this Node design)* zero changes — it never called the removed - `*_with_resolver` entrypoints and continued on `getInstance()`. **(No longer true: Python has - since been ported to the handle-addressed `*_engine` ABI and `getInstance()` is gone — see the - superseded-notes banner above.)** -- **Node, resolver-less / single-resolver usage:** behaves identically; the new code path is a - functional superset. + the JSON string for sync `run()`), never an NPE/crash. +- **Node admission / argument / allocation failures:** synchronous `napi_throw_error` (generic + Error); worker-thread OOM → terminal error JSON. Never `napi_reject_deferred` (absent from + `addon.c`). +- **Python init failures:** isolate-create failure → `DataWeaveError`, refcount not incremented, + `_isolate` stays None; `create_engine` failure after isolate create → release the ref (tearing + down if this call created it) and unregister any resolver token, then raise. `run`/stream after + `cleanup()` → instance guard raises `DataWeaveError` (handle already cleared). +- **Teardown failure (Python `graal_tear_down_isolate` returns nonzero):** surface a warning and + re-raise `DataWeaveError`, and clear the isolate globals (`_lib`/`_lib_path`/`_isolate` → None, + count already 0) so the next `initialize()` builds a fresh isolate rather than reusing one whose + teardown just failed. - **Intended breaking changes (pre-GA, no shims):** the dwlib C ABI drops the exported - `run_script_with_resolver` / `run_script_callback_with_resolver` / - `run_script_input_output_callback_with_resolver` entrypoints and replaces them with the - `*_engine` set, and adds a `ctx` parameter to `ResolveModuleCallback`. dwlib is consumed by this - repo's own Python and Node bindings in lockstep. `DataWeave.cleanup()` changes from `void` to - `Promise`. These are documented in the PR, not shimmed. + `run_script` / `run_script_callback` / `run_script_input_output_callback` legacy singleton + entrypoints **and** the `run_script[...]_with_resolver` entrypoints, keeping only the `*_engine` + + `create_engine[_with_resolver]` + `destroy_engine` set; `ResolveModuleCallback` is 3-arg only; + Java `getInstance()`/`defaultInstance` are removed. dwlib is consumed by this repo's own Python + and Node bindings in lockstep. Node `DataWeave.cleanup()` changed from `void` to `Promise`. + The **Python public API is unchanged** — only `native.py`'s internal ABI changed. -## 10. Testing Strategy +## 11. Testing Strategy - **Java unit** (`native-lib:test`): two `ScriptRuntime` instances with different in-memory - resolvers each resolve only their own module; `destroy()` removes an instance. (The - `@CEntryPoint` methods can't be driven from a hosted JVM — GraalVM word types don't box — so - handle-based entrypoint coverage lives at the Node integration layer.) + resolvers each resolve only their own module; `destroy()` removes an instance; `getInstance()` + tests removed. (The `@CEntryPoint` methods can't be driven from a hosted JVM — GraalVM word types + don't box — so handle-based entrypoint coverage lives at the binding integration layers.) - **Node integration** (`native-lib:nodeTest`, real addon, `vi.mock` of `ffi` forbidden): the core W-23692110 regression (two independent resolvers in one process); unknown/destroyed-handle envelopes for all three run paths; the deadlock regression (active stream + `cleanup()` + - concurrent `run()` resolves within a bounded timeout); same-instance lifecycle - (init/run/transform during the cleanup window); ref-count-proxy teardown assertions (a - subsequent raw engine call throwing `/not initialized/` proves the isolate reached zero refs); - and `worker_threads` Worker lifecycle — resolver-backed and resolver-less engines in a Worker, - per-Worker resolver binding, **normal Worker exit without `cleanup()`** (the abandonment / - init-reference-release proof: N Workers each `initialize()` + create N≥3 engines and exit; the - main thread's engine must survive and final teardown must reach exactly zero), - `Worker.terminate()` mid-life, and explicit in-Worker `cleanup()`. -- **Unit** (`ffi` mocked, no dwlib): `DataWeave.initialize()` ref-count/rollback safety; module - singleton poisoning recovery; module + instance `cleanup()` coalescing; `stream.ts` rejection - propagation (parked consumer wakes and throws; buffered-then-reject drains first); `runTransform` - post-pre-buffer re-check; `doCleanup()` releasing the init reference even when `destroyEngine` - throws. -- **Documented posture on non-forceable paths.** Allocator/N-API fault injection and exact + concurrent `run()` resolves within a bounded timeout); same-instance lifecycle; ref-count-proxy + teardown assertions; and `worker_threads` Worker lifecycle including **normal Worker exit without + `cleanup()`** (the abandonment / init-reference-release proof), `Worker.terminate()` mid-life, + and explicit in-Worker `cleanup()`. +- **Node unit** (`ffi` mocked, no dwlib): `DataWeave.initialize()` ref-count/rollback safety; + module singleton poisoning recovery; module + instance `cleanup()` coalescing; `stream.ts` + rejection propagation; `runTransform` post-pre-buffer re-check; `doCleanup()` releasing the init + reference even when `destroyEngine` throws. +- **Python unit** (fake/mocked lib, no dwlib): refcount create/reuse/last-release-teardown; + attach-on-demand thread accounting (bootstrap detached after create; every op attaches+detaches + its own thread; teardown attaches a fresh thread); ctx→resolver trampoline dispatch (two handles → + two resolvers); `cleanup()` idempotency + double-cleanup; `create_engine`-failure rollback + releasing the ref; failed resolver-backed init unregistering the token. +- **Python integration** (real dwlib): the core W-23692110 regression (two instances, different + resolvers, no cross-talk); multi-instance refcount teardown; synchronous `run()` resolving custom + modules; streaming/transform still stream; streaming custom-module resolution fails closed + (parity); a **foreign-thread last-release no-hang** regression (init on a worker thread, last + release/cleanup on a different thread, bounded timeout); TCK conformance stays green. +- **Documented posture on non-forceable paths (Node).** Allocator/N-API fault injection and exact cross-thread teardown interleavings are **not deterministically forceable** from JS/vitest (no addon-boundary fault-injection hook — deliberately not added, YAGNI/test-only surface). Their correctness rests on the C-level invariants in §6, verified by code reasoning and adversarial - review; the Worker tests are best-effort probabilistic guards (green on fixed code, cannot - false-fail on it). This is a standing, documented decision. -- **Native image build** (`native-lib:nativeCompile`) stays green. - -## 11. Follow-Up Work + review; the Worker tests are best-effort probabilistic guards. This is a standing, documented + decision. +- **Native image build** (`native-lib:nativeCompile`) stays green with the legacy entrypoints + removed (confirms no SPI/reflection config referenced them). + +## 12. Engine lifecycle contract (shared by both bindings) + +These invariants are the shared artifact both `native-lib/node/src/addon.c` and +`native-lib/python/src/dataweave/native.py` implement. Any binding on the `*_engine` C ABI must +uphold all six: + +1. One process-wide isolate; engines are handle-addressed objects in the Java registry. +2. The isolate is reference-counted; the refcount equals the number of live engines; the isolate + exists iff the refcount > 0. +3. Create-on-first-ref, tear-down-on-last-release; the binding calls + `graal_create_isolate` / `graal_tear_down_isolate` from *outside* the isolate, and holds no + thread persistently attached across calls (so teardown never blocks on a phantom attachment). +4. Each engine handle is created by `create_engine` / `create_engine_with_resolver` and destroyed + by `destroy_engine`. +5. Resolver dispatch is per-engine via the opaque `ctx` echoed to the 3-arg `ResolveModuleCallback`; + custom-module resolution fails closed off the engine's owner thread. +6. A failed engine-create rolls back the isolate ref; a throwing `destroy_engine` still releases + the ref. + +## 13. Follow-Up Work -- **Python binding parity: DONE (2026-08-26).** The handle-based - `create_engine`/`run_script_engine` API has been ported to the Python binding so both - bindings share one mental model, and the `ScriptRuntime` singleton plus the three legacy C - entrypoints have been removed. See - [docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md](./2026-08-26-python-multi-engine-unification-design.md). - **Streaming/transform + custom-module resolution** across the background-thread boundary remains - a separate, not-yet-scoped effort (unrelated to the singleton fix). + a separate, not-yet-scoped effort in both bindings (unrelated to the singleton fix). Because + Python callbacks hold the GIL, Python *could* later support this as a Python-specific enhancement; + kept out of scope here to preserve one unified behavior. ## References @@ -497,24 +652,27 @@ dwB.run(...) → resolves via resolver B only; A's cache untouched; no cross-t | Singleton root cause | `native-lib/src/main/java/org/mule/weave/lib/ScriptRuntime.java` | | 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` | | WriteCallback/ReadCallback ctx idiom | `native-lib/src/main/java/org/mule/weave/lib/NativeCallbacks.java` | -| Concurrency & lifecycle machinery | `native-lib/node/src/addon.c` | -| JS lifecycle / singleton / exit hooks | `native-lib/node/src/dataweave.ts` | -| Stream error propagation | `native-lib/node/src/stream.ts` | +| Java engine registry / entrypoints | `native-lib/src/main/java/org/mule/weave/lib/{ScriptRuntime,NativeLib,NativeCallbacks}.java` | +| Node concurrency & lifecycle machinery | `native-lib/node/src/addon.c` | +| Node JS lifecycle / singleton / exit hooks | `native-lib/node/src/dataweave.ts` | +| Node stream error propagation | `native-lib/node/src/stream.ts` | +| Python isolate/engine glue | `native-lib/python/src/dataweave/{native,runtime,models}.py` | | Node binding API + lifecycle docs | `native-lib/node/README.md`, `native-lib/node/docs/external-modules.md` | | Original external-modules design | `docs/superpowers/specs/2026-08-04-nodejs-external-modules-design.md` | ## Appendix: Hardening provenance -The concurrency & lifecycle model (§6) converged over a series of code-review rounds; each round's -decisions are folded into the sections above. This map exists only for git archaeology — the -per-round design documents were consolidated into this file. +The Node concurrency & lifecycle model (§6) converged over a series of code-review rounds, and the +Python unification (§7) was implemented and reviewed task-by-task; each round's decisions are folded +into the sections above. This map exists only for git archaeology — the per-round and Python +unification design documents were consolidated into this file. | Round(s) | Area folded into | Decision | |----------|------------------|----------| -| Feature (08-07) | §1–§5, §7–§9 | Object-level engines behind opaque handles; per-handle resolver bridge; ABI redesign. | +| Feature (08-07) | §1–§5, §8–§10 | Object-level engines behind opaque handles; per-handle resolver bridge; ABI redesign. | | 5 (08-11) | §6.2 | `cleanup()`-during-active-stream deadlock → async teardown + waiter thread + `TEARDOWN_*` adoption. | | 6 (08-14) | §6.3, §6.4 | JS three-state lifecycle; atomic streaming/transform admission under `g_mutex`; handle-read validation. | -| 7 (08-18 ffi-sweep) | §6.3, §6.5, §9 | Atomic admission for sync `run()`; uniform `napi_get_value_*` status checks; docs await `cleanup()`. | +| 7 (08-18 ffi-sweep) | §6.3, §6.5, §10 | Atomic admission for sync `run()`; uniform `napi_get_value_*` status checks; docs await `cleanup()`. | | 8 (08-18 oom-setup) | §6.5 | OOM-safe streaming/transform setup allocations. | | 9 (08-18 engine/worker-oom) | §6.3, §6.5 | Deferred registry removal for all engines; worker/callback OOM → terminal result; N-API-create checks. | | 10 (08-19 dangling-ctx) | §6.3, §6.4 | Env-cleanup removes the Java registry entry (`deferred_registry_remove`); shutdown-doc accuracy. | @@ -523,4 +681,6 @@ per-round design documents were consolidated into this file. | 13 (08-20 per-env init) | §6.1 | Per-`napi_env` init-reference ownership; `g_ref_count == Σ init_refs`. | | 14 (08-21 review5) | §6.2, §6.3 | Engine-creation admission requires an owned init reference; `g_teardown_needed` retry flag; `doCleanup()` releases the ref even when destroy throws. | | 15 (08-21 review6) | §6.2, §6.4, §6.5 | Singleton-poisoning fix; stream rejection propagation; teardown return-code checks; init-driven stranded-teardown retry. | -| 16 (08-24 review7) | §6.2, §6.4, §6.5, §9 | Detach on failed teardown; init-hook-failure retry arming; observable init rollback; `Promise.reject(undefined)` fix; lifecycle-doc accuracy. | +| 16 (08-24 review7) | §6.2, §6.4, §6.5, §10 | Detach on failed teardown; init-hook-failure retry arming; observable init rollback; `Promise.reject(undefined)` fix; lifecycle-doc accuracy. | +| Python unification (08-26) | §2, §5, §7, §8 (Layer 1/4), §10–§12 | Remove `ScriptRuntime` singleton + 3 legacy C entrypoints; Python onto shared refcounted isolate + handle engines via `*_engine` ABI; 3-arg ctx resolver trampoline. | +| Python final review (08-26) | §7.2, §10 | Detach isolate bootstrap thread at create + attach-on-demand so cross-thread last-release teardown cannot hang; unregister resolver token on failed init. | diff --git a/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md b/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md deleted file mode 100644 index 9e343adb..00000000 --- a/docs/superpowers/specs/2026-08-26-python-multi-engine-unification-design.md +++ /dev/null @@ -1,301 +0,0 @@ -# Design: Unify Node & Python on One Handle-Based Engine Model (remove the ScriptRuntime singleton) - -**Date:** 2026-08-26 -**Status:** Approved (brainstorm); pending implementation plan -**Branch:** `w-23692110-multi-engine-design` (extends PR #157 — one unified change, not a follow-up) -**Tracks:** GUS W-23692110 — "Native-lib: support multiple DataWeave engine instances with independent module resolvers" -**Related:** [2026-08-07-native-lib-multi-engine-design.md](./2026-08-07-native-lib-multi-engine-design.md) (the Node multi-engine design this extends), [2026-08-04-nodejs-external-modules-design.md](./2026-08-04-nodejs-external-modules-design.md) - -> **Pre-GA.** `dwlib` is consumed only by this repo's own Node and Python bindings, in lockstep. -> This design intentionally breaks the C ABI and removes the Java singleton; there are no -> compatibility shims. The Python *public* API is preserved. - -## 1. Goal - -Put the Node and Python bindings on a **single, unified engine-isolation model** — one shared -process-wide GraalVM isolate holding N handle-addressed engines, each with its own module -resolver and script cache — and **remove the `ScriptRuntime` singleton entirely** so that all -execution is handle-addressed in both bindings. Maximize shared code by making the Java engine -layer the single source of truth that both bindings drive through the identical C ABI. - -## 2. Background - -PR #157 gave the **Node** binding multiple isolated engines per process using "one shared isolate -+ object-level engine handles" (see the related design). It kept, for backward compatibility, the -`ScriptRuntime` static singleton (`defaultInstance` / `getInstance()`) and three legacy -singleton C entrypoints (`run_script`, `run_script_callback`, `run_script_input_output_callback`), -because the **Python** binding still used them. - -Two facts make unification the right move now: - -1. **The rebase exposed a real collision.** Master shipped a Python module-resolver feature that - calls `run_script_with_resolver` (a 2-arg-callback singleton entrypoint). PR #157's ABI break - removed exactly that entrypoint and changed the resolver callback to a 3-arg (ctx) form. After - rebasing, master's Python tests run against this branch's dwlib and fail - (`run_script_with_resolver not found`) — the current red CI. -2. **The two bindings had diverged in isolation model.** Python historically used **one isolate - per `DataWeave` instance** (isolate-per-instance); Node uses **one shared isolate + engine - handles**. Maintaining two models is undesirable. The maintainer's decision: unify on one - model and reuse as much code as possible. - -## 3. Why the shared-isolate model is the unifying choice - -There are two candidate isolation models: - -- **Isolate-per-engine** (Python's current model): each instance gets its own isolate; separate - heaps; teardown is trivially independent. -- **Shared isolate + engine handles** (Node's model): one process-wide isolate; engines are cheap - Java objects in a registry; the isolate is reference-counted and torn down on last release. - -Unifying on **shared isolate + engine handles** is correct because: - -- **Node cannot cheaply move to isolate-per-engine.** `graal_tear_down_isolate` blocks until all - GraalVM-attached threads reach a safepoint; Node's streaming workers deliver chunks via a - `napi_threadsafe_function` that needs the libuv event loop to run. Tearing an isolate down while - the loop must keep running is the deadlock PR #157 spent ~15 review rounds hardening. Multiplying - that per-isolate is strictly worse, and switching Node off its shipped model discards that work. -- **Python can trivially move to the shared model.** Its ctypes calls are synchronous and it owns - its stream-worker threads directly, so it needs *none* of Node's `PENDING_WAIT`/adoption/retry - machinery — just a reference count and a synchronous drain-before-teardown. -- **The engine logic already lives in a shared layer** (Java `ScriptRuntime` registry + the - `*_engine` C ABI), so both bindings reuse it verbatim. - -**Accepted trade-off:** Python instances in one process now share one isolate's heap instead of -having separate heaps. This is weaker memory isolation, relevant only if mutually-untrusted scripts -run in one process expecting heap-level separation. The maintainer accepted this in exchange for a -single maintained model. - -## 4. The reuse boundary (fixed by the architecture) - -**Shared common core (used identically by both bindings):** -- Java `ScriptRuntime` + the handle registry (`register`/`get`/`destroy`). -- The engine C ABI: `create_engine`, `create_engine_with_resolver`, `run_script_engine`, - `run_script_callback_engine`, `run_script_input_output_callback_engine`, `destroy_engine`. -- The 3-arg `ResolveModuleCallback(thread, ctx, modulePath)` contract. - -**Necessarily binding-specific (cannot share source):** -- Isolate lifecycle (`graal_create_isolate`/`graal_tear_down_isolate`) + reference count, thread - attach/detach, resolver-callback marshalling, stream worker threads. This *must* live in the - binding because the isolate C API is called from *outside* the isolate; Java code runs *inside* - one and cannot create/tear down its own. Node implements this in `addon.c` (N-API/C); Python in - `native.py` (ctypes). They share no source but implement the **same lifecycle contract** — which - is captured in a short shared "engine lifecycle contract" doc. - -Net: one shared Java engine ABI as the source of truth; each binding drives it with thin, -host-appropriate glue. - -## 5. Architecture (layer map) - -### Java — `native-lib/src/main/java/org/mule/weave/lib/` (shared core; mostly subtractive) -- **`ScriptRuntime.java`** — delete `defaultInstance` + `getInstance()`. Keep the registry, the - resolver-bound-at-construction model, and instance execution. `ScriptRuntime` becomes purely - handle-addressed. -- **`NativeLib.java`** — delete the 3 legacy `@CEntryPoint`s (`run_script`, `run_script_callback`, - `run_script_input_output_callback`). Keep only the `*_engine` + `create_engine[_with_resolver]` - + `destroy_engine` set. (The `*_with_resolver` entrypoints removed by PR #157 stay removed.) -- **`NativeCallbacks.java`** — `ResolveModuleCallback` stays the 3-arg ctx form; the old 2-arg form - is gone. Both bindings use the 3-arg form. - -### Node — `native-lib/node/src/addon.c` (near-zero change) -- Delete `dw_napi_run_script`, the `fn_run_script`/`run_script` `dlsym`, and its entry in the - required-symbols guard. The engine API and teardown state machine are otherwise untouched. -- Verify whether any public JS export still surfaces the legacy `run`; if so, remove it as a - documented pre-GA break. - -### Python — `native-lib/python/src/dataweave/` (the bulk of the work) -- **`native.py` (`NativeRuntime`)** — rewrite the glue to the shared model: - - Module-level shared state guarded by one lock: `_isolate` (or None), `_isolate_ref_count`, - the main attached thread. - - Bind the `*_engine` + `create_engine[_with_resolver]` + `destroy_engine` symbols. - - The 3-arg ctx resolver trampoline + a `{handle: (resolver, buffers)}` map. - - The reference-count + drain-before-teardown lifecycle (§6). -- **`runtime.py` (`DataWeave`)** — `initialize()` acquires an isolate ref + creates one engine - (`create_engine` or `create_engine_with_resolver`) and stores its `handle`; run methods route - through the `*_engine` entrypoints with that handle; `cleanup()` drains this instance's stream - workers, `destroy_engine(handle)`, releases the isolate ref. **The public Python API surface is - unchanged.** -- **`models.py`** — `RESOLVE_MODULE_CALLBACK` ctypes signature gains the `ctx` argument. -- **Tests** — migrate off `run_script_with_resolver` to the engine ABI; add multi-instance - isolation + refcount-teardown coverage. - -### New shared artifact -- A short **engine lifecycle contract** doc (the invariant list) that both bindings reference. - -## 6. Python lifecycle & teardown model - -**Shared state (module-level in `native.py`), all mutations under one module lock:** -- `_isolate` (the single process-wide isolate, or None), `_isolate_ref_count`, main attached thread. -- **Invariant:** `_isolate_ref_count` == number of live engines across all `DataWeave` instances, - and the isolate exists iff the count > 0. - -**`initialize()` (per instance):** -1. Under the lock: if `_isolate` is None → `graal_create_isolate()` + attach the main thread once; - then `_isolate_ref_count += 1`. -2. `create_engine()` or `create_engine_with_resolver(ctx=handle, trampoline)` → store `handle` on - the instance. (Handle is allocated by Java; for the resolver case the ctx *is* that handle, so - the map entry is added immediately after the handle is returned — see §7 for the ordering note.) - -Each instance owns exactly one engine handle and contributes exactly one to the isolate refcount. - -**`run` / `run_streaming` / `run_callback` / `run_transform`:** route through the `*_engine` -entrypoints with the instance's `handle`. Stream workers attach their own GraalVM thread, run, -detach on completion (existing pattern). - -**`cleanup()` (per instance):** -1. Drain *this instance's* stream workers — signal cancel + **join** the threads (synchronous; - Python owns the threads, so no event loop and no deadlock). -2. `destroy_engine(handle)`; remove the resolver-map entry; clear the instance handle. -3. Under the lock: `_isolate_ref_count -= 1`; **if it reaches 0** → detach the main thread and - `graal_tear_down_isolate()`, set `_isolate = None`. - -**Why this stays simple:** teardown happens only on the *last* release, by which point every -instance has already joined its own workers in step 1 — so the isolate has no attached worker -threads when `graal_tear_down_isolate` runs. Hence **none** of Node's `PENDING_WAIT`/adoption/retry -machinery is needed. - -**Idempotency / safety:** `cleanup()` on an uninitialized or already-cleaned instance is a no-op; -double-`cleanup()` releases the ref only once (guarded by the instance's handle being cleared). - -**Concurrency:** keep today's **per-instance serialization** of native execution calls -(`_serialized_native_operation`), but allow **different instances to run concurrently** — each on -its own attached thread in the shared isolate (GraalVM supports multiple attached threads). The -module lock guards only isolate refcount/create/teardown; it is **not** held during script -execution, so one engine's long-running script never blocks another engine's `initialize()`/`run()`. - -## 7. Resolver dispatch & the streaming/resolver hazard - -**Per-engine resolver dispatch (ctx mechanism):** -- `create_engine_with_resolver` passes `ctx = handle` (the engine handle). -- Python registers **one** C trampoline (`RESOLVE_MODULE_CALLBACK`). GraalVM calls it with - `(thread, ctx, module_path)`; it looks up `ctx` in `{handle: (resolver, buffers)}`, invokes that - engine's Python resolver, and returns the source-buffer pointer. -- This is the Python analog of Node's per-handle bridge — same ctx concept, so the Java/ABI side is - identical. Multiple Python engines with different resolvers dispatch correctly within the shared - isolate. - -**Ordering note:** the ctx passed to `create_engine_with_resolver` is the handle it returns, so -either (a) allocate the handle first and pass it as ctx, or (b) register the trampoline against a -provisional key and re-key once the handle is known. The plan will pick the concrete mechanism; the -requirement is that no resolve callback can fire for a handle before its map entry exists (resolves -only occur during a `run` on that engine, which happens strictly after `create_engine_with_resolver` -returns, so this is naturally safe). - -**Streaming / transform + custom modules — parity with Node (out of scope):** -Resolving a *custom* module reached from a background stream-worker thread is the pre-existing -documented hazard. Python adopts the **same owner-thread guard** as Node: the trampoline resolves -only when invoked on the engine's owner thread and fails closed ("not found") on a background stream -thread — identical behavior across bindings. Built-in modules resolve normally everywhere; -synchronous `run()` with a resolver resolves custom modules fully in both bindings. - -This is a conservative parity choice, not a hard Python limitation: because Python callbacks hold -the GIL, Python could potentially support streaming custom-module resolution later as a -Python-specific enhancement. Out of scope here to preserve one unified behavior. - -## 8. Data flow - -``` -dwA = DataWeave(resolve_module=A); dwA.initialize() - → lock: _isolate None → graal_create_isolate() + attach main thread; ref 0→1 - → create_engine_with_resolver(ctx=handleA, trampoline); map[handleA] = (A, buffers); dwA._handle = handleA - -dwB = DataWeave(resolve_module=B); dwB.initialize() - → lock: _isolate exists → reuse; ref 1→2 - → create_engine_with_resolver(ctx=handleB, trampoline); map[handleB] = (B, buffers) - -dwA.run("... import custom/lib ...") - → run_script_engine(handleA, script, inputs) [own attached thread] - → Java engine A: ClassLoader miss → callback(thread, ctx=handleA, "custom/lib") - → trampoline: map[handleA] → resolver A → source; A's cache used, B untouched - -dwB.run(...) → resolves via B only; independent cache; no cross-talk - -dwA.cleanup() → join dwA workers; destroy_engine(handleA); del map[handleA]; ref 2→1 (isolate stays) -dwB.cleanup() → join workers; destroy_engine(handleB); ref 1→0 → detach main + graal_tear_down_isolate(); _isolate=None -``` - -## 9. Error handling - -- **Isolate create fails** → `DataWeaveError`; refcount not incremented; `_isolate` stays None. -- **`create_engine` fails after isolate create** → release the isolate ref (tearing down if this - call created it), then raise — a failed init leaks nothing. -- **Unknown/destroyed handle** → Java `get(handle)` is null → entrypoint returns - `{"success":false,"error":"Unknown engine handle"}`; Python surfaces an unsuccessful - `ExecutionResult`/`DataWeaveError`, never a crash. -- **Resolver raises / returns non-str** → trampoline returns None → standard "unable to resolve - module" (unchanged Python behavior). -- **`run`/stream after `cleanup()`** → instance guard raises `DataWeaveError` (handle already - cleared); the C layer never sees a stale handle. -- **`destroy_engine` throws during `cleanup()`** → still release the isolate ref (so a throwing - destroy cannot strand the isolate), then re-raise — mirrors Node's `doCleanup()`. -- **`graal_tear_down_isolate` returns nonzero** → surface a warning and re-raise `DataWeaveError`, - and clear the isolate globals (`_lib`/`_lib_path`/`_isolate` → None, count already 0) so the next - `initialize()` builds a fresh isolate rather than reusing one whose teardown just failed. No retry - flag needed — there is no event loop to defer to. (The implementation nulls the globals in a - `finally` after re-raising; the failed teardown is reported on `stderr`.) - -## 10. Backward compatibility (all intended, pre-GA, no shims) - -- **dwlib C ABI:** removes `run_script` / `run_script_callback` / `run_script_input_output_callback`; - keeps only the `*_engine` + `create_engine[_with_resolver]` + `destroy_engine` set; - `ResolveModuleCallback` is 3-arg only. Consumed by this repo's own bindings in lockstep. -- **Java:** `getInstance()`/`defaultInstance` removed — `ScriptRuntime` is purely handle-addressed. -- **Node:** removes the legacy `dw_napi_run_script` path (and any JS export surfacing it). -- **Python:** **public API unchanged** — `DataWeave(...)`, `initialize`, `run`, `run_streaming`, - `run_callback`, `run_transform`, `cleanup`, and the module-level functions keep signatures and - behavior. Only `native.py`'s internal ABI changes. -- **Docs:** update the 2026-08-07 consolidated design's Python notes: singleton removed; all - execution handle-addressed; both bindings on one engine ABI. - -## 11. Testing strategy - -- **Java unit** — two `ScriptRuntime` instances with different in-memory resolvers each resolve only - their own module; `destroy(handle)` removes one. Delete/adjust tests referencing `getInstance()`. -- **Python unit** (fake/mocked lib, no dwlib) — migrate `test_native.py` off `run_script_with_resolver` - to the engine ABI; cover refcount create/reuse/last-release-teardown, ctx→resolver trampoline - dispatch (two handles → two resolvers), `cleanup()` idempotency + double-cleanup, and the - `create_engine`-failure rollback releasing the isolate ref. -- **Python integration** (real dwlib) — the core W-23692110 regression (two instances, different - resolvers, one process, no cross-talk); multi-instance teardown via a refcount proxy (after all - instances clean up, a fresh raw engine call fails "not initialized"); synchronous `run()` with a - resolver resolves custom modules; streaming/transform still stream; streaming custom-module - resolution fails closed (parity); **TCK conformance stays green**. -- **Node** — existing suite stays green; remove the `dw_napi_run_script` test with its entrypoint. -- **Build** — `native-lib:nativeCompile` green with the 3 legacy `@CEntryPoint`s removed (confirm no - SPI/reflection config references them). -- **CI** — the currently-red Python module-resolver tests pass, because Python now calls - `create_engine_with_resolver` instead of the removed `run_script_with_resolver`. - -## 12. Follow-Up Work - -- Optional Python-specific enhancement: support custom-module resolution during streaming/transform - (feasible under the GIL; deliberately out of scope here for cross-binding parity). -- Fold the two multi-engine design docs' shared concepts into a single reference if they drift. - -## References - -| Item | Location | -|------|----------| -| GUS ticket | W-23692110 | -| Node multi-engine design (extended here) | `docs/superpowers/specs/2026-08-07-native-lib-multi-engine-design.md` | -| Java engine registry / entrypoints | `native-lib/src/main/java/org/mule/weave/lib/{ScriptRuntime,NativeLib,NativeCallbacks}.java` | -| Node addon (legacy path to remove) | `native-lib/node/src/addon.c` (`dw_napi_run_script`) | -| Python glue to rewrite | `native-lib/python/src/dataweave/{native,runtime,models}.py` | -| Current Python isolate-per-instance model | `native-lib/python/src/dataweave/native.py` (`graal_create_isolate` in `NativeRuntime.initialize`) | - -## Engine lifecycle contract (shared by both bindings) - -These invariants are the shared artifact both `native-lib/node/src/addon.c` and -`native-lib/python/src/dataweave/native.py` implement. Any binding on the `*_engine` C ABI must -uphold all six: - -1. One process-wide isolate; engines are handle-addressed objects in the Java registry. -2. The isolate is reference-counted; the refcount equals the number of live engines; the isolate - exists iff the refcount > 0. -3. Create-on-first-ref, tear-down-on-last-release; the binding calls - `graal_create_isolate` / `graal_tear_down_isolate` from *outside* the isolate. -4. Each engine handle is created by `create_engine` / `create_engine_with_resolver` and destroyed - by `destroy_engine`. -5. Resolver dispatch is per-engine via the opaque `ctx` echoed to the 3-arg `ResolveModuleCallback`; - custom-module resolution fails closed off the engine's owner thread. -6. A failed engine-create rolls back the isolate ref; a throwing `destroy_engine` still releases - the ref. From 089e611c1df08e2b4b9f4109e02d8ff7e518051d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 09:34:38 -0300 Subject: [PATCH 150/216] refactor(native-lib): add no-arg ScriptRuntime constructor for built-ins-only engines new ScriptRuntime(null) obscured intent; the null just selects the ClassLoader-only resolver. Add a delegating no-arg constructor and use it at the one production call site (create_engine) and all test sites, so a built-ins-only engine reads as new ScriptRuntime(). No behavior change; the resolver-backed two-arg constructor is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/org/mule/weave/lib/NativeLib.java | 2 +- .../org/mule/weave/lib/ScriptRuntime.java | 5 ++ .../org/mule/weave/lib/ScriptRuntimeTest.java | 80 ++++++++++--------- 3 files changed, 50 insertions(+), 37 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 cb94e4c4..4d596b6e 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 @@ -273,7 +273,7 @@ private static CCharPointer toUnmanagedCString(String value) { */ @CEntryPoint(name = "create_engine") public static long createEngine(IsolateThread thread) { - return ScriptRuntime.register(new ScriptRuntime(null)); + return ScriptRuntime.register(new ScriptRuntime()); } /** 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 977dcc29..cb46317e 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 @@ -73,6 +73,11 @@ public ScriptRuntime(WeaveResourceResolver customResolver) { .build(); } + /** Builds an engine with built-in (ClassLoader) modules only — no custom resolver. */ + public ScriptRuntime() { + this(null); + } + /** * Creates composite resolver: ClassLoader (built-ins) + custom (user modules). * If no custom resolver is provided, returns ClassLoader only. 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 5b9ac2dd..4a3edfc2 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 @@ -6,12 +6,23 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.mule.weave.v2.parser.ast.variables.NameIdentifier; +import org.mule.weave.v2.sdk.NameIdentifierHelper; +import org.mule.weave.v2.sdk.WeaveResource; +import org.mule.weave.v2.sdk.WeaveResourceResolver; +import scala.Option; +import scala.collection.JavaConverters; +import scala.collection.immutable.Seq; +import scala.collection.immutable.Seq$; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.Charset; import java.util.Base64; +import java.util.Collections; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; @@ -19,7 +30,7 @@ class ScriptRuntimeTest { @Test void runSimpleScript() { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Running sqrt(144) 10 times with timing:"); System.out.println("=".repeat(50)); @@ -39,7 +50,7 @@ void runSimpleScript() { @Test void runParseError() { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Running sqrt(144) 10 times with timing:"); System.out.println("=".repeat(50)); @@ -55,7 +66,7 @@ void runParseError() { @Test void runWithInputs() { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing runWithInputs with two integer numbers:"); System.out.println("=".repeat(50)); @@ -129,7 +140,7 @@ private String encode(Object value) { @Test void runWithXmlInput() { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing runWithInputs with XML input to calculate average age:"); System.out.println("=".repeat(50)); @@ -181,7 +192,7 @@ void runWithXmlInput() { @Test void runWithJsonObjectInput() { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing runWithInputs with JSON object input:"); System.out.println("=".repeat(50)); @@ -216,7 +227,7 @@ void runWithJsonObjectInput() { @Test void runWithBinaryResult() { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Running fromBase64 10 times with timing:"); System.out.println("=".repeat(50)); @@ -239,7 +250,7 @@ void runWithBinaryResult() { @Test void runWithInputProperties() { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); String encodedIn0 = Base64.getEncoder().encodeToString("1234567".getBytes()); Result result = Result.parse(runtime.run("in0.column_1[0] as Number", "{\"in0\": " + @@ -252,7 +263,7 @@ void runWithInputProperties() { @Test void streamSimpleScript() throws IOException { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing streaming simple script:"); System.out.println("=".repeat(50)); @@ -279,7 +290,7 @@ void streamSimpleScript() throws IOException { @Test void streamWithInputs() throws IOException { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing streaming with inputs:"); System.out.println("=".repeat(50)); @@ -310,7 +321,7 @@ void streamWithInputs() throws IOException { @Test void streamChunkedRead() throws IOException { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing streaming chunked read:"); System.out.println("=".repeat(50)); @@ -341,7 +352,7 @@ void streamChunkedRead() throws IOException { @Test void streamWithStreamingInput() throws Exception { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing streaming with streaming input:"); System.out.println("=".repeat(50)); @@ -396,7 +407,7 @@ void streamWithStreamingInput() throws Exception { @Test void streamWithLargeStreamingInput() throws Exception { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing streaming with large streaming input:"); System.out.println("=".repeat(50)); @@ -455,7 +466,7 @@ void streamWithLargeStreamingInput() throws Exception { @Test void streamErrorSession() { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing streaming error session:"); System.out.println("=".repeat(50)); @@ -474,7 +485,7 @@ void streamErrorSession() { @Test void callbackOutputStreaming() throws IOException { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing callback-based output streaming:"); System.out.println("=".repeat(50)); @@ -505,7 +516,7 @@ void callbackOutputStreaming() throws IOException { @Test void callbackInputOutputStreaming() throws Exception { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing callback-based input+output streaming:"); System.out.println("=".repeat(50)); @@ -566,7 +577,7 @@ void callbackInputOutputStreaming() throws Exception { @Test void callbackOutputStreamingError() { - ScriptRuntime runtime = new ScriptRuntime(null); + ScriptRuntime runtime = new ScriptRuntime(); System.out.println("Testing callback-based output streaming with error:"); System.out.println("=".repeat(50)); @@ -585,31 +596,28 @@ void callbackOutputStreamingError() { /** 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; } + static final class MapResolver implements WeaveResourceResolver { + private final Map modules; + MapResolver(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, "/"); + public Option resolve(NameIdentifier id) { + String path = 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)); + if (src == null) return Option.empty(); + return Option.apply(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); + public Seq resolveAll(NameIdentifier id) { + Option r = resolve(id); if (r.isDefined()) { - return scala.collection.JavaConverters - .asScalaBuffer(java.util.Collections.singletonList(r.get())).toList(); + return JavaConverters + .asScalaBuffer(Collections.singletonList(r.get())) + .toList(); } - return (scala.collection.immutable.Seq) - scala.collection.immutable.Seq$.MODULE$.empty(); + return (Seq) Seq$.MODULE$.empty(); } } @@ -620,9 +628,9 @@ public scala.collection.immutable.Seq resol @Test void twoEnginesResolveOnlyTheirOwnModule() { - ScriptRuntime engineA = new ScriptRuntime(new MapResolver(java.util.Map.of( + ScriptRuntime engineA = new ScriptRuntime(new MapResolver(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( + ScriptRuntime engineB = new ScriptRuntime(new MapResolver(Map.of( "org/test/b.dwl", "%dw 2.0\nfun greet(n: String) = \"B:\" ++ n"))); long hA = ScriptRuntime.register(engineA); @@ -649,7 +657,7 @@ void twoEnginesResolveOnlyTheirOwnModule() { @Test void engineWithoutResolverStillRunsBuiltins() { - ScriptRuntime engine = new ScriptRuntime(null); // ClassLoader-only + ScriptRuntime engine = new ScriptRuntime(); // 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\")"); @@ -692,7 +700,7 @@ static class Result { static Result parse(String json) { Result result = new Result(); - org.json.JSONObject obj = new org.json.JSONObject(json); + JSONObject obj = new JSONObject(json); result.success = obj.getBoolean("success"); if (result.success) { From a984dd2312b10aa7cdb4080d1833e0b45cc14264 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 10:05:31 -0300 Subject: [PATCH 151/216] test(node): assert throwing-resolver run() surfaces an error message Strengthen the throwing-resolver regression from "run reported failure" to "run reported failure AND produced a diagnostic" (result.error truthy), without pinning the internal error wording. Closes the last open GA-cleanup-backlog item. (W-23692110) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/tests/integration/dataweave-resolver.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/native-lib/node/tests/integration/dataweave-resolver.test.ts b/native-lib/node/tests/integration/dataweave-resolver.test.ts index a3963d24..eca77297 100644 --- a/native-lib/node/tests/integration/dataweave-resolver.test.ts +++ b/native-lib/node/tests/integration/dataweave-resolver.test.ts @@ -164,8 +164,10 @@ describe('DataWeave with resolver', () => { `); // The test itself completing (no uncaught exception / segfault) is the - // crash-check; we don't assert on the internal error message wording. + // crash-check. We also assert an error message is surfaced -- but not its + // wording, which is an internal detail. expect(result.success).toBe(false); + expect(result.error).toBeTruthy(); }); // Regression test for a resolver-backed engine's initialize -> cleanup -> From ea6b1ca0ae7810e342627499766a740bfa4895e1 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 14:57:00 -0300 Subject: [PATCH 152/216] fix(python): roll back resolver token when _acquire_isolate fails during initialize (review #10 #3) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/native.py | 14 ++++++++++--- native-lib/python/tests/unit/test_native.py | 23 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index a8ea403f..cd04fe59 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -232,11 +232,14 @@ def __init__(self, lib_path: Optional[str] = None): def initialize(self) -> None: if self.initialized: return - self.lib, self.isolate = _acquire_isolate(self.lib_path) + acquired = False try: + self.lib, self.isolate = _acquire_isolate(self.lib_path) + acquired = True self.handle = self._create_engine() except Exception: - # Roll back the ref we just took so a failed init leaks nothing. + # Roll back the ref we just took (if any) so a failed init leaks + # nothing. self.lib = self.isolate = None # Finding #2: install_resolver() registered a token BEFORE this call. # A failed init must unregister it, or it leaks: self.initialized stays @@ -245,7 +248,12 @@ def initialize(self) -> None: with _resolver_lock_global: _resolver_registry.pop(self._resolver_token, None) self._resolver_token = 0 - _release_isolate() + # Release the ref only if _acquire_isolate actually incremented it + # (a library-load / isolate-create / bootstrap-detach failure inside + # _acquire_isolate never increments the refcount, so releasing here + # unconditionally would decrement someone else's live reference). + if acquired: + _release_isolate() raise self.initialized = True diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 32af3490..500e04e4 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -754,3 +754,26 @@ def test_failed_init_with_resolver_unregisters_the_token(monkeypatch): assert token not in native._resolver_registry assert native._isolate_ref_count == 0 assert native._isolate is None + + +@pytest.mark.unit +def test_failed_acquire_with_resolver_unregisters_the_token(monkeypatch): + """A library-load failure inside _acquire_isolate must still roll back the + resolver token (regression: _acquire_isolate was called outside + initialize()'s try, so the rollback below never ran).""" + monkeypatch.setattr( + native.ctypes, "CDLL", lambda _path: (_ for _ in ()).throw(OSError("no lib")) + ) + + runtime = native.NativeRuntime("/tmp/dwlib") + runtime.install_resolver(lambda path: "src") + token = runtime._resolver_token + assert native._resolver_registry.get(token) is runtime + + with pytest.raises(native.DataWeaveError): + runtime.initialize() + + assert token not in native._resolver_registry + assert runtime._resolver_token == 0 + assert native._isolate_ref_count == 0 + assert native._isolate is None From 0c53f99a896fde174f9fa7658be743320266fbc8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 15:05:20 -0300 Subject: [PATCH 153/216] fix(python): retain live isolate and retry on failed teardown instead of nulling globals (review #10 #3, align with Node) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/native.py | 60 ++++++++++++++---- native-lib/python/tests/unit/test_native.py | 70 +++++++++++++++++++-- 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index cd04fe59..5c0183e9 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -35,6 +35,12 @@ class graal_isolatethread_t(ctypes.Structure): _lib_path = None _isolate = None _isolate_ref_count = 0 +# Set when a final graal_tear_down_isolate (or the attach immediately before +# it) failed. The isolate is still live in that case; teardown must be +# retried -- and must succeed -- before any new isolate is created. Mirrors +# Node's g_teardown_needed retryable-teardown model. Only read/written while +# holding _isolate_lock. +_teardown_needed = False # Per-engine resolver dispatch. The ctx passed to create_engine_with_resolver is @@ -102,11 +108,30 @@ def _bind_abi(lib) -> None: lib.run_script_input_output_callback_engine.restype = ctypes.c_void_p +def _retry_pending_teardown_locked() -> None: + """If a prior final teardown failed, retry it now (caller holds _isolate_lock). + On success, clears the flag and nulls the isolate globals so the caller may + build fresh. On failure, leaves the isolate live and the flag armed, and + propagates the failure so the caller does not proceed to build a second, + racing isolate.""" + global _lib, _lib_path, _isolate, _teardown_needed + if not _teardown_needed: + return + lib, isolate = _lib, _isolate + worker = GraalIsolateThreadPointer() + if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: + raise DataWeaveError("Failed to attach thread to retry isolate teardown") + _tear_down(lib, worker) # raises on failure -> flag stays armed + _lib = _lib_path = _isolate = None + _teardown_needed = False + + def _acquire_isolate(lib_path: str): """Returns (lib, isolate), creating the shared isolate on the first reference. Increments the refcount only on success.""" global _lib, _lib_path, _isolate, _isolate_ref_count with _isolate_lock: + _retry_pending_teardown_locked() if _isolate is None: try: lib = ctypes.CDLL(lib_path) @@ -139,8 +164,12 @@ def _acquire_isolate(lib_path: str): def _release_isolate() -> None: - """Decrements the refcount; tears the isolate down and nulls globals on 0.""" - global _lib, _lib_path, _isolate, _isolate_ref_count + """Decrements the refcount; tears the isolate down on 0. A failed teardown + (or a failed attach immediately before it) retains the isolate live and + arms _teardown_needed for a retry at the next acquire, instead of nulling + the globals -- nulling would let the next initialize() build a second live + isolate while the first is still alive.""" + global _lib, _lib_path, _isolate, _isolate_ref_count, _teardown_needed with _isolate_lock: if _isolate_ref_count == 0: return @@ -149,23 +178,32 @@ def _release_isolate() -> None: return # Last release: no thread is persistently attached (the bootstrap was # detached at create and every op detaches its own thread), so attach a - # fresh thread and tear down. Then clear globals regardless. + # fresh thread and tear down. lib, isolate = _lib, _isolate + worker = GraalIsolateThreadPointer() + if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: + # Cannot even attach to tear down; retain the isolate and arm a retry. + _teardown_needed = True + print( + "DataWeave: could not attach a thread to tear down the GraalVM " + "isolate; teardown will be retried on the next initialize().", + file=sys.stderr, + ) + raise DataWeaveError("Failed to attach thread for isolate teardown") try: - worker = GraalIsolateThreadPointer() - if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: - raise DataWeaveError("Failed to attach thread for isolate teardown") _tear_down(lib, worker) except BaseException: + # Teardown failed: keep the isolate live, arm a retry, do NOT null + # globals (nulling would let the next initialize() build a second + # live isolate). Mirrors Node's g_teardown_needed retryable model. + _teardown_needed = True print( - "DataWeave: GraalVM isolate teardown failed; the isolate reference " - "has been cleared and a fresh isolate will be created on the next " - "initialize().", + "DataWeave: GraalVM isolate teardown failed; the isolate is " + "retained and teardown will be retried on the next initialize().", file=sys.stderr, ) raise - finally: - _lib = _lib_path = _isolate = None + _lib = _lib_path = _isolate = None def _tear_down(lib, thread) -> None: diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 500e04e4..3895aa08 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -604,7 +604,13 @@ def cleanup_b(): @pytest.mark.unit -def test_failed_isolate_teardown_surfaces_and_clears_state_for_retry(monkeypatch): +def test_failed_isolate_teardown_retains_isolate_and_arms_retry(monkeypatch): + # Updated for the retryable-teardown contract (review #10 #3, align with + # Node): a failed final teardown must NOT null the globals -- nulling would + # let the next initialize() build a SECOND live isolate while the first is + # still alive. Instead the isolate is retained and a retry is armed; the + # retry runs (and must succeed) before any fresh isolate can be built. + monkeypatch.setattr(native, "_teardown_needed", False) # restored after the test regardless of outcome library = FakeLibrary() library.graal_tear_down_isolate = CallableFunction(lambda _thread: 1) # non-zero == failure monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) @@ -613,17 +619,69 @@ def test_failed_isolate_teardown_surfaces_and_clears_state_for_retry(monkeypatch a.initialize() with pytest.raises(native.DataWeaveError): a.cleanup() # last release -> teardown fails -> raises - # State cleared regardless, so a fresh isolate is creatable. - assert native._isolate is None + + # The isolate is retained live (not nulled) and a retry is armed. + assert native._isolate is not None assert native._isolate_ref_count == 0 + assert native._teardown_needed is True + + # Restore a passing teardown so the pending retry (run before b's isolate + # is built) succeeds. + library.graal_tear_down_isolate = CallableFunction(lambda _thread: 0) + b = native.NativeRuntime("/tmp/dwlib") - b.initialize() # must succeed against a fresh isolate + b.initialize() # retries the pending teardown, then builds a fresh isolate + assert native._teardown_needed is False assert native._isolate is not None - # Restore a passing teardown so b.cleanup() doesn't raise on the way out. - library.graal_tear_down_isolate = CallableFunction(lambda _thread: 0) b.cleanup() +@pytest.mark.unit +def test_failed_teardown_retains_isolate_and_retries(monkeypatch): + """A failing graal_tear_down_isolate must NOT null the globals or create a + second isolate; the next acquire retries the pending teardown.""" + monkeypatch.setattr(native, "_teardown_needed", False) # restored after the test regardless of outcome + library = FakeLibrary() + create_isolate_calls = [] + + def create_isolate(_params, _isolate, _thread): + create_isolate_calls.append(1) + return 0 + + tear_down_results = [1, 0] # the final teardown fails once, then the retry succeeds + + def tear_down(thread): + library.tear_down_threads.append(thread) + return tear_down_results.pop(0) if tear_down_results else 0 + + library.graal_create_isolate = CallableFunction(create_isolate) + library.graal_tear_down_isolate = CallableFunction(tear_down) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + lib, isolate = native._acquire_isolate("/tmp/dwlib") + assert len(create_isolate_calls) == 1 + + with pytest.raises(native.DataWeaveError): + native._release_isolate() # last release -> tear_down fails + + # Isolate retained, retry armed, NOT nulled, no second isolate created. + assert native._lib is lib + assert native._isolate is isolate + assert native._teardown_needed is True + assert native._isolate_ref_count == 0 + assert len(create_isolate_calls) == 1 + + # Next acquire retries the pending teardown (which now succeeds) and only + # then builds a fresh isolate. + lib2, isolate2 = native._acquire_isolate("/tmp/dwlib") + assert native._teardown_needed is False + assert len(library.tear_down_threads) == 2 # the failed attempt + the retry + assert len(create_isolate_calls) == 2 # then a fresh isolate + assert isolate2 is not isolate + + native._release_isolate() + + @pytest.mark.unit def test_two_engines_dispatch_to_their_own_resolver(monkeypatch): library = FakeLibrary() From 1130a39c24603814849bdba55a0bf3efbe585714 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 15:13:37 -0300 Subject: [PATCH 154/216] test(python): reset _teardown_needed and _lib_path in unit conftest's isolate-state clear (review #10 #3 fast-follow) Closes a cross-test pollution gap: _clear() reset _lib/_isolate/_isolate_ref_count but not the newly added _teardown_needed or _lib_path, so a test that armed the retry flag without its own monkeypatch guard could leak True into the next test. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/tests/unit/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/native-lib/python/tests/unit/conftest.py b/native-lib/python/tests/unit/conftest.py index 49a8732f..0b4483d6 100644 --- a/native-lib/python/tests/unit/conftest.py +++ b/native-lib/python/tests/unit/conftest.py @@ -20,3 +20,5 @@ def _clear(): native._lib = None native._isolate = None native._isolate_ref_count = 0 + native._teardown_needed = False + native._lib_path = None From 94e50c62a0567ac1b4f881e36df3874f8c29f3b3 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 15:17:21 -0300 Subject: [PATCH 155/216] fix(python): tear down just-created isolate on bootstrap-detach failure (review #10 #3) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/native.py | 19 +++++++- native-lib/python/tests/unit/test_native.py | 51 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index 5c0183e9..ca051ac8 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -129,7 +129,7 @@ def _retry_pending_teardown_locked() -> None: def _acquire_isolate(lib_path: str): """Returns (lib, isolate), creating the shared isolate on the first reference. Increments the refcount only on success.""" - global _lib, _lib_path, _isolate, _isolate_ref_count + global _lib, _lib_path, _isolate, _isolate_ref_count, _teardown_needed with _isolate_lock: _retry_pending_teardown_locked() if _isolate is None: @@ -153,6 +153,23 @@ def _acquire_isolate(lib_path: str): # when done; teardown attaches a fresh thread. Mirrors the Node/Go bindings. detach_result = lib.graal_detach_thread(thread) if detach_result != 0: + # The bootstrap thread could not be detached. The isolate is + # created but not yet published (globals unset, refcount not + # bumped), so tear it down here rather than leak an unreachable + # live isolate. Reuse the same still-attached bootstrap thread to + # tear down (it is the only attached thread). + try: + _tear_down(lib, thread) + except BaseException: + # Even teardown failed: retain the created isolate and arm a + # retry rather than leaking it silently. + _lib, _lib_path, _isolate = lib, lib_path, isolate + _teardown_needed = True + print( + "DataWeave: bootstrap-thread detach and isolate teardown " + "both failed; isolate retained for retry.", + file=sys.stderr, + ) raise DataWeaveError( f"Failed to detach GraalVM isolate bootstrap thread. Error code: {detach_result}" ) diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 3895aa08..66d3b4f0 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -682,6 +682,57 @@ def tear_down(thread): native._release_isolate() +@pytest.mark.unit +def test_bootstrap_detach_failure_tears_down_created_isolate(monkeypatch): + """A failed bootstrap-thread detach must not leak the just-created isolate: + it is torn down (reusing the still-attached bootstrap thread) before the + failure is raised, and nothing is published to the module globals.""" + library = FakeLibrary() + create_isolate_calls = [] + + def create_isolate(_params, _isolate, _thread): + create_isolate_calls.append(1) + return 0 + + library.graal_create_isolate = CallableFunction(create_isolate) + library.graal_detach_thread = CallableFunction(lambda _thread: 1) # bootstrap detach fails + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + with pytest.raises(native.DataWeaveError): + native._acquire_isolate("/tmp/dwlib") + + # No leaked live isolate: the just-created isolate was torn down, and + # nothing was published since the detach failure happened before publish. + assert native._isolate is None + assert native._lib is None + assert native._isolate_ref_count == 0 + assert len(create_isolate_calls) == 1 + assert len(library.tear_down_threads) == 1 + assert native._teardown_needed is False + + +@pytest.mark.unit +def test_bootstrap_detach_and_teardown_both_failing_arms_retry_instead_of_leaking(monkeypatch): + """If the just-created isolate's teardown ALSO fails after a bootstrap + detach failure, the isolate must be retained (not silently leaked) and a + retry armed for the next acquire -- mirroring the release-path contract.""" + library = FakeLibrary() + library.graal_detach_thread = CallableFunction(lambda _thread: 1) # bootstrap detach fails + library.graal_tear_down_isolate = CallableFunction( + lambda thread: library.tear_down_threads.append(thread) or 1 + ) # teardown also fails + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + with pytest.raises(native.DataWeaveError): + native._acquire_isolate("/tmp/dwlib") + + assert native._isolate is not None + assert native._lib is library + assert native._isolate_ref_count == 0 + assert native._teardown_needed is True + assert len(library.tear_down_threads) == 1 + + @pytest.mark.unit def test_two_engines_dispatch_to_their_own_resolver(monkeypatch): library = FakeLibrary() From 450a04e90f19de4fa8e01dee05ecc4b77af2d6df Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 15:25:14 -0300 Subject: [PATCH 156/216] fix(python): make resolver-backed DataWeave.initialize() idempotent (review #10 #8) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/runtime.py | 2 ++ native-lib/python/tests/unit/test_runtime.py | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/native-lib/python/src/dataweave/runtime.py b/native-lib/python/src/dataweave/runtime.py index 641e2cf4..967230a1 100644 --- a/native-lib/python/src/dataweave/runtime.py +++ b/native-lib/python/src/dataweave/runtime.py @@ -41,6 +41,8 @@ def __init__( self._cleaning_up = False def initialize(self): + if self._native.initialized: + return if self._resolve_module is not None: self._native.install_resolver(self._resolve_module) self._native.initialize() diff --git a/native-lib/python/tests/unit/test_runtime.py b/native-lib/python/tests/unit/test_runtime.py index 865dc547..b32ad3a0 100644 --- a/native-lib/python/tests/unit/test_runtime.py +++ b/native-lib/python/tests/unit/test_runtime.py @@ -14,10 +14,12 @@ def __init__(self, lib_path=None): self.has_callback_input_output = True self.cleaned = 0 self.runs = [] + self.install_resolver_calls = 0 def install_resolver(self, resolver): - assert not self.initialized, "resolver must be installed before initialize()" + assert not self.initialized, "Cannot install a resolver after initialize()" self.installed_resolver = resolver + self.install_resolver_calls += 1 def initialize(self): self.initialized = True @@ -44,6 +46,19 @@ def test_dataweave_installs_resolver_before_initialize(monkeypatch): assert dw._native.cleaned == 1 +@pytest.mark.unit +def test_initialize_is_idempotent_with_resolver(monkeypatch): + monkeypatch.setattr(runtime, "NativeRuntime", _FakeNative) + resolver = lambda path: None + dw = DataWeave(resolve_module=resolver) + dw.initialize() + # Second call must be a harmless no-op, not raise "Cannot install a resolver after initialize()". + dw.initialize() + assert dw._native.initialized is True + assert dw._native.install_resolver_calls == 1 + dw.cleanup() + + @pytest.mark.unit def test_run_routes_through_engine(monkeypatch): monkeypatch.setattr(runtime, "NativeRuntime", _FakeNative) From ce67fd0c26f533d16c263c9b5ed724a3d019471d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 15:43:11 -0300 Subject: [PATCH 157/216] fix(python): serialize per-instance initialize()/cleanup() to prevent engine/isolate over-count (review #10 #2) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/native.py | 77 +++++++++++++-------- native-lib/python/tests/unit/test_native.py | 63 ++++++++++++++++- 2 files changed, 112 insertions(+), 28 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index ca051ac8..ca3dbde1 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -283,34 +283,45 @@ def __init__(self, lib_path: Optional[str] = None): self._resolver_active_ident = None self._resolver_lock = Lock() self._execution_owner = None + # Guards this instance's initialize()/cleanup() lifecycle transitions + # (the initialized-check -> acquire -> create-engine -> publish + # sequence, and cleanup()'s initialized-clearing prologue) so two + # threads calling initialize() on the SAME instance cannot both pass + # the check, both acquire (refcount over-count), and both create an + # engine. Never held across a long native execution call -- only + # instance lifecycle transitions. + self._init_lock = Lock() def initialize(self) -> None: if self.initialized: return - acquired = False - try: - self.lib, self.isolate = _acquire_isolate(self.lib_path) - acquired = True - self.handle = self._create_engine() - except Exception: - # Roll back the ref we just took (if any) so a failed init leaks - # nothing. - self.lib = self.isolate = None - # Finding #2: install_resolver() registered a token BEFORE this call. - # A failed init must unregister it, or it leaks: self.initialized stays - # False, so a later cleanup() returns early and never reaches the pop. - if self._resolver_token: - with _resolver_lock_global: - _resolver_registry.pop(self._resolver_token, None) - self._resolver_token = 0 - # Release the ref only if _acquire_isolate actually incremented it - # (a library-load / isolate-create / bootstrap-detach failure inside - # _acquire_isolate never increments the refcount, so releasing here - # unconditionally would decrement someone else's live reference). - if acquired: - _release_isolate() - raise - self.initialized = True + with self._init_lock: + if self.initialized: + return + acquired = False + try: + self.lib, self.isolate = _acquire_isolate(self.lib_path) + acquired = True + self.handle = self._create_engine() + except Exception: + # Roll back the ref we just took (if any) so a failed init leaks + # nothing. + self.lib = self.isolate = None + # Finding #2: install_resolver() registered a token BEFORE this call. + # A failed init must unregister it, or it leaks: self.initialized stays + # False, so a later cleanup() returns early and never reaches the pop. + if self._resolver_token: + with _resolver_lock_global: + _resolver_registry.pop(self._resolver_token, None) + self._resolver_token = 0 + # Release the ref only if _acquire_isolate actually incremented it + # (a library-load / isolate-create / bootstrap-detach failure inside + # _acquire_isolate never increments the refcount, so releasing here + # unconditionally would decrement someone else's live reference). + if acquired: + _release_isolate() + raise + self.initialized = True def _create_engine(self) -> int: with self._current_thread_attachment(self.thread) as thread: @@ -460,9 +471,21 @@ def _resolver_scope(self): def cleanup(self) -> None: with self._serialized_native_operation(): - if not self.initialized: - return - self.initialized = False + # _init_lock is nested INSIDE _resolver_lock here (never the + # reverse -- initialize() only ever takes _init_lock alone, and + # never takes _resolver_lock), so there is no lock-ordering + # inversion between the two. Only the initialized-clearing flag + # flip needs the lock; the actual destroy/release below stays + # outside it, guarded by _serialized_native_operation as before. + # Mirrors _serialized_native_operation's own hasattr guard below: + # some unit tests build a NativeRuntime via __new__ and set + # attributes directly, bypassing __init__. + if not hasattr(self, "_init_lock"): + self._init_lock = Lock() + with self._init_lock: + if not self.initialized: + return + self.initialized = False try: if self.handle: with self._current_thread_attachment(self.thread) as thread: diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 66d3b4f0..668bb7cc 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -1,6 +1,6 @@ from pathlib import Path import ctypes -from threading import current_thread, get_ident, Thread +from threading import Barrier, BrokenBarrierError, current_thread, get_ident, Thread import pytest @@ -886,3 +886,64 @@ def test_failed_acquire_with_resolver_unregisters_the_token(monkeypatch): assert runtime._resolver_token == 0 assert native._isolate_ref_count == 0 assert native._isolate is None + + +@pytest.mark.unit +def test_concurrent_initialize_on_one_instance_creates_a_single_engine(monkeypatch): + # Finding (review #10 #2): initialize() has no instance-level lock spanning + # the initialized-check -> _acquire_isolate -> _create_engine -> publish + # sequence. Two threads calling initialize() on the SAME instance can both + # pass the check, both acquire (refcount over-counts), and both create an + # engine -- the second self.handle write orphans the first, and cleanup() + # then releases only one ref. + library = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + runtime = native.NativeRuntime("/tmp/dwlib") + + # A 2-party barrier with a timeout, patched into the instance's + # _create_engine. On the UNFIXED code both threads pass the `if + # self.initialized: return` fast-path concurrently and reach here at + # roughly the same time, so the barrier is satisfied and both proceed to + # create an engine (reproducing the over-count). On the FIXED + # (per-instance-locked) code only one thread is ever inside initialize() + # at a time, so the second party never arrives here before the timeout; + # the wait times out, the barrier breaks, and the lone thread just + # proceeds -- this must NOT deadlock the fixed code. + barrier = Barrier(2) + orig_create_engine = runtime._create_engine + + def slow_create_engine(): + try: + barrier.wait(timeout=0.5) + except BrokenBarrierError: + pass + return orig_create_engine() + + monkeypatch.setattr(runtime, "_create_engine", slow_create_engine) + + errors = [] + + def call_initialize(): + try: + runtime.initialize() + except BaseException as error: # pragma: no cover - surfaced via assert + errors.append(error) + + threads = [Thread(target=call_initialize) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(5) + + assert not any(thread.is_alive() for thread in threads), "initialize() deadlocked" + assert not errors + assert runtime.initialized is True + # Exactly one acquire, exactly one engine -- no over-count regardless of + # how the two calls interleaved. + assert native._isolate_ref_count == 1 + assert len(library.created_engines) == 1 + assert runtime.handle == library.created_engines[0][0] + + runtime.cleanup() + assert native._isolate_ref_count == 0 From 4389613fc953499ccf3f6f1786cdc829de2f5d5a Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 16:07:03 -0300 Subject: [PATCH 158/216] fix(node): retain engine bridge when destroy is skipped on attach failure (review #10 svacas P1) bridge_finalize_registry now returns a bool: true when the engine was actually destroyed or the whole isolate is going away (TEARING_DOWN / g_isolate == NULL, so the Java registry dies with it), false when destroy was SKIPPED while the isolate is still live (fn_attach_thread failed). bridge_finalize frees the bridge only on true; on false it retains the bridge on a new g_stranded_bridges list so its ctx stays valid -- the Java CallbackWeaveResourceResolver still points at it, and freeing early reopened a use-after-free in a later run_script_engine -> resolve_module_callback. The stranded list is drained (destroy retried, then freed) at the top of napi_initialize and at each op-completion path; the drain does only Graal calls + list manipulation + free (env_still_alive=false, no thread-affine napi call), so it is safe from any thread. The two create-hook-failure sites now route through bridge_finalize too, closing the same latent UAF. All new shared state is read/written under g_mutex. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 145 ++++++++++++++++++++++++++++++++---- 1 file changed, 131 insertions(+), 14 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 6a1ecd4c..cb325f7a 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -108,6 +108,19 @@ typedef struct engine_bridge { } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex +// Round-15 (svacas P1): bridges whose engine destroy was SKIPPED because +// fn_attach_thread failed while the isolate was STILL LIVE. Such a bridge must +// NOT be freed: the Java-side CallbackWeaveResourceResolver still holds it as +// its ctx word, so freeing it would leave a dangling ctx that a later +// run_script_engine -> resolve_module_callback dereferences (UAF). Retain the +// bridge here (linked via its own `next`, which is free once the bridge is +// unlinked from g_bridges -- every bridge_finalize call site unlinks first) so +// its ctx stays valid, and retry the destroy + free at the next drain point +// (top of napi_initialize, or an op-completion path) once the isolate is +// confirmed live and attachable -- or, if the isolate went away, free it then +// (the Java registry died with the isolate). All access under g_mutex. +static engine_bridge_t* g_stranded_bridges = NULL; // linked list, guarded by g_mutex + // One record per napi_env that has ever taken an init reference (via // initialize()). init_refs is that env's net initialize()-minus-cleanup() // balance. Created lazily on the env's first initialize(); registers exactly @@ -218,6 +231,19 @@ static engine_bridge_t* bridge_find(long long handle) { return NULL; } +// Round-15 (svacas P1): retain a bridge whose engine destroy was skipped while +// the isolate was still live (see g_stranded_bridges). The ctx word Java holds +// stays valid until a later drain retries the destroy and frees it. Takes +// g_mutex; the caller MUST have already unlinked `b` from g_bridges (its `next` +// is reused for the stranded list) and MUST NOT hold g_mutex. +static void bridge_retain_stranded(engine_bridge_t* b) { + if (b == NULL) return; + uv_mutex_lock(&g_mutex); + b->next = g_stranded_bridges; + g_stranded_bridges = b; + uv_mutex_unlock(&g_mutex); +} + // Find this env's init record, or NULL. Caller MUST hold g_mutex. static env_init_rec_t* env_init_rec_find_locked(napi_env env) { for (env_init_rec_t* r = g_env_recs; r != NULL; r = r->next) { @@ -287,33 +313,51 @@ static int env_init_refs_total_locked(void) { // attach. The teardown-state check and the g_active_ops++ are ONE critical // section: no teardown path can interleave between "isolate is live" and // "reservation taken". Callable from any thread NOT holding g_mutex. -static void bridge_finalize_registry(engine_bridge_t* b) { - if (b == NULL || fn_destroy_engine == NULL) return; +// +// Returns TRUE when the caller may safely free the bridge: the engine was +// actually destroyed (registry entry removed), OR the whole isolate is going +// away (TEARING_DOWN / g_isolate == NULL) so the Java registry -- and the +// CallbackWeaveResourceResolver holding this bridge as its ctx -- dies with it. +// Returns FALSE only when the destroy was SKIPPED while the isolate is still +// live (fn_attach_thread failed): the Java registry still holds this bridge as a +// resolver ctx, so freeing it now would be a UAF. The caller must instead retain +// the bridge (bridge_retain_stranded) and retry later (round-15, svacas P1). +static bool bridge_finalize_registry(engine_bridge_t* b) { + if (b == NULL || fn_destroy_engine == NULL) return true; uv_mutex_lock(&g_mutex); // If the waiter already committed to physical teardown (TEARING_DOWN) or the // isolate is already gone, the Java registry died/dies with it -- nothing to - // remove, and attaching would race graal_tear_down_isolate. Skip. Because - // the waiter publishes TEARING_DOWN (and Case 4 holds g_mutex across its - // g_active_ops==0 check + teardown) under this same lock, this check plus the - // increment below cannot be split by a teardown. + // remove, and attaching would race graal_tear_down_isolate. Skip, but report + // "safe to free": the registry entry is (being) reclaimed with the isolate, + // so the resolver ctx can no longer be dereferenced. Because the waiter + // publishes TEARING_DOWN (and Case 4 holds g_mutex across its g_active_ops==0 + // check + teardown) under this same lock, this check plus the increment below + // cannot be split by a teardown. if (g_teardown_state == TEARDOWN_TEARING_DOWN || g_isolate == NULL) { uv_mutex_unlock(&g_mutex); - return; + return true; } g_active_ops++; // pins the live isolate against teardown for this attach uv_mutex_unlock(&g_mutex); void* thread = NULL; - if (fn_attach_thread(g_isolate, &thread) == 0) { + bool destroyed = false; + if (fn_attach_thread(g_isolate, &thread) == 0 && thread != NULL) { fn_destroy_engine(thread, b->handle); fn_detach_thread(thread); + destroyed = true; // registry entry removed -> resolver ctx is now dead } + // else: attach failed while the isolate is STILL LIVE -- destroy was skipped, + // the Java registry still holds this bridge as a resolver ctx. Report FALSE so + // the caller retains (does NOT free) the bridge. // Verbatim g_active_ops release pattern. uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + + return destroyed; } // The non-isolate finalize phase: delete the resolver napi_ref (owner JS thread @@ -331,13 +375,58 @@ static void bridge_finalize_free(engine_bridge_t* b, bool env_still_alive) { // Thin wrapper preserving the original signature and every call site. Registry // removal (if requested) runs first under its transient reservation, then the -// record is freed. +// record is freed -- but round-15 (svacas P1) makes the free CONDITIONAL on the +// registry removal succeeding. If do_registry_remove is requested and the +// destroy was SKIPPED while the isolate is still live, bridge_finalize_registry +// returns false: the Java registry still holds this bridge as a resolver ctx, so +// we must NOT free it. Retain it (bridge_retain_stranded) so the ctx stays valid +// and a later drain retries the destroy and frees it. When do_registry_remove is +// false there is nothing registered (handle <= 0 construction failures), so the +// free is unconditional as before. static void bridge_finalize(engine_bridge_t* b, bool env_still_alive, bool do_registry_remove) { if (b == NULL) return; - if (do_registry_remove) bridge_finalize_registry(b); + if (do_registry_remove && !bridge_finalize_registry(b)) { + bridge_retain_stranded(b); // keep ctx valid; retry destroy + free later + return; + } bridge_finalize_free(b, env_still_alive); } +// Round-15 (svacas P1): retry destroy for every bridge stranded because its +// engine destroy was skipped on a transient fn_attach_thread failure while the +// isolate was live (see g_stranded_bridges). Detach the whole list under g_mutex, +// then for each bridge retry the isolate registry removal via +// bridge_finalize_registry: on success (or the isolate having since gone away) +// free the record; on repeated failure re-retain it for the next drain. Does +// ONLY GraalVM calls (attach/destroy/detach, inside bridge_finalize_registry) + +// list manipulation + free -- NO napi env-affine calls. In particular the free +// passes env_still_alive=false: this drain may run on a thread that is NOT the +// bridge's owner (e.g. another env's napi_initialize, or a background worker), +// so it must not touch the thread-affine napi_ref; Node reclaims that ref when +// the owner env is destroyed. Safe to call from any thread NOT holding g_mutex. +static void drain_stranded_bridges(void) { + uv_mutex_lock(&g_mutex); + engine_bridge_t* list = g_stranded_bridges; + g_stranded_bridges = NULL; + uv_mutex_unlock(&g_mutex); + + while (list != NULL) { + engine_bridge_t* b = list; + list = list->next; // snapshot the link before b is freed or re-retained + b->next = NULL; + if (bridge_finalize_registry(b)) { + // Registry entry removed (or isolate gone): the resolver ctx is dead, + // so freeing is safe. Skip the napi_ref delete (env_still_alive=false) + // -- we may not be on the owner thread. + bridge_finalize_free(b, /*env_still_alive=*/false); + } else { + // Still could not attach (isolate live, transient failure): keep the + // ctx valid and retry at the next drain. + bridge_retain_stranded(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 @@ -583,6 +672,14 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { size_t len; napi_get_value_string_utf8(env, argv[0], lib_path, sizeof(lib_path), &len); + // Round-15 (svacas P1): retry any bridge whose engine destroy was skipped on a + // transient attach failure (g_stranded_bridges). Drain before taking g_mutex + // (drain_stranded_bridges locks internally). If a live isolate survives from a + // prior init the retry destroys + frees it now; if the isolate is gone the + // stranded bridges are freed (their Java registry died with it). Cheap no-op + // when nothing is stranded. + drain_stranded_bridges(); + uv_mutex_lock(&g_mutex); // A prior last-release could not tear the isolate down and armed the retry @@ -915,6 +1012,11 @@ static void streaming_thread_fn(void* arg) { retry_stranded_teardown_locked(); uv_mutex_unlock(&g_mutex); + // Round-15 (svacas P1): op-completion drain point -- retry destroy for any + // bridge stranded on a transient attach failure. Graal-only + free, no napi + // env call, so it is safe on this background worker thread. + drain_stranded_bridges(); + // 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 @@ -1412,6 +1514,11 @@ static void transform_thread_fn(void* arg) { retry_stranded_teardown_locked(); uv_mutex_unlock(&g_mutex); + // Round-15 (svacas P1): op-completion drain point -- retry destroy for any + // bridge stranded on a transient attach failure. Graal-only + free, no napi + // env call, so it is safe on this background worker thread. + drain_stranded_bridges(); + // 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 @@ -1925,8 +2032,10 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { engine_bridge_t** pp = &g_bridges; while (*pp != NULL) { if (*pp == rec) { *pp = rec->next; break; } pp = &(*pp)->next; } uv_mutex_unlock(&g_mutex); - bridge_finalize_registry(rec); - bridge_finalize_free(rec, /*env_still_alive=*/true); + // round-15 (svacas P1): go through bridge_finalize (do_registry_remove=true) + // so a destroy skipped on a transient attach failure retains the record for + // retry instead of freeing it while the Java registry still references it. + bridge_finalize(rec, /*env_still_alive=*/true, /*do_registry_remove=*/true); uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to register engine cleanup hook"); return NULL; @@ -2023,8 +2132,10 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i engine_bridge_t** pp = &g_bridges; while (*pp != NULL) { if (*pp == bridge) { *pp = bridge->next; break; } pp = &(*pp)->next; } uv_mutex_unlock(&g_mutex); - bridge_finalize_registry(bridge); - bridge_finalize_free(bridge, /*env_still_alive=*/true); + // round-15 (svacas P1): go through bridge_finalize (do_registry_remove=true) + // so a destroy skipped on a transient attach failure retains the bridge for + // retry instead of freeing it while the Java registry still references it. + bridge_finalize(bridge, /*env_still_alive=*/true, /*do_registry_remove=*/true); uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to register engine cleanup hook"); return NULL; @@ -2219,6 +2330,12 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) bridge_end_op(bridge, /*env_still_alive=*/true); uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + // Round-15 (svacas P1): op-completion drain point -- retry destroy for any + // bridge stranded on a transient attach failure (Graal-only + free, no napi + // env call). This is the synchronous raw-FFI path whose resolve_module_callback + // is the UAF the retain fix protects. + drain_stranded_bridges(); + 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); } From 545cba73ad5140a62897b3188c60b721ec0e305a Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 16:21:45 -0300 Subject: [PATCH 159/216] fix(node): pre-allocate streaming/transform completion sentinel so OOM fails synchronously instead of hanging the promise (review #10 svacas P2) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 109 ++++++++++++++++++++++++------------ 1 file changed, 74 insertions(+), 35 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index cb325f7a..eed1b919 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -883,6 +883,13 @@ struct streaming_work { // 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; + // review #10 (svacas P2): the completion sentinel, pre-allocated in the + // synchronous setup path (napi_run_script_streaming_engine) so the worker's + // terminal path is allocation-free and can ALWAYS enqueue completion. If it + // were malloc'd on the worker instead, a NULL return there forced a return + // WITHOUT enqueuing -- but the env is alive on OOM, so the promise would + // never settle and the tsfn would never be released: a permanent hang. + struct chunk_data* sentinel; }; static void call_js_write(napi_env env, napi_value js_callback, void* context, void* data) { @@ -1017,21 +1024,17 @@ static void streaming_thread_fn(void* arg) { // env call, so it is safe on this background worker thread. drain_stranded_bridges(); - // 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; - } + // review #10 (svacas P2): the completion sentinel was pre-allocated in the + // synchronous setup path (napi_run_script_streaming_engine) and carried on + // w->sentinel, so this terminal path is ALLOCATION-FREE and the completion + // enqueue + tsfn release always run. The old code malloc'd the sentinel HERE + // and, on NULL, freed w and returned WITHOUT enqueuing -- but the env is + // alive on OOM (not the napi_closing case), so the promise never settled and + // the tsfn was never released: a permanent hang. Pre-allocating removes that + // failure mode entirely. (meta_result above uses the OOM_JSON static fallback + // on strdup failure, so it is always a valid C string and never gates the + // enqueue either.) + struct chunk_data* sentinel = w->sentinel; sentinel->buf = meta_result; sentinel->len = -1; napi_status enq = napi_call_threadsafe_function(w->tsfn, sentinel, napi_tsfn_blocking); @@ -1198,12 +1201,29 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i return NULL; } + // review #10 (svacas P2): pre-allocate the completion sentinel HERE, in the + // synchronous setup path on the owner JS thread, before the worker is + // spawned -- so the worker's terminal completion path is allocation-free and + // can ALWAYS enqueue completion + release the tsfn. On NULL, unwind exactly + // like the promise-creation path below (release the tsfn, which holds w as + // its context; free w + buffers; release the pin and g_active_ops) and throw + // synchronously. This mirrors napi_run_script_engine's "OOM" throw. + w->sentinel = malloc(sizeof(struct chunk_data)); + if (w->sentinel == NULL) { + napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); + free(w->script); free(w->inputs_json); free(w); + bridge_end_op(pinned, /*env_still_alive=*/true); + 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; + } + napi_value 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); + free(w->sentinel); free(w->script); free(w->inputs_json); free(w); bridge_end_op(pinned, /*env_still_alive=*/true); 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"); @@ -1240,6 +1260,7 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i 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->sentinel); free(w->script); free(w->inputs_json); free(w); @@ -1266,6 +1287,11 @@ struct transform_work { // 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; + // review #10 (svacas P2): the completion sentinel, pre-allocated in the + // synchronous setup path (napi_run_script_transform_engine) so the worker's + // terminal path is allocation-free and can ALWAYS enqueue completion. See + // the same field on struct streaming_work for the hang this prevents. + struct chunk_data* sentinel; }; struct read_request { @@ -1519,24 +1545,18 @@ static void transform_thread_fn(void* arg) { // env call, so it is safe on this background worker thread. drain_stranded_bridges(); - // 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; - } + // review #10 (svacas P2): the completion sentinel was pre-allocated in the + // synchronous setup path (napi_run_script_transform_engine) and carried on + // w->sentinel, so this terminal path is ALLOCATION-FREE and the completion + // enqueue + tsfn release always run. The old code malloc'd the sentinel HERE + // and, on NULL, freed w and returned WITHOUT enqueuing -- but the env is + // alive on OOM (not the napi_closing case), so the promise never settled and + // the tsfn was never released: a permanent hang. Removing the allocation + // (rather than releasing the tsfn here, which the enq-failure branch below + // documents as unsafe) is what makes the enqueue unconditional. (meta_result + // above uses the OOM_JSON static fallback on strdup failure, so it is always + // a valid C string and never gates the enqueue either.) + struct chunk_data* sentinel = w->sentinel; sentinel->buf = meta_result; sentinel->len = -1; napi_status enq = napi_call_threadsafe_function(w->write_tsfn, sentinel, napi_tsfn_blocking); @@ -1729,11 +1749,29 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i return NULL; } + // review #10 (svacas P2): pre-allocate the completion sentinel HERE, in the + // synchronous setup path on the owner JS thread, before the worker is + // spawned -- so the worker's terminal completion path is allocation-free and + // can ALWAYS enqueue completion + release the tsfn. On NULL, unwind exactly + // like the promise-creation path below (release both tsfns; free w + all five + // string buffers; release the pin and g_active_ops) and throw synchronously. + // This mirrors napi_run_script_engine's "OOM" throw. + w->sentinel = malloc(sizeof(struct chunk_data)); + if (w->sentinel == NULL) { + 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); + bridge_end_op(pinned, /*env_still_alive=*/true); + 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; + } + napi_value 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); + free(w->sentinel); free(w->script); free(w->inputs_json); free(w->input_name); free(w->input_mime_type); free(w->input_charset); free(w); bridge_end_op(pinned, /*env_still_alive=*/true); 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"); @@ -1772,6 +1810,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i 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->sentinel); free(w->script); free(w->inputs_json); free(w->input_name); From dca0569ff97e6dc0bffe91d8ea8950c08bfbd2c4 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 17:36:18 -0300 Subject: [PATCH 160/216] fix(node): validate raw napi_initialize lib-path argument (review #10 #5, svacas P2) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 22 +++++++++++++++---- .../integration/malformed-inputs.test.ts | 22 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index eed1b919..e6c7c297 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -661,16 +661,30 @@ static void retry_stranded_teardown_locked(void); static napi_value napi_initialize(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) { + // Review #10 #5 (svacas P2): check napi_get_cb_info's status too, not just + // argc -- mirrors every other validated entrypoint in this file (e.g. + // napi_run_script_engine), which never assumes an N-API call succeeded. + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc < 1) { napi_throw_error(env, NULL, "initialize requires a library path argument"); return NULL; } + // Reject a non-string argv[0] before touching the stack lib_path buffer + // below. Without this, a non-string argument left napi_get_value_string_utf8's + // status ignored and lib_path uninitialized/partially-written before + // uv_dlopen read it (garbage path, occasionally UB). + napi_valuetype vt; + if (napi_typeof(env, argv[0], &vt) != napi_ok || vt != napi_string) { + napi_throw_error(env, NULL, "initialize: library path must be a string"); + return NULL; + } + char lib_path[4096]; size_t len; - napi_get_value_string_utf8(env, argv[0], lib_path, sizeof(lib_path), &len); + if (napi_get_value_string_utf8(env, argv[0], lib_path, sizeof(lib_path), &len) != napi_ok) { + napi_throw_error(env, NULL, "initialize: failed to read library path"); + return NULL; + } // Round-15 (svacas P1): retry any bridge whose engine destroy was skipped on a // transient attach failure (g_stranded_bridges). Drain before taking g_mutex diff --git a/native-lib/node/tests/integration/malformed-inputs.test.ts b/native-lib/node/tests/integration/malformed-inputs.test.ts index 2a6194b0..5d565da0 100644 --- a/native-lib/node/tests/integration/malformed-inputs.test.ts +++ b/native-lib/node/tests/integration/malformed-inputs.test.ts @@ -19,6 +19,28 @@ describe("malformed raw-ffi inputs throw (round 7 #2)", () => { await ffi.cleanup(); }); + // Review #10 #5 (svacas P2): napi_initialize used to ignore the status of + // napi_get_cb_info and napi_get_value_string_utf8 and never checked that + // argv[0] is a string, so a non-string libPath left the 4096-byte stack + // lib_path buffer uninitialized before uv_dlopen used it. The TS wrapper + // always passes a string, so drive this through the raw ffi binding + // directly with each malformed shape and assert it throws synchronously + // (and the process survives) rather than reading the uninitialized buffer. + it.each([ + { name: "number", value: 42 }, + { name: "object", value: {} }, + { name: "null", value: null }, + ])("initialize throws synchronously on a non-string libPath ($name)", ({ value }) => { + // Assert on the specific validation message, not just toThrow(): without + // the argv[0] type check, the garbage stack lib_path still happens to + // make uv_dlopen fail downstream, so a bare toThrow() would pass even on + // the unfixed addon for the wrong reason (an accidental "Failed to load + // library" error instead of a synchronous, pre-buffer-use rejection). + expect(() => ffi.initialize(value as unknown as string)).toThrow( + /library path must be a string/ + ); + }); + it("destroyEngine throws on a non-integer handle", () => { ffi.initialize(findLibrary()); expect(() => ffi.destroyEngine({} as unknown as number)).toThrow(); From 973e8465baf7030dbc174cd8e9383c886ffc75dd Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 17:45:17 -0300 Subject: [PATCH 161/216] fix(node): guard unknown/torn-down handle cleanup against attaching a dead isolate; document deliberate cleanup()-resolves-on-teardown-failure (review #10 #5) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 67 +++++++++++++++++-- .../engine-handle-contract.test.ts | 35 ++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index e6c7c297..d51551e7 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2277,11 +2277,40 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { // 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); } + // stay robust to a double-destroy or an unknown handle): fall back to + // removing the Java registry entry directly. That removal requires + // attaching to the live isolate, so guard the attach EXACTLY like + // bridge_finalize_registry (review #10 #5): read g_isolate/g_teardown_state + // under g_mutex and, if the isolate is live, pin it with a TRANSIENT + // g_active_ops reservation so graal_tear_down_isolate() cannot run across + // the attach (the state check + the g_active_ops++ are one critical + // section). If the isolate is already gone (g_isolate == NULL) or the + // waiter has committed to physical teardown (TEARDOWN_TEARING_DOWN), the + // Java registry died/dies with the isolate -- there is nothing to remove + // and attaching would race the teardown, so return early / no-op safely. + // Without this guard an unknown-handle (or double-)destroyEngine racing a + // concurrent cleanup() teardown could call fn_attach_thread on a NULL or + // being-torn-down isolate. The unlocked g_initialized check at the top of + // this function is a stale read under concurrency and does NOT close this + // window; only the g_mutex-guarded read here does. + if (fn_destroy_engine && fn_attach_thread) { + uv_mutex_lock(&g_mutex); + if (g_teardown_state == TEARDOWN_TEARING_DOWN || g_isolate == NULL) { + uv_mutex_unlock(&g_mutex); // isolate gone/tearing down -> nothing to remove + } else { + g_active_ops++; // pins the live isolate against teardown for this attach + uv_mutex_unlock(&g_mutex); + void* thread = NULL; + if (fn_attach_thread(g_isolate, &thread) == 0 && thread != NULL) { + fn_destroy_engine(thread, handle); + fn_detach_thread(thread); + } + // Verbatim g_active_ops release pattern. + uv_mutex_lock(&g_mutex); + g_active_ops--; + uv_cond_broadcast(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); + } } } return NULL; @@ -2540,6 +2569,15 @@ static void teardown_waiter_thread_fn(void* arg) { // with nothing to reclaim it (review #6 #4). Mirrors the twin arm in // isolate_ref_release_n_locked's waiter-spawn-failure path. g_teardown_needed = true; + // Observable failure (review #10 #5): the deferred cleanup() promise is still + // RESOLVED below (via call_js_teardown_done -- deliberate, exactly as the + // synchronous Case 4 path resolves on failure), so emit a diagnostic or a + // failed async teardown would be silent. Parity with Python's _release_isolate + // stderr notice (native.py). + fprintf(stderr, + "[DataWeave Node addon] GraalVM isolate teardown failed on deferred " + "cleanup(); the isolate is retained and teardown will be retried on the " + "next initialize() or op completion.\n"); } // 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 @@ -2885,7 +2923,26 @@ static napi_value release_isolate_ref_locked(napi_env env) { // if no later op or initialize() ever runs, the isolate lingers to process // exit (OS reclaims it) -- benign, no ref-count violation. g_teardown_needed = true; + // Make the failure OBSERVABLE (review #10 #5): the promise below still + // RESOLVES (see the deliberate-resolve note), so without a diagnostic a + // failed final teardown would be entirely silent. Mirrors the stderr notice + // Python emits in _release_isolate on the same failure (native.py). + fprintf(stderr, + "[DataWeave Node addon] GraalVM isolate teardown failed on cleanup(); " + "the isolate is retained and teardown will be retried on the next " + "initialize() or op completion.\n"); } + // Deliberate design (review #10 #5): cleanup() RESOLVES even when the final + // Graal teardown failed above -- it does NOT reject. Teardown failure is a + // recoverable, retryable condition (the isolate is retained and + // g_teardown_needed is armed for a later retry), not a caller error, and this + // file never uses napi_reject_deferred: run/streaming/transform failures also + // surface as RESOLVED values. Rejecting here would break the isolate + // adoption/coalescing contract (a still-live PENDING_WAIT isolate a concurrent + // initialize() may adopt) and the existing cleanup() tests. The failure stays + // observable via the armed retry + the stderr diagnostic above. Parity: + // Python's _release_isolate arms _teardown_needed and logs to stderr on the + // same failure rather than surfacing a hard error (native.py). uv_mutex_unlock(&g_mutex); return already_resolved_promise(env); } diff --git a/native-lib/node/tests/integration/engine-handle-contract.test.ts b/native-lib/node/tests/integration/engine-handle-contract.test.ts index 04a192c2..e46ac9d9 100644 --- a/native-lib/node/tests/integration/engine-handle-contract.test.ts +++ b/native-lib/node/tests/integration/engine-handle-contract.test.ts @@ -256,6 +256,35 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { ffi.destroyEngine(h2); }); + it("destroyEngine on an unknown / already-destroyed handle is a safe no-op and leaves the isolate healthy (review #10 #5)", () => { + // Drives napi_destroy_engine's `found == NULL` else branch on a LIVE isolate + // (the shared beforeAll isolate). Round-10 #5 added a teardown-state guard + // there so the direct registry-removal attach reads g_isolate/g_teardown_state + // under g_mutex and pins the isolate before fn_attach_thread, mirroring + // bridge_finalize_registry. On a live isolate this is a benign no-op; the test + // asserts it does not throw or wedge the isolate. (The crash it guards against + // -- fn_attach_thread on a NULL or TEARDOWN_TEARING_DOWN isolate -- only arises + // when destroyEngine races a concurrent cleanup() teardown, a non-deterministic + // cross-thread window not reproducible through this single-threaded public API; + // see this file's note above on best-effort race coverage.) + expect(() => ffi.destroyEngine(UNKNOWN_HANDLE)).not.toThrow(); + + // Double-destroy: the second call finds no record and takes the same + // else branch. Must not throw or corrupt the isolate. + const handle = ffi.createEngine(); + expect(() => ffi.destroyEngine(handle)).not.toThrow(); + expect(() => ffi.destroyEngine(handle)).not.toThrow(); + + // Isolate remains fully usable after both no-op destroys. + const h2 = ffi.createEngine(); + const envelope = JSON.parse( + ffi.runScriptEngine(h2, "%dw 2.0\noutput application/json\n---\n3 + 4", buildInputsJson({})) + ); + expect(envelope.success).toBe(true); + expect(JSON.parse(Buffer.from(envelope.result, "base64").toString("utf-8"))).toBe(7); + ffi.destroyEngine(h2); + }); + it("final cleanup drains the shared isolate (idempotent)", async () => { // Exactly one ffi.initialize() ran for this whole file (beforeAll), so // this is the ONE balancing ffi.cleanup() that brings the native @@ -271,6 +300,12 @@ describe("*_engine unknown/destroyed-handle contract (round 11 #6)", () => { ffi.runScriptEngine(UNKNOWN_HANDLE, "%dw 2.0\noutput application/json\n---\n1", buildInputsJson({})) ).toThrow(/not initialized/i); + // review #10 #5: with the isolate torn down (g_isolate == NULL, + // g_initialized == 0), destroyEngine on an unknown handle must be a safe + // no-op and must NOT attach to the now-NULL global isolate -- it returns + // early at the !g_initialized guard. The process must survive. + expect(() => ffi.destroyEngine(UNKNOWN_HANDLE)).not.toThrow(); + // A second cleanup() call after the ref count already reached zero must // remain a safe no-op, mirroring independent-engines.test.ts's final // teardown discipline. From 487882416d4e08b26adf2bb87c5b3939848724f6 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 18:03:19 -0300 Subject: [PATCH 162/216] fix(java): cancel-then-join transform feeder before returning so a slow read callback cannot be invoked after callback state is freed (review #10 #1 Critical) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/org/mule/weave/lib/NativeLib.java | 132 +++++++++++++++--- .../mule/weave/lib/NativeLibFeederTest.java | 98 +++++++++++++ 2 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java 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 4d596b6e..f80a28b6 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 @@ -119,8 +119,9 @@ private static CCharPointer transformViaCallbacks( // raw addresses and reconstitutes them via WordFactory. final long readCallbackAddr = readCallback.rawValue(); final long ctxAddr = ctx.rawValue(); - Thread feeder = new Thread(new InputCallbackFeeder( - readCallbackAddr, ctxAddr, inputSession), "dw-input-callback-feeder"); + InputCallbackFeeder feederRunnable = new InputCallbackFeeder( + readCallbackAddr, ctxAddr, inputSession); + Thread feeder = new Thread(feederRunnable, "dw-input-callback-feeder"); feeder.setDaemon(true); feeder.start(); @@ -128,7 +129,7 @@ private static CCharPointer transformViaCallbacks( StreamSession session = runtime.runStreaming(dwScript, mergedInputs); if (session.isError()) { - cleanupFeeder(feeder, inputHandle); + cleanupFeeder(feederRunnable, feeder, inputHandle); return toUnmanagedCString("{\"success\":false,\"error\":\"" + escapeJsonString(session.getError()) + "\"}"); } @@ -144,7 +145,7 @@ private static CCharPointer transformViaCallbacks( } int rc = writeCallback.invoke(ctx, writeBuf, n); if (rc != 0) { - cleanupFeeder(feeder, inputHandle); + cleanupFeeder(feederRunnable, feeder, inputHandle); return toUnmanagedCString("{\"success\":false,\"error\":\"" + "Write callback returned error: " + rc + "\"}"); } @@ -153,14 +154,14 @@ private static CCharPointer transformViaCallbacks( UnmanagedMemory.free(writeBuf); } } catch (IOException e) { - cleanupFeeder(feeder, inputHandle); + cleanupFeeder(feederRunnable, feeder, inputHandle); return toUnmanagedCString("{\"success\":false,\"error\":\"" + escapeJsonString(e.getMessage()) + "\"}"); } finally { session.closeStream(); } - cleanupFeeder(feeder, inputHandle); + cleanupFeeder(feederRunnable, feeder, inputHandle); return toUnmanagedCString("{\"success\":true" + ",\"mimeType\":\"" + session.getMimeType() + "\"" @@ -181,14 +182,54 @@ private static String mergeInputEntry(String existingJson, String name, String e } /** - * Waits for the feeder thread to finish and closes the input session. + * Cancels the input feeder, waits for it to fully exit {@link InputCallbackFeeder#run()} + * (including its {@code finally} block), and closes the input session. + * + *

Why this must not abandon a live feeder: once this method returns, + * {@link #transformViaCallbacks} returns to its {@code @CEntryPoint}, which returns to the + * native caller — at which point the caller is free to release the callback state + * ({@code ctx}). If the feeder thread were still alive it could invoke + * {@code readCallback(ctx, …)} against freed memory, a native use-after-free. Therefore this + * method may only return once {@code thread.isAlive() == false}.

+ * + *

Order (all three are part of stopping the feeder):

+ *
    + *
  1. Signal cancel — {@link InputCallbackFeeder#cancel()} sets a volatile flag the + * loop checks immediately after each {@code readCallback} invocation returns and before + * re-invoking it, so a slow-but-returning in-flight callback breaks the loop instead of + * being re-entered.
  2. + *
  3. Close the input session — this closes both ends of the pipe, which unblocks a + * feeder parked inside {@link InputStreamSession#write} on a full pipe (the next write + * throws {@link IOException} and breaks the loop). This is a legitimate part of the + * cancel signal for the pipe-backpressure case and is harmless on the success path, + * where the feeder has already reached EOF and exited. It also unregisters the handle.
  4. + *
  5. Join without a finite timeout — we wait for {@code run()} to complete rather + * than abandoning the thread after a bound. An {@link InterruptedException} does not end + * the wait (returning early would reopen the use-after-free window); we re-assert the + * interrupt and keep waiting.
  6. + *
+ * + *

Documented trade-off: cancellation guarantees we wait only for the + * in-flight {@code readCallback} to return — no signal can interrupt native code + * parked inside the caller's callback. A callback that blocks forever inside a single + * invocation therefore cannot be joined and this method would block indefinitely. That is the + * correct trade: the only alternative — abandoning a still-live feeder — is the + * use-after-free this method exists to prevent.

*/ - private static void cleanupFeeder(Thread feeder, long inputHandle) { - try { - feeder.join(5000); - } catch (InterruptedException ignored) { - } + static void cleanupFeeder(InputCallbackFeeder feederRunnable, Thread thread, long inputHandle) { + feederRunnable.cancel(); + // Unblock a feeder parked on a full pipe and drop the session from the registry. InputStreamSession.close(inputHandle); + boolean joined = false; + while (!joined) { + try { + thread.join(); + joined = true; + } catch (InterruptedException e) { + // Never abandon a live feeder: re-assert the interrupt and keep waiting. + Thread.currentThread().interrupt(); + } + } } /** @@ -201,11 +242,25 @@ private static void cleanupFeeder(Thread feeder, long inputHandle) { * *

The feeder allocates its own native read buffer and frees it in its {@code finally} * block, ensuring no shared native memory between threads.

+ * + *

Cancellation: {@link #cancel()} sets a {@code volatile} flag that + * {@link #run()} checks immediately after {@code readChunk} (the read callback) + * returns and before the next iteration re-invokes it. This is what closes the + * use-after-free window: once cancellation is requested, a callback that was blocked and then + * returns breaks the loop instead of being re-entered. See + * {@link NativeLib#cleanupFeeder} for the full stop protocol.

+ * + *

Package-private and non-final (rather than {@code private}) so a JVM unit test can + * subclass it and override {@link #readChunk} with a pure-Java blocking source, exercising the + * cancel/join contract without the GraalVM {@code Word}-type machinery + * ({@link WordFactory#pointer}, {@code cb.invoke}), which only initialises inside a compiled + * native image.

*/ - private static final class InputCallbackFeeder implements Runnable { + static class InputCallbackFeeder implements Runnable { private final long readCallbackAddr; private final long ctxAddr; private final InputStreamSession inputSession; + private volatile boolean cancelled = false; InputCallbackFeeder(long readCallbackAddr, long ctxAddr, InputStreamSession inputSession) { @@ -214,27 +269,60 @@ private static final class InputCallbackFeeder implements Runnable { this.inputSession = inputSession; } - @Override - public void run() { + /** Requests the feeder loop stop after the in-flight {@code readChunk} returns. */ + void cancel() { + cancelled = true; + } + + boolean isCancelled() { + return cancelled; + } + + /** + * Pulls the next input chunk from the caller-owned read callback into {@code dest}, + * returning the number of bytes read ({@code 0} = EOF, negative = error). + * + *

Reconstitutes the {@code Word}-typed callback and context from their raw addresses + * and copies the bytes out of a freshly allocated native scratch buffer. Overridable so + * JVM tests can supply a pure-Java implementation; production code never overrides it.

+ */ + int readChunk(byte[] dest, int max) { NativeCallbacks.ReadCallback cb = WordFactory.pointer(readCallbackAddr); PointerBase ctx = WordFactory.pointer(ctxAddr); - CCharPointer buf = UnmanagedMemory.malloc(CALLBACK_BUFFER_SIZE); + CCharPointer buf = UnmanagedMemory.malloc(max); + try { + int n = cb.invoke(ctx, buf, max); + if (n > 0) { + for (int i = 0; i < n; i++) { + dest[i] = buf.read(i); + } + } + return n; + } finally { + UnmanagedMemory.free(buf); + } + } + + @Override + public void run() { + byte[] tmp = new byte[CALLBACK_BUFFER_SIZE]; try { - while (true) { - int n = cb.invoke(ctx, buf, CALLBACK_BUFFER_SIZE); + while (!cancelled) { + int n = readChunk(tmp, CALLBACK_BUFFER_SIZE); if (n <= 0) { break; // 0 = EOF, negative = error } - byte[] tmp = new byte[n]; - for (int i = 0; i < n; i++) { - tmp[i] = buf.read(i); + // Check AFTER the callback returns and BEFORE re-invoking / writing: once + // cancelled, a slow-but-returning in-flight callback must not be re-entered + // (its ctx may be freed the moment cleanupFeeder returns). + if (cancelled) { + break; } inputSession.write(tmp, n); } } catch (IOException e) { // pipe broken – DW engine will see the error } finally { - UnmanagedMemory.free(buf); try { inputSession.closeWriter(); } catch (IOException ignored) { diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java new file mode 100644 index 00000000..05ecb1ef --- /dev/null +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -0,0 +1,98 @@ +package org.mule.weave.lib; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Contract test for {@link NativeLib#cleanupFeeder} + {@link NativeLib.InputCallbackFeeder} + * (review #10 #1, Critical): the transform input feeder must be cancelled and fully joined + * before {@code cleanupFeeder} returns, so a slow read callback that is still in-flight cannot + * be re-invoked after the native caller frees the callback state ({@code ctx}). + * + *

The real feeder pulls input via a GraalVM {@code Word}-typed function pointer + * ({@code cb.invoke}), which cannot be exercised from a hosted JVM test. We instead subclass + * {@link NativeLib.InputCallbackFeeder} and override {@link NativeLib.InputCallbackFeeder#readChunk} + * with a pure-Java stand-in that models a read callback which blocks (an in-flight + * {@code cb.invoke}) while cleanup runs.

+ */ +class NativeLibFeederTest { + + /** + * A read callback that always returns data (never EOF) and blocks ~500 ms per call, + * modelling a slow-but-returning in-flight {@code cb.invoke}. + * + *

Post-return invariant under test: after {@code cleanupFeeder} returns, the feeder thread + * is no longer alive (so it can never touch freed callback state), and the "callback" was not + * re-invoked after cancellation was requested.

+ * + *

Against the pre-fix code ({@code join(5000)} then abandon, no cancel signal, and the input + * session closed only after the join) the feeder loops forever writing chunks: the + * join times out with the thread still alive, {@code isAlive()} is {@code true}, and the + * invocation count is large — the test fails, demonstrating the use-after-free window.

+ */ + @Test + void cleanupFeederCancelsAndJoinsInFlightReadCallbackBeforeReturning() throws Exception { + InputStreamSession inputSession = new InputStreamSession("application/json", "UTF-8"); + long inputHandle = inputSession.register(); + + AtomicInteger invocations = new AtomicInteger(0); + CountDownLatch entered = new CountDownLatch(1); + + // Raw addresses are unused: readChunk is overridden and never reconstitutes them. + NativeLib.InputCallbackFeeder feeder = + new NativeLib.InputCallbackFeeder(0L, 0L, inputSession) { + @Override + int readChunk(byte[] dest, int max) { + invocations.incrementAndGet(); + entered.countDown(); + try { + // Simulate a slow in-flight cb.invoke that returns *after* cleanup + // has requested cancellation. + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + // Always return data (never EOF): pre-fix code would loop forever. + dest[0] = 'x'; + return 1; + } + }; + + Thread thread = new Thread(feeder, "test-input-callback-feeder"); + thread.setDaemon(true); + thread.start(); + + // Wait until the feeder is inside the (blocking) callback, then clean up while it blocks. + assertTrue(entered.await(2, TimeUnit.SECONDS), "feeder never entered the read callback"); + + long start = System.nanoTime(); + NativeLib.cleanupFeeder(feeder, thread, inputHandle); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + // Post-return invariant: the feeder has fully exited run() — no UAF window remains. + assertFalse(thread.isAlive(), + "cleanupFeeder returned while the feeder thread was still alive (use-after-free window)"); + assertTrue(feeder.isCancelled(), "cleanupFeeder must have signalled cancellation"); + + // The in-flight callback was allowed to return, but it was NOT re-invoked after cancel: + // exactly one invocation proves the loop checks the cancel flag after cb.invoke returns + // and before re-invoking it. + assertEquals(1, invocations.get(), + "read callback was re-invoked after cancellation (should break the loop instead)"); + + // Sanity: the join waited only for the in-flight callback (~500 ms), not a finite abandon + // timeout, and certainly did not hang. + assertTrue(elapsedMs < 4000, + "cleanupFeeder took unexpectedly long (" + elapsedMs + " ms)"); + + System.out.printf("cleanupFeeder returned after %d ms; invocations=%d, alive=%b%n", + elapsedMs, invocations.get(), thread.isAlive()); + } +} From e6bc5cdba9414bb13d211d345f17cd0a12e163f6 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 20:27:39 -0300 Subject: [PATCH 163/216] test(node): mark only the TCK output-equality assertion as expected-fail, not the whole test body (review #10 #6) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/tests/tck/tck.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/native-lib/node/tests/tck/tck.test.ts b/native-lib/node/tests/tck/tck.test.ts index 3691335a..01481272 100644 --- a/native-lib/node/tests/tck/tck.test.ts +++ b/native-lib/node/tests/tck/tck.test.ts @@ -111,7 +111,7 @@ if (!existsSync(SUITES_DIR)) { const ignored = isIgnored(c.caseIdentifier); for (const scenario of c.scenarios) { const expectedFailure = ACCEPTED_BASELINE_MISMATCHES[scenario.name]; - const testFn = ignored ? it.skip : expectedFailure ? it.fails : it; + const testFn = ignored ? it.skip : it; const label = ignored ? `${scenario.name} [skip: ${ignoreReason(c.caseIdentifier)}]` : expectedFailure @@ -137,7 +137,14 @@ if (!existsSync(SUITES_DIR)) { ? readFileSync(encodingFile, "utf-8").trim() : null; const cmp = compareOutput(scenario.outputExtension, actual, expected, charset); - expect(cmp.match, cmp.detail).toBe(true); + if (expectedFailure) { + expect( + cmp.match, + `expected baseline mismatch for ${scenario.name} ([xfail: ${expectedFailure}]) but output matched — remove it from ACCEPTED_BASELINE_MISMATCHES` + ).toBe(false); + } else { + expect(cmp.match, cmp.detail).toBe(true); + } }); } } From 3bd1ff12032ee019384ad30623f5d237dd834793 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 20:32:21 -0300 Subject: [PATCH 164/216] test(node): recategorize 6 execution-failure TCK cases from output-mismatch xfails to documented skips (review #10 #6) The narrowed xfail (parent commit) surfaced that 6 ACCEPTED_BASELINE_MISMATCHES entries fail at execution (result.success === false: multipart empty-parts, Cannot coerce Null) rather than producing differing output. it.fails had masked that distinction. All 6 are already listed in LEGACY_IGNORED_CASES with accurate reasons; removing them from ACCEPTED_BASELINE_MISMATCHES reverts them to honest, reconciliation-policy-checked skips via CAPABILITY_EXCLUSIONS. The 15 genuine output-mismatch xfails keep asserting cmp.match === false. TCK: 676 passed / 0 failed / 38 skipped / 15 xfailed. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/tests/tck/ignore-list.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/native-lib/node/tests/tck/ignore-list.ts b/native-lib/node/tests/tck/ignore-list.ts index e7598f56..8b683fc6 100644 --- a/native-lib/node/tests/tck/ignore-list.ts +++ b/native-lib/node/tests/tck/ignore-list.ts @@ -164,23 +164,17 @@ export const ACCEPTED_BASELINE_MISMATCHES: ExpectedFailurePolicy = { "core-modules/multipart-binary-out.multipart:out.multipart": "multipart writer output differs from the baseline fixture", "core-modules/multipart-class-cast-issue-out.multipart:out.multipart": "multipart writer output differs from the baseline fixture", "core-modules/multipart-empty-part-out.multipart:out.multipart": "multipart writer output differs from the baseline fixture", - "core-modules/multipart-mixed-message-out.multipart:out.multipart": "multipart writer output differs from the baseline fixture", - "core-modules/multipart-write-message-out.multipart:out.multipart": "multipart writer output differs from the baseline fixture", - "core-modules/multipart-write-subtype-override-out.multipart:out.multipart": "multipart writer output differs from the baseline fixture", "core-modules/properties-passthrough-out.properties:out.properties": "properties writer output differs from the baseline fixture", "core-modules/xml-escaped-data-out.xml:out.xml": "XML character escaping differs from the baseline fixture", "core-modules/xml-streaming-selectors-out.xml:out.xml": "streaming XML serialization differs from the baseline fixture", "core-modules/xml-value-selector-out.xml:out.xml": "XML namespace scoping differs from the baseline fixture", "core-modules/xml_empty_namespace-out.xml:out.xml": "empty XML namespace serialization differs from the baseline fixture", - "runtime/access_raw_value-out.json:out.json": "runtime coercion output differs from the baseline fixture", "runtime/coerciones_toString-out.json:out.json": "locale-sensitive runtime output differs from the baseline fixture", "runtime/properties-writer-out.properties:out.properties": "properties writer output differs from the baseline fixture", - "runtime/read-concat-out.json:out.json": "runtime coercion output differs from the baseline fixture", "runtime/runtime_dataFormatsDescriptors-out.json:out.json": "dw::Runtime output differs from the baseline fixture", "runtime/runtime_orElseTry-out.json:out.json": "source-location runtime output differs from the baseline fixture", "runtime/runtime_run-out.json:out.json": "dw::Runtime output differs from the baseline fixture", "runtime/try-recursive-call-out.json:out.json": "source-location runtime output differs from the baseline fixture", - "runtime/update-op-out.dwl:out.dwl": "runtime coercion output differs from the baseline fixture", }; export const REENABLED_CASES = [ From 0bb15f94a7a2c95763c31c2dfaac2dada020e70d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 20:39:24 -0300 Subject: [PATCH 165/216] test(node): fail the dedicated TCK job when the corpus is absent/empty (review #10 #7) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/tests/tck/tck.test.ts | 158 +++++++++++++++----------- 1 file changed, 92 insertions(+), 66 deletions(-) diff --git a/native-lib/node/tests/tck/tck.test.ts b/native-lib/node/tests/tck/tck.test.ts index 01481272..821de12d 100644 --- a/native-lib/node/tests/tck/tck.test.ts +++ b/native-lib/node/tests/tck/tck.test.ts @@ -27,6 +27,7 @@ import { const SUITES_DIR = join(__dirname, "suites"); const FIXTURES_DIR = join(__dirname, "fixtures"); +const REQUIRE_CORPUS = process.env.DATAWEAVE_TCK_REQUIRE_CORPUS === "1"; /** A discovered case: its directory and the scenarios parsed from it. */ interface DiscoveredCase { @@ -72,81 +73,106 @@ function discoverCases(): { return { cases, skipped, structuralModuleCases }; } +/** + * Registers a stand-in for the TCK suite when the corpus is missing/empty. + * In the dedicated CI job (DATAWEAVE_TCK_REQUIRE_CORPUS=1) this must be loud — + * a silent skip there would let the conformance lane go green with zero + * cases. Local dev without the flag keeps the quiet skip. + */ +function registerMissingCorpus(reason: string) { + if (REQUIRE_CORPUS) { + describe("TCK conformance", () => { + it("TCK corpus must be staged", () => { + throw new Error(`TCK corpus ${reason} but DATAWEAVE_TCK_REQUIRE_CORPUS=1 — stage it with stageTckSuites`); + }); + }); + } else { + describe.skip(`TCK conformance (corpus ${reason} — run stageTckSuites)`, () => { + it("skipped", () => {}); + }); + } +} + if (!existsSync(SUITES_DIR)) { // Corpus not staged — nothing to run in this lane. `npm run test:tck` on a - // checkout without the Gradle download is a no-op (passWithNoTests). - describe.skip("TCK conformance (corpus not staged — run stageTckSuites)", () => { - it("skipped", () => {}); - }); + // checkout without the Gradle download is a no-op (passWithNoTests), unless + // the dedicated CI job opted into DATAWEAVE_TCK_REQUIRE_CORPUS=1. + registerMissingCorpus("not staged"); } else { const { cases, skipped, structuralModuleCases } = discoverCases(); - const runnableCases = new Set(cases.map((item) => item.caseIdentifier)); - const runnableScenarios = new Set(cases.flatMap((item) => item.scenarios.map((scenario) => scenario.name))); - const policyErrors = [ - ...validateInventoryPolicy(cases.length, skipped), - ...validateIgnorePolicy(IGNORED_CASES, runnableCases), - ...validateReconciledPolicy(IGNORED_CASES, ACCEPTED_BASELINE_MISMATCHES, REENABLED_CASES, runnableScenarios), - ...validateStructuralModulePolicy(STRUCTURAL_MODULE_CASES, structuralModuleCases), - ]; - if (policyErrors.length > 0) { - throw new Error(`Invalid TCK policy:\n${policyErrors.join("\n")}`); - } + if (cases.length === 0) { + // Corpus directory exists but discovery found nothing runnable — same + // silent-green risk as the missing-directory case above. + registerMissingCorpus("empty"); + } else { + const runnableCases = new Set(cases.map((item) => item.caseIdentifier)); + const runnableScenarios = new Set(cases.flatMap((item) => item.scenarios.map((scenario) => scenario.name))); + const policyErrors = [ + ...validateInventoryPolicy(cases.length, skipped), + ...validateIgnorePolicy(IGNORED_CASES, runnableCases), + ...validateReconciledPolicy(IGNORED_CASES, ACCEPTED_BASELINE_MISMATCHES, REENABLED_CASES, runnableScenarios), + ...validateStructuralModulePolicy(STRUCTURAL_MODULE_CASES, structuralModuleCases), + ]; + if (policyErrors.length > 0) { + throw new Error(`Invalid TCK policy:\n${policyErrors.join("\n")}`); + } - // One shared runtime for the whole lane. Modules imported by a handful of - // TCK cases (org::mule::weave::v2::libs::lib) live only in the private - // data-weave runtime repo's test resources, not in any published - // artifact/TCK zip — resolve them from a committed fixture instead. - const dw = new DataWeave({ resolveModule: modulesFromDirectory(FIXTURES_DIR) }); + // One shared runtime for the whole lane. Modules imported by a handful of + // TCK cases (org::mule::weave::v2::libs::lib) live only in the private + // data-weave runtime repo's test resources, not in any published + // artifact/TCK zip — resolve them from a committed fixture instead. + const dw = new DataWeave({ resolveModule: modulesFromDirectory(FIXTURES_DIR) }); - describe("TCK conformance", () => { - // eslint-disable-next-line no-console - console.log( - `TCK: ${cases.length} runnable cases, ${skipped} structurally skipped, ` - + `${structuralModuleCases.size} structural module cases, ${Object.keys(IGNORED_CASES).length} exclusions, ` - + `${Object.keys(ACCEPTED_BASELINE_MISMATCHES).length} expected failures` - ); - dw.initialize(); + describe("TCK conformance", () => { + // eslint-disable-next-line no-console + console.log( + `TCK: ${cases.length} runnable cases, ${skipped} structurally skipped, ` + + `${structuralModuleCases.size} structural module cases, ${Object.keys(IGNORED_CASES).length} exclusions, ` + + `${Object.keys(ACCEPTED_BASELINE_MISMATCHES).length} expected failures` + ); + dw.initialize(); - for (const c of cases) { - const ignored = isIgnored(c.caseIdentifier); - for (const scenario of c.scenarios) { - const expectedFailure = ACCEPTED_BASELINE_MISMATCHES[scenario.name]; - const testFn = ignored ? it.skip : it; - const label = ignored - ? `${scenario.name} [skip: ${ignoreReason(c.caseIdentifier)}]` - : expectedFailure - ? `${scenario.name} [xfail: ${expectedFailure}]` - : scenario.name; - testFn(label, () => { - const script = readFileSync(join(c.dir, MAIN_TRANSFORM), "utf-8"); + for (const c of cases) { + const ignored = isIgnored(c.caseIdentifier); + for (const scenario of c.scenarios) { + const expectedFailure = ACCEPTED_BASELINE_MISMATCHES[scenario.name]; + const testFn = ignored ? it.skip : it; + const label = ignored + ? `${scenario.name} [skip: ${ignoreReason(c.caseIdentifier)}]` + : expectedFailure + ? `${scenario.name} [xfail: ${expectedFailure}]` + : scenario.name; + testFn(label, () => { + const script = readFileSync(join(c.dir, MAIN_TRANSFORM), "utf-8"); - const inputs = Object.fromEntries( - scenario.inputs.map((i) => [ - i.name, - { content: readFileSync(join(c.dir, i.fileName)), mimeType: i.mimeType }, - ]) - ); + const inputs = Object.fromEntries( + scenario.inputs.map((i) => [ + i.name, + { content: readFileSync(join(c.dir, i.fileName)), mimeType: i.mimeType }, + ]) + ); - const result = dw.run(script, inputs); - expect(result.success, `script failed: ${result.error}`).toBe(true); + const result = dw.run(script, inputs); + expect(result.success, `script failed: ${result.error}`).toBe(true); - const actual = result.getBytes()!; - const expected = readFileSync(join(c.dir, scenario.outputFileName)); - const encodingFile = join(c.dir, "encoding"); - const charset = existsSync(encodingFile) - ? readFileSync(encodingFile, "utf-8").trim() - : null; - const cmp = compareOutput(scenario.outputExtension, actual, expected, charset); - if (expectedFailure) { - expect( - cmp.match, - `expected baseline mismatch for ${scenario.name} ([xfail: ${expectedFailure}]) but output matched — remove it from ACCEPTED_BASELINE_MISMATCHES` - ).toBe(false); - } else { - expect(cmp.match, cmp.detail).toBe(true); - } - }); + const actual = result.getBytes()!; + const expected = readFileSync(join(c.dir, scenario.outputFileName)); + const encodingFile = join(c.dir, "encoding"); + const charset = existsSync(encodingFile) + ? readFileSync(encodingFile, "utf-8").trim() + : null; + const cmp = compareOutput(scenario.outputExtension, actual, expected, charset); + if (expectedFailure) { + expect( + cmp.match, + `expected baseline mismatch for ${scenario.name} ([xfail: ${expectedFailure}]) but output matched — remove it from ACCEPTED_BASELINE_MISMATCHES` + ).toBe(false); + } else { + expect(cmp.match, cmp.detail).toBe(true); + } + }); + } } - } - }); + }); + } } From 95a7d1b7ea3eab40af62f5f47e7d4f3c45d466ed Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 20:41:22 -0300 Subject: [PATCH 166/216] ci(node): require a staged TCK corpus on the dedicated master TCK lane (review #10 #7) Set DATAWEAVE_TCK_REQUIRE_CORPUS=1 on the Node TCK conformance step (gated by run-tck, master-only) so the harness fails loudly if the corpus staged by native-lib:stageTckSuites is missing/empty, instead of the describe.skip that let a zero-case run pass green. Scoped to that step only; local dev and the always-on unit/integration lane are unaffected. Completes the CI half of the Task 12 harness change (subagent was blocked from .github by policy). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/node/action.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/actions/node/action.yml b/.github/actions/node/action.yml index 4d8bd007..6a283897 100644 --- a/.github/actions/node/action.yml +++ b/.github/actions/node/action.yml @@ -52,6 +52,12 @@ runs: - name: Run Node.js TCK Conformance if: always() && inputs.run-tck == 'true' + # This is the dedicated (master-only) TCK lane. The corpus is staged + # earlier in the job (native-lib:stageTckSuites). Set the require-corpus + # flag so a missing/empty staged corpus fails this job loudly instead of + # skipping silently (review #10 #7); local dev without the flag still skips. + env: + DATAWEAVE_TCK_REQUIRE_CORPUS: '1' run: | cd native-lib/node && npm run test:tck shell: bash From 24f86dcdff9bf795712d2d8c9bc368c08fb24fe3 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 20:55:09 -0300 Subject: [PATCH 167/216] docs: document custom-module resolution scope (run() only) and record round-10 hardening in the multi-engine design (review #10 #4) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-08-07-native-lib-multi-engine-design.md | 56 +++++++++++++++++-- native-lib/node/README.md | 8 +++ native-lib/node/src/addon.c | 17 ++++++ native-lib/python/README.md | 6 ++ 4 files changed, 83 insertions(+), 4 deletions(-) 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 index 2afaaaad..5141ea56 100644 --- 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 @@ -288,6 +288,23 @@ coordination: the op's own `g_active_ops--` stays on the worker thread; the fina short-lived reservation only around the attach, makes no env-affine or JS-loop-dependent call, and never holds it across a JS callback (so it cannot re-introduce the §6.2 deadlock). +**Conditional bridge free (round 10, Task 6/svacas P1 confirmation).** When `bridge_finalize_registry` +reports the destroy was *skipped* while the isolate is still live (a transient `fn_attach_thread` +failure, not `TEARING_DOWN`/isolate-gone), `bridge_finalize` must not free the bridge — the Java +registry still holds it as a `CallbackWeaveResourceResolver` ctx, and freeing it would be a +use-after-free the next time that ctx is dereferenced. The bridge is instead moved onto +`g_stranded_bridges` (`bridge_retain_stranded`, list guarded by `g_mutex`) and retried by +`drain_stranded_bridges` at the next natural drain point (`napi_initialize`, op completion, +`cleanup`), which frees it only once the registry removal actually succeeds or the whole isolate has +since gone away. This is strictly better than the pre-fix behavior (an unconditional free on the +skipped-destroy path). One caveat, documented at the call sites in `addon.c`: a stranded bridge is +**not** `in_flight`-pinned the way a normally-admitted bridge is (§6.3's admission pinning above) — +it doesn't need to be under the supported single-owner-thread contract, because by the time a bridge +reaches this path its `in_flight` count has already drained to zero. The only way a drained-then-freed +stranded bridge could still be dereferenced is unsupported cross-Worker handle sharing or other API +misuse that starts a new operation against a handle that has already been unlinked from `g_bridges` +— not a case the supported API surface can reach. + **Env cleanup hooks reclaim abandoned engines.** Every engine registers a `napi_add_env_cleanup_hook` at creation (checked for failure — creation is all-or-nothing; on hook-registration failure the record is unlinked, its registry entry removed, its init reference @@ -352,12 +369,26 @@ because a boolean cannot represent the window during which `cleanup()` has start `napi_run_script_engine`) and unwind `g_active_ops` + the engine pin with no double-free (`calloc`-zeroed `w` makes the free-set `free(NULL)`-safe). Worker-thread OOM produces a **terminal error JSON result** (a static `{"success":false,"error":"Out of memory"}` string when - the copy itself failed, flagged so it is never `free()`d), never a hung promise. + the copy itself failed, flagged so it is never `free()`d), never a hung promise. **The streaming + and transform completion sentinel (`struct chunk_data`, the `len == -1` terminal record) is now + pre-allocated in the synchronous setup path** (`w->sentinel`, allocated right before + `napi_create_promise`), not `malloc`'d on the worker's terminal path (round 10, Task 7). Before + this fix, a `malloc` failure on the worker's terminal path freed the work struct and returned + without ever enqueuing completion — the env was alive, so the JS promise never settled and the + tsfn was never released: a permanent hang, not a clean error. With the sentinel pre-allocated, + the worker's terminal completion path performs no allocation between the `g_active_ops--` + decrement and the unconditional tsfn enqueue, so that hang is now structurally impossible; a + setup-time sentinel-allocation failure instead unwinds cleanly and throws synchronous `"OOM"`, + identical to every other setup-phase allocation failure. - **Argument validation.** Every FFI-facing entrypoint checks the status of every `napi_get_value_*` conversion (handle `int64`, string size-probes and fills, `napi_typeof` for nullable args) and throws before using the converted value, so a raw addon caller cannot turn a malformed argument into an uninitialized native input. `inputCharset` is nullable - (`string | null | undefined`); any other type is rejected rather than silently coerced. + (`string | null | undefined`); any other type is rejected rather than silently coerced. The raw + `napi_initialize(libPath)` entrypoint now applies the same discipline to its own argument: it + checks `napi_get_cb_info`'s status, rejects a non-string `libPath` via `napi_typeof` before + touching it, and checks `napi_get_value_string_utf8`'s status — previously the argument was read + without any of these checks (round 10, Task 8). - **Stream error propagation.** `streamFromNative` handles **both** settlement branches of the native `start()` promise: on rejection it records the error, marks completion, and wakes every parked `next()` consumer (otherwise the generator hangs forever and the rejection is unhandled), @@ -404,7 +435,15 @@ attachment**, mirroring the Node and Go bindings: for the whole call, and detaches it when done (`_current_thread_attachment`). A stream-worker thread that has already attached its own IsolateThread passes it through unchanged. - **`_release_isolate`** (last ref): attaches a fresh thread solely to call - `graal_tear_down_isolate`, then clears the globals. + `graal_tear_down_isolate`. On success it clears the globals; on failure (attach failure, or + `graal_tear_down_isolate` itself failing) it now **retains the live isolate and arms + `_teardown_needed`** rather than nulling the globals — nulling on a failed teardown would let the + next `_acquire_isolate` build a second, racing isolate over the first one, which is still alive + (round 10, Task 2/3). `_acquire_isolate` checks `_teardown_needed` first and retries the pending + teardown before deciding whether to create a new isolate; a repeated failure re-arms the flag and + raises rather than proceeding. This mirrors Node's `g_teardown_needed` retryable-teardown model + (§6.2) — the two bindings now share one failure-recovery contract instead of Python's previous + unconditional-null behavior. Because nothing stays attached between calls, teardown never blocks on a phantom attachment regardless of which OS thread performs the last release. @@ -445,7 +484,15 @@ instance has already joined its own workers, so the isolate has no attached work resolves custom modules only when invoked on the engine's owner thread and **fails closed** ("not found") on a background stream-worker thread. Built-in modules resolve normally everywhere; synchronous `run()` with a resolver resolves custom modules fully. This is a conservative parity - choice (identical behavior across bindings), not a hard Python limitation. + choice (identical behavior across bindings), not a hard Python limitation. This scope is now + stated for end users directly (round 10, Task 13): both README's "Custom module resolution scope" + section (`native-lib/node/README.md`, `native-lib/python/README.md`) states the three-part rule — + a configured resolver applies to `run()`; built-ins resolve everywhere; custom modules fail closed + in streaming/transform/callback APIs — and the existing fail-closed behavior is covered by + `native-lib/node/tests/integration/dataweave-resolver.test.ts` (`runStreaming fails cleanly for a + custom module...`) and `native-lib/python/tests/integration/test_module_resolver.py` + (`test_resolver_is_inactive_for_resolver_less_apis_after_synchronous_install`, which additionally + covers `run_transform`, `run_callback`, and `run_input_output_callback`). ## 8. Architecture (layer map) @@ -683,4 +730,5 @@ unification design documents were consolidated into this file. | 15 (08-21 review6) | §6.2, §6.4, §6.5 | Singleton-poisoning fix; stream rejection propagation; teardown return-code checks; init-driven stranded-teardown retry. | | 16 (08-24 review7) | §6.2, §6.4, §6.5, §10 | Detach on failed teardown; init-hook-failure retry arming; observable init rollback; `Promise.reject(undefined)` fix; lifecycle-doc accuracy. | | Python unification (08-26) | §2, §5, §7, §8 (Layer 1/4), §10–§12 | Remove `ScriptRuntime` singleton + 3 legacy C entrypoints; Python onto shared refcounted isolate + handle engines via `*_engine` ABI; 3-arg ctx resolver trampoline. | +| PR157 review 10 (08-27) | §6.3, §6.5, §7.2, §7.4 | Python `_release_isolate`/`_acquire_isolate` retryable-teardown model brought to parity with Node's `g_teardown_needed` (retains the live isolate on failed teardown instead of nulling globals); Node streaming/transform completion sentinel pre-allocated in synchronous setup (worker terminal path now allocation-free, closing a stranded-hang window); stranded-bridge free confirmed conditional on registry removal, with the non-`in_flight`-pinned residual window documented as reachable only via unsupported cross-Worker handle sharing / API misuse; raw `napi_initialize` validates its library-path argument synchronously; user-facing custom-module resolution scope (`run()`-only) documented in both READMEs, cross-referencing the existing streaming-resolver-guard tests. | | Python final review (08-26) | §7.2, §10 | Detach isolate bootstrap thread at create + attach-on-demand so cross-thread last-release teardown cannot hang; unregister resolver token on failed init. | diff --git a/native-lib/node/README.md b/native-lib/node/README.md index afd90999..f2ba9035 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -292,6 +292,14 @@ try { See [docs/external-modules.md](docs/external-modules.md) for complete documentation, resolver factories, error handling, and dependency management. Note: a resolver runs with full process permissions (no sandboxing) — see the "Security / Trust Model" section there before pointing one at untrusted sources. +### Custom module resolution scope + +- A `resolveModule` you configure applies to `run()`. +- Built-in modules (e.g. `dw::core::*`) resolve everywhere — `run()`, `runStreaming()`, and `runTransform()`. +- Custom modules do **not** resolve inside `runStreaming()`/`runTransform()`: those execute on a background thread that must not call back into your resolver, so a streamed/transformed script that imports a custom module fails closed (reports the module as not found) rather than making an unsafe cross-thread call. If you need a custom module in a streamed/transform script, resolve it via `run()` instead, or inline the module into the script. + +See [docs/external-modules.md](docs/external-modules.md#multiple-independent-engines) for the full explanation, including the Worker-thread ownership rules. + ### Input Formats Inputs can be provided in multiple formats: diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index d51551e7..4ffbffa2 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -236,6 +236,23 @@ static engine_bridge_t* bridge_find(long long handle) { // stays valid until a later drain retries the destroy and frees it. Takes // g_mutex; the caller MUST have already unlinked `b` from g_bridges (its `next` // is reused for the stranded list) and MUST NOT hold g_mutex. +// +// Round-10 review note (parked from Task 6): unlike the normal admitted-op path +// in §6.3/above, a stranded bridge is NOT `in_flight`-pinned while it sits on +// g_stranded_bridges. That is safe under the supported single-owner-thread +// contract: a bridge only reaches here via bridge_finalize (destroyEngine, the +// owner-thread-only call, or the env cleanup hook on the owner env's death), and +// both of those already require in_flight == 0 to have run at all (see the +// deferred-destroy comment above bridge_finalize_registry) -- so in_flight is +// already drained to zero by construction before a bridge is ever stranded, and +// bridge_find() can no longer look it up by handle (it's unlinked from +// g_bridges), so no new op can be admitted against it. The only way a +// drained-then-freed stranded bridge could still be dereferenced is unsupported +// cross-Worker handle sharing or other API misuse that starts a background +// operation against a handle after it has already been unlinked here -- +// outside the documented single-owner-thread usage this addon supports. Even in +// that unsupported scenario this is strictly better than the pre-fix behavior +// (an unconditional free on every skipped-destroy path). static void bridge_retain_stranded(engine_bridge_t* b) { if (b == NULL) return; uv_mutex_lock(&g_mutex); diff --git a/native-lib/python/README.md b/native-lib/python/README.md index 1b4294c7..0997545f 100644 --- a/native-lib/python/README.md +++ b/native-lib/python/README.md @@ -210,6 +210,12 @@ runs reuse it. The instance retains the resolver callback until successful isolate teardown, then releases callback references during `cleanup()`. Different live instances can therefore use different resolvers. +### Custom module resolution scope + +- A `resolve_module` you configure applies to `run()`. +- Built-in modules (e.g. `dw::core::*`) resolve everywhere — `run()`, `run_streaming()`, `run_transform()`, and the low-level callback APIs. +- Custom modules do **not** resolve inside `run_streaming()`, `run_transform()`, or the low-level callback APIs: those execute on a background thread that must not call back into your resolver, so such a script fails closed (reports the module as not found) rather than making an unsafe cross-thread call. If you need a custom module in a streamed/transform/callback script, resolve it via `run()` instead, or inline the module into the script. + ### Error Handling **Option A: Use `raise_on_error=True` (recommended)** From a743697059d510a8cb9811701a545de81e39b41a Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 21:00:00 -0300 Subject: [PATCH 168/216] test(node): sync tck-policy counts to recategorized ignore-list (38/15); reword CI comment to not trip ci-structure substring check (review #10 #7 #9 follow-up) Task 11 moved 6 execution-failure cases from ACCEPTED_BASELINE_MISMATCHES (now 15) into CAPABILITY_EXCLUSIONS (now 38); the hardcoded 32/21 assertions in tck-policy.test.ts were stale. Task 12's action.yml comment literally contained "native-lib:stageTckSuites", tripping test_ci_structure.py:134's naive substring guard that the node action must not stage the corpus itself. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/node/action.yml | 9 +++++---- native-lib/node/tests/unit/tck-policy.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/actions/node/action.yml b/.github/actions/node/action.yml index 6a283897..4254cd85 100644 --- a/.github/actions/node/action.yml +++ b/.github/actions/node/action.yml @@ -52,10 +52,11 @@ runs: - name: Run Node.js TCK Conformance if: always() && inputs.run-tck == 'true' - # This is the dedicated (master-only) TCK lane. The corpus is staged - # earlier in the job (native-lib:stageTckSuites). Set the require-corpus - # flag so a missing/empty staged corpus fails this job loudly instead of - # skipping silently (review #10 #7); local dev without the flag still skips. + # This is the dedicated (master-only) TCK lane. The corpus is staged once + # by the shared TCK staging step earlier in the job (see main.yml). Set the + # require-corpus flag so a missing/empty staged corpus fails this job loudly + # instead of skipping silently (review #10 #7); local dev without the flag + # still skips. env: DATAWEAVE_TCK_REQUIRE_CORPUS: '1' run: | diff --git a/native-lib/node/tests/unit/tck-policy.test.ts b/native-lib/node/tests/unit/tck-policy.test.ts index 2838f9a1..80ccede1 100644 --- a/native-lib/node/tests/unit/tck-policy.test.ts +++ b/native-lib/node/tests/unit/tck-policy.test.ts @@ -44,8 +44,8 @@ describe("TCK ignore policy", () => { }); it("reconciles exclusions into capability skips and strict xfails", () => { - expect(Object.keys(CAPABILITY_EXCLUSIONS)).toHaveLength(32); - expect(Object.keys(ACCEPTED_BASELINE_MISMATCHES)).toHaveLength(21); + expect(Object.keys(CAPABILITY_EXCLUSIONS)).toHaveLength(38); + expect(Object.keys(ACCEPTED_BASELINE_MISMATCHES)).toHaveLength(15); expect(REENABLED_CASES).toHaveLength(6); expect(CAPABILITY_EXCLUSIONS).toHaveProperty("runtime/big_intersection-out.json"); expect(IGNORED_CASES).toBe(CAPABILITY_EXCLUSIONS); From 604f39cceabc7ec25b0e8ce9b549f2bf3bb59648 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Thu, 27 Aug 2026 21:14:53 -0300 Subject: [PATCH 169/216] test(node): record empirically-verified execution-failure evidence for the 3 recategorized multipart TCK cases (final review Low-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broad final review flagged that the multipart reason strings did not assert result.success===false, unlike the coercion cases. Verified empirically by temporarily forcing them back to xfail: all 3 fail the unconditional success===true assertion with "Multipart Object has empty `parts` and expects at least one part" — genuine execution failures, so they correctly belong as skips (CAPABILITY_EXCLUSIONS), not output-mismatch xfails. Sharpened reasons to make that evidence durable; categorization (runtime-baseline-mismatch) and TCK accounting (729/676/0/38/15) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/tests/tck/ignore-list.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/native-lib/node/tests/tck/ignore-list.ts b/native-lib/node/tests/tck/ignore-list.ts index 8b683fc6..6fb913a7 100644 --- a/native-lib/node/tests/tck/ignore-list.ts +++ b/native-lib/node/tests/tck/ignore-list.ts @@ -91,10 +91,10 @@ const LEGACY_IGNORED_CASES: Readonly> = { "multipart-binary-out.multipart": { reason: "multipart: boundary nondeterminism + binary part encoding" }, "multipart-class-cast-issue-out.multipart": { reason: "multipart: boundary nondeterminism" }, "multipart-empty-part-out.multipart": { reason: "multipart: boundary nondeterminism + empty part handling" }, - "multipart-mixed-message-out.multipart": { reason: "multipart: empty parts / structural" }, + "multipart-mixed-message-out.multipart": { reason: "multipart: execution fails — 'Multipart Object has empty `parts`' (skip, not an output-mismatch xfail)" }, "multipart-write-binary-out.json": { reason: "multipart: binary part write" }, - "multipart-write-message-out.multipart": { reason: "multipart: empty parts / structural" }, - "multipart-write-subtype-override-out.multipart": { reason: "multipart: subtype override" }, + "multipart-write-message-out.multipart": { reason: "multipart: execution fails — 'Multipart Object has empty `parts`' (skip, not an output-mismatch xfail)" }, + "multipart-write-subtype-override-out.multipart": { reason: "multipart: execution fails — 'Multipart Object has empty `parts`' (skip, not an output-mismatch xfail)" }, // slow — passes but risks exceeding the 30s test timeout on CI "big_intersection-out.json": { reason: "slow: 500-way intersection type exceeds the test timeout" }, From e66359a6bd195727d90f7dcf85a38d2315adfc9c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 12:13:57 -0300 Subject: [PATCH 170/216] fix(native-lib): close input session and convert exceptions to error envelope in transformViaCallbacks (review #11 #1) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/org/mule/weave/lib/NativeLib.java | 187 ++++++++++++------ .../mule/weave/lib/NativeLibFeederTest.java | 50 +++++ 2 files changed, 182 insertions(+), 55 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 f80a28b6..49ed5e8f 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 @@ -104,80 +104,146 @@ private static CCharPointer transformViaCallbacks( 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(); - - // 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 a background thread that calls the readCallback and feeds data into the pipe. - // Word types (CCharPointer, CFunctionPointer, PointerBase) cannot be captured in - // lambdas in GraalVM Native Image, so we use an explicit Runnable that stores their - // raw addresses and reconstitutes them via WordFactory. - final long readCallbackAddr = readCallback.rawValue(); - final long ctxAddr = ctx.rawValue(); - InputCallbackFeeder feederRunnable = new InputCallbackFeeder( - readCallbackAddr, ctxAddr, inputSession); - Thread feeder = new Thread(feederRunnable, "dw-input-callback-feeder"); - feeder.setDaemon(true); - feeder.start(); - - // Execute the script and stream output via the writeCallback - StreamSession session = runtime.runStreaming(dwScript, mergedInputs); - - if (session.isError()) { - cleanupFeeder(feederRunnable, feeder, inputHandle); - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(session.getError()) + "\"}"); + // Register the input session and merge its stream-handle entry into the inputs JSON. + // This setup can throw on a malformed `inputs` string; setUpInputSession closes the + // handle and yields an error envelope in that case, so nothing leaks and no exception + // escapes this @CEntryPoint before the feeder is even started. + InputSetup setup = setUpInputSession(inputs, inName, inMime, inCharset); + if (setup.errorEnvelope != null) { + return toUnmanagedCString(setup.errorEnvelope); } + long inputHandle = setup.handle; + InputCallbackFeeder feederRunnable = null; + Thread feeder = null; try { - byte[] buf = new byte[CALLBACK_BUFFER_SIZE]; - CCharPointer writeBuf = UnmanagedMemory.malloc(CALLBACK_BUFFER_SIZE); + // Start a background thread that calls the readCallback and feeds data into the pipe. + // Word types (CCharPointer, CFunctionPointer, PointerBase) cannot be captured in + // lambdas in GraalVM Native Image, so we use an explicit Runnable that stores their + // raw addresses and reconstitutes them via WordFactory. + final long readCallbackAddr = readCallback.rawValue(); + final long ctxAddr = ctx.rawValue(); + feederRunnable = new InputCallbackFeeder(readCallbackAddr, ctxAddr, setup.session); + feeder = new Thread(feederRunnable, "dw-input-callback-feeder"); + feeder.setDaemon(true); + feeder.start(); + + // Execute the script and stream output via the writeCallback + StreamSession session = runtime.runStreaming(dwScript, setup.mergedInputs); + + if (session.isError()) { + return toUnmanagedCString("{\"success\":false,\"error\":\"" + + escapeJsonString(session.getError()) + "\"}"); + } + 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(feederRunnable, feeder, inputHandle); - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + "Write callback returned error: " + rc + "\"}"); + 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) { + return toUnmanagedCString("{\"success\":false,\"error\":\"" + + "Write callback returned error: " + rc + "\"}"); + } } + } finally { + UnmanagedMemory.free(writeBuf); } } finally { - UnmanagedMemory.free(writeBuf); + session.closeStream(); + } + + return toUnmanagedCString("{\"success\":true" + + ",\"mimeType\":\"" + session.getMimeType() + "\"" + + ",\"charset\":\"" + session.getCharset() + "\"" + + ",\"binary\":" + session.isBinary() + + "}"); + } catch (Exception e) { + // No Java exception may escape this @CEntryPoint: convert to an error envelope. + String m = e.getMessage(); + if (m == null || m.trim().isEmpty()) { + m = e.toString(); } - } catch (IOException e) { - cleanupFeeder(feederRunnable, feeder, inputHandle); return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(e.getMessage()) + "\"}"); + + escapeJsonString(m) + "\"}"); } finally { - session.closeStream(); + // Sole close of the input handle for every path once the feeder region is entered. + // Safe (and a no-op cancel/join) when the feeder never started. + cleanupFeeder(feederRunnable, feeder, inputHandle); } + } - cleanupFeeder(feederRunnable, feeder, inputHandle); + /** + * Registers a new {@link InputStreamSession} for the callback-supplied input and merges its + * stream-handle entry into {@code inputs}. + * + *

Package-private (rather than {@code private}) so a JVM unit test can drive this + * leak-prone setup region directly: {@link #transformViaCallbacks} itself takes GraalVM + * {@code Word}-typed callbacks and returns a {@code CCharPointer}, neither of which resolves in + * a hosted JVM. This helper uses only plain-Java types.

+ * + *

On success, {@link InputSetup#errorEnvelope} is {@code null}, {@link InputSetup#session} + * and {@link InputSetup#mergedInputs} are populated, and the session is left registered and + * open for the feeder — its handle must ultimately be closed via {@link #cleanupFeeder}. On a + * malformed {@code inputs} string the handle is already closed and + * {@link InputSetup#errorEnvelope} carries the {@code success:false} payload to return + * verbatim, so nothing leaks and no {@code JSONException} escapes.

+ */ + static InputSetup setUpInputSession(String inputs, String inName, String inMime, String inCharset) { + InputStreamSession inputSession = new InputStreamSession(inMime, inCharset); + long inputHandle = inputSession.register(); + try { + org.json.JSONObject streamEntry = new org.json.JSONObject(); + streamEntry.put("streamHandle", Long.toString(inputHandle)); + streamEntry.put("mimeType", inMime); + if (inCharset != null) { + streamEntry.put("charset", inCharset); + } + String mergedInputs = mergeInputEntry(inputs, inName, streamEntry); + return new InputSetup(inputSession, inputHandle, mergedInputs, null); + } catch (Exception e) { + InputStreamSession.close(inputHandle); + String m = e.getMessage(); + if (m == null || m.trim().isEmpty()) { + m = e.toString(); + } + return new InputSetup(null, inputHandle, null, + "{\"success\":false,\"error\":\"" + escapeJsonString(m) + "\"}"); + } + } - return toUnmanagedCString("{\"success\":true" - + ",\"mimeType\":\"" + session.getMimeType() + "\"" - + ",\"charset\":\"" + session.getCharset() + "\"" - + ",\"binary\":" + session.isBinary() - + "}"); + /** + * Outcome of {@link #setUpInputSession}: either a live registered session plus its merged + * inputs ({@link #errorEnvelope} {@code null}), or a {@code success:false} error envelope with + * the handle already closed ({@link #session}/{@link #mergedInputs} {@code null}). + */ + static final class InputSetup { + final InputStreamSession session; + final long handle; + final String mergedInputs; + final String errorEnvelope; + + InputSetup(InputStreamSession session, long handle, String mergedInputs, String errorEnvelope) { + this.session = session; + this.handle = handle; + this.mergedInputs = mergedInputs; + this.errorEnvelope = errorEnvelope; + } } /** * Merges a single input entry into an existing JSON inputs string. */ - private static String mergeInputEntry(String existingJson, String name, String entryJson) { + private static String mergeInputEntry(String existingJson, String name, org.json.JSONObject entry) { org.json.JSONObject obj = (existingJson == null || existingJson.trim().isEmpty()) ? new org.json.JSONObject() : new org.json.JSONObject(existingJson); - obj.put(name, new org.json.JSONObject(entryJson)); + obj.put(name, entry); return obj.toString(); } @@ -209,6 +275,11 @@ private static String mergeInputEntry(String existingJson, String name, String e * interrupt and keep waiting. * * + *

Null-safety: when the feeder never started — a setup failure threw + * before {@code feeder.start()} — {@code feederRunnable} and/or {@code thread} may be + * {@code null}. The cancel and join are then no-ops, but the input handle is always + * closed so a failed setup cannot leak the session.

+ * *

Documented trade-off: cancellation guarantees we wait only for the * in-flight {@code readCallback} to return — no signal can interrupt native code * parked inside the caller's callback. A callback that blocks forever inside a single @@ -217,9 +288,15 @@ private static String mergeInputEntry(String existingJson, String name, String e * use-after-free this method exists to prevent.

*/ static void cleanupFeeder(InputCallbackFeeder feederRunnable, Thread thread, long inputHandle) { - feederRunnable.cancel(); - // Unblock a feeder parked on a full pipe and drop the session from the registry. + if (feederRunnable != null) { + feederRunnable.cancel(); + } + // Always drop the session from the registry (and unblock a feeder parked on a full pipe). + // This runs even when the feeder never started, so the input handle is never leaked. InputStreamSession.close(inputHandle); + if (thread == null) { + return; + } boolean joined = false; while (!joined) { try { diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java index 05ecb1ef..80df35cd 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -2,6 +2,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -95,4 +97,52 @@ int readChunk(byte[] dest, int max) { System.out.printf("cleanupFeeder returned after %d ms; invocations=%d, alive=%b%n", elapsedMs, invocations.get(), thread.isAlive()); } + + /** + * Leak + exception-escape guard for the {@code transformViaCallbacks} setup region + * (review #11 #1, High): a malformed {@code inputs} JSON string must not leak the registered + * {@link InputStreamSession} handle and must not let the {@link org.json.JSONException} escape + * the {@code @CEntryPoint}. It must instead resolve to a {@code success:false} envelope with the + * handle already closed. + * + *

Driven through the package-private {@link NativeLib#setUpInputSession} seam because + * {@code transformViaCallbacks} itself takes GraalVM {@code Word}-typed callbacks and returns a + * {@code CCharPointer}, neither of which resolves in a hosted JVM.

+ */ + @Test + void setUpInputSessionMalformedInputsReturnsErrorEnvelopeAndClosesHandle() { + NativeLib.InputSetup setup = + NativeLib.setUpInputSession("{not json", "payload", "application/json", "UTF-8"); + + assertNotNull(setup.errorEnvelope, "malformed inputs must yield an error envelope"); + assertTrue(setup.errorEnvelope.contains("\"success\":false"), + "envelope must be success:false, was: " + setup.errorEnvelope); + assertNull(setup.mergedInputs, "no merged inputs on the error path"); + assertNull(InputStreamSession.get(setup.handle), + "input session handle leaked after malformed inputs"); + } + + /** + * Happy-path guard: valid {@code inputs} register the session, merge the stream-handle entry + * structurally, and leave the handle live for the feeder (no behavior change). The caller + * (via {@code cleanupFeeder}) is responsible for the eventual close. + */ + @Test + void setUpInputSessionValidInputsMergesEntryAndKeepsHandleLive() { + NativeLib.InputSetup setup = NativeLib.setUpInputSession( + "{\"other\":{\"x\":1}}", "payload", "application/json", "UTF-8"); + + assertNull(setup.errorEnvelope, "valid inputs must not produce an error envelope"); + assertNotNull(setup.mergedInputs, "valid inputs must produce merged inputs"); + assertTrue(setup.mergedInputs.contains("streamHandle"), + "merged inputs must carry the stream handle entry, was: " + setup.mergedInputs); + assertTrue(setup.mergedInputs.contains("payload"), + "merged inputs must carry the input binding name, was: " + setup.mergedInputs); + assertNotNull(InputStreamSession.get(setup.handle), + "session must remain live for the feeder on the success path"); + + // Clean up the still-live session so the test leaves no handle behind. + InputStreamSession.close(setup.handle); + assertNull(InputStreamSession.get(setup.handle)); + } } From 7bc1a7958a7789e466ab494dafa0b275bcc6ecb3 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 12:23:19 -0300 Subject: [PATCH 171/216] fix(native-lib): reject out-of-range input callback lengths instead of OOB/silent EOF (review #11 #4) --- .../java/org/mule/weave/lib/NativeLib.java | 49 +++++++ .../mule/weave/lib/NativeLibFeederTest.java | 124 ++++++++++++++++++ 2 files changed, 173 insertions(+) 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 49ed5e8f..11063e52 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 @@ -158,6 +158,18 @@ private static CCharPointer transformViaCallbacks( session.closeStream(); } + // The feeder ran concurrently; by the time output streaming reached EOF it has + // finished. If it stopped on a read-callback contract violation (out-of-range + // length), the input was truncated — surface that as an error rather than presenting + // a success envelope built on partial input. Safe to read here: cleanupFeeder (in the + // finally) does not null feederRunnable. The engine usually errors first via + // session.isError(); this catches the case where it tolerated the truncated input. + String feederError = feederRunnable.getError(); + if (feederError != null) { + return toUnmanagedCString("{\"success\":false,\"error\":\"" + + escapeJsonString(feederError) + "\"}"); + } + return toUnmanagedCString("{\"success\":true" + ",\"mimeType\":\"" + session.getMimeType() + "\"" + ",\"charset\":\"" + session.getCharset() + "\"" @@ -338,6 +350,7 @@ static class InputCallbackFeeder implements Runnable { private final long ctxAddr; private final InputStreamSession inputSession; private volatile boolean cancelled = false; + private volatile String feederError; InputCallbackFeeder(long readCallbackAddr, long ctxAddr, InputStreamSession inputSession) { @@ -355,6 +368,29 @@ boolean isCancelled() { return cancelled; } + /** + * The read-callback contract violation that stopped the feeder as an error, or + * {@code null} if the feeder reached a clean EOF (or never ran). Read by + * {@link NativeLib#transformViaCallbacks} after the output loop so a truncated input + * caused by a misbehaving callback is reported as {@code success:false} rather than + * presented as a successful transform. + */ + String getError() { + return feederError; + } + + /** + * Records an out-of-range read-callback length as a feeder error and maps it to the + * error return code ({@code -1}). Per the read convention {@code 0} = EOF, {@code >0} = + * bytes read, {@code -1} = error; any length outside {@code [-1, max]} is a contract + * violation. + */ + private int rejectOutOfRange(int n, int max) { + feederError = "Input read callback returned out-of-range length " + n + + " (max " + max + ")"; + return -1; + } + /** * Pulls the next input chunk from the caller-owned read callback into {@code dest}, * returning the number of bytes read ({@code 0} = EOF, negative = error). @@ -369,6 +405,12 @@ int readChunk(byte[] dest, int max) { CCharPointer buf = UnmanagedMemory.malloc(max); try { int n = cb.invoke(ctx, buf, max); + // Reject a contract violation BEFORE the copy loop: n > max would index past + // dest[] / the native buf (an out-of-bounds copy that used to silently kill the + // feeder and present the engine with a clean EOF on truncated input). + if (n > max || n < -1) { + return rejectOutOfRange(n, max); + } if (n > 0) { for (int i = 0; i < n; i++) { dest[i] = buf.read(i); @@ -386,6 +428,13 @@ public void run() { try { while (!cancelled) { int n = readChunk(tmp, CALLBACK_BUFFER_SIZE); + // Defence in depth for the overridable readChunk seam: reject any length + // outside [-1, max] here too, so an out-of-range value can never reach the + // write below (which would throw IndexOutOfBounds out of run()). In + // production readChunk has already recorded this and returned -1. + if (n > CALLBACK_BUFFER_SIZE || n < -1) { + n = rejectOutOfRange(n, CALLBACK_BUFFER_SIZE); + } if (n <= 0) { break; // 0 = EOF, negative = error } diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java index 80df35cd..bcf92fa1 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -11,6 +11,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; /** * Contract test for {@link NativeLib#cleanupFeeder} + {@link NativeLib.InputCallbackFeeder} @@ -145,4 +146,127 @@ void setUpInputSessionValidInputsMergesEntryAndKeepsHandleLive() { InputStreamSession.close(setup.handle); assertNull(InputStreamSession.get(setup.handle)); } + + // ── Bounds-check on the read-callback length (review #11 #4, Medium) ───── + + /** + * Drives the feeder to completion with the given {@code readChunk} stand-in and returns the + * throwable (if any) that escaped {@link NativeLib.InputCallbackFeeder#run()} via the thread's + * uncaught-exception handler. Uses a fresh registered session so the feeder's {@code finally} + * has a real writer to close, and unregisters it afterwards so no handle leaks. + */ + private static Throwable runFeederCapturingEscapedError(ReadChunkStub stub) throws Exception { + InputStreamSession inputSession = new InputStreamSession("application/json", "UTF-8"); + long inputHandle = inputSession.register(); + try { + NativeLib.InputCallbackFeeder feeder = + new NativeLib.InputCallbackFeeder(0L, 0L, inputSession) { + @Override + int readChunk(byte[] dest, int max) { + return stub.readChunk(dest, max); + } + }; + AtomicReference escaped = new AtomicReference<>(); + Thread thread = new Thread(feeder, "test-bounds-feeder"); + thread.setDaemon(true); + thread.setUncaughtExceptionHandler((t, e) -> escaped.set(e)); + thread.start(); + thread.join(TimeUnit.SECONDS.toMillis(5)); + assertFalse(thread.isAlive(), "feeder thread did not stop after an out-of-range length"); + stub.setFeeder(feeder); + return escaped.get(); + } finally { + InputStreamSession.close(inputHandle); + } + } + + /** Test seam mirroring {@code readChunk} plus a hook to reach the feeder after it stops. */ + private interface ReadChunkStub { + int readChunk(byte[] dest, int max); + + default void setFeeder(NativeLib.InputCallbackFeeder feeder) { + } + } + + /** + * A read callback that returns {@code max + 1} (one past the buffer) must be rejected: the + * feeder stops as an error with {@link NativeLib.InputCallbackFeeder#getError()} naming the + * out-of-range count, and no out-of-bounds exception escapes {@code run()}. + */ + @Test + void readCallbackLengthAboveMaxIsRejectedAsError() throws Exception { + AtomicReference ref = new AtomicReference<>(); + AtomicInteger calls = new AtomicInteger(0); + Throwable escaped = runFeederCapturingEscapedError(new ReadChunkStub() { + @Override + public int readChunk(byte[] dest, int max) { + calls.incrementAndGet(); + return max + 1; // one byte past the destination buffer + } + + @Override + public void setFeeder(NativeLib.InputCallbackFeeder feeder) { + ref.set(feeder); + } + }); + + assertNull(escaped, "an out-of-bounds exception escaped run(): " + escaped); + assertEquals(1, calls.get(), "feeder must stop after the first out-of-range read"); + String error = ref.get().getError(); + assertNotNull(error, "out-of-range length must be recorded as a feeder error"); + assertTrue(error.contains(Integer.toString(NativeLibFeederConstants.BUFFER + 1)), + "error must name the out-of-range count, was: " + error); + } + + /** + * A read callback that returns {@code -5} (outside the {@code [-1, max]} contract) must be + * rejected the same way: recorded feeder error naming the count, no exception out of {@code run()}. + */ + @Test + void readCallbackNegativeOutOfRangeLengthIsRejectedAsError() throws Exception { + AtomicReference ref = new AtomicReference<>(); + Throwable escaped = runFeederCapturingEscapedError(new ReadChunkStub() { + @Override + public int readChunk(byte[] dest, int max) { + return -5; + } + + @Override + public void setFeeder(NativeLib.InputCallbackFeeder feeder) { + ref.set(feeder); + } + }); + + assertNull(escaped, "an exception escaped run(): " + escaped); + String error = ref.get().getError(); + assertNotNull(error, "out-of-range negative length must be recorded as a feeder error"); + assertTrue(error.contains("-5"), "error must name the out-of-range count, was: " + error); + } + + /** + * A clean EOF ({@code 0}) is not an error: the feeder stops with {@code getError() == null}. + */ + @Test + void readCallbackCleanEofLeavesNoFeederError() throws Exception { + AtomicReference ref = new AtomicReference<>(); + Throwable escaped = runFeederCapturingEscapedError(new ReadChunkStub() { + @Override + public int readChunk(byte[] dest, int max) { + return 0; // immediate EOF + } + + @Override + public void setFeeder(NativeLib.InputCallbackFeeder feeder) { + ref.set(feeder); + } + }); + + assertNull(escaped, "no exception may escape run() on clean EOF: " + escaped); + assertNull(ref.get().getError(), "clean EOF must leave getError() == null"); + } + + /** Mirrors the package-private {@code CALLBACK_BUFFER_SIZE} used as the read {@code max}. */ + private static final class NativeLibFeederConstants { + static final int BUFFER = 8 * 1024; + } } From 98be651e8ee11efe209c8c44f5982add75129e3d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 12:31:10 -0300 Subject: [PATCH 172/216] fix(python): detach teardown worker on failed isolate teardown so cross-thread retry can proceed (review #11 #2) --- native-lib/python/src/dataweave/native.py | 35 ++++++- native-lib/python/tests/unit/test_native.py | 102 ++++++++++++++++++++ 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index ca3dbde1..aa09615e 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -121,7 +121,19 @@ def _retry_pending_teardown_locked() -> None: worker = GraalIsolateThreadPointer() if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: raise DataWeaveError("Failed to attach thread to retry isolate teardown") - _tear_down(lib, worker) # raises on failure -> flag stays armed + try: + _tear_down(lib, worker) # raises on failure -> flag stays armed + except BaseException: + # Teardown failed again: detach the worker we just attached (best-effort) + # so it does not stay attached and block the NEXT retry, which attaches + # its own fresh worker. _teardown_needed stays armed; globals not nulled. + try: + lib.graal_detach_thread(worker) + except Exception: + pass + raise + # Success: the isolate (and every thread pointer into it) is now invalid, so + # the worker must NOT be detached. _lib = _lib_path = _isolate = None _teardown_needed = False @@ -162,7 +174,11 @@ def _acquire_isolate(lib_path: str): _tear_down(lib, thread) except BaseException: # Even teardown failed: retain the created isolate and arm a - # retry rather than leaking it silently. + # retry rather than leaking it silently. Leaving the bootstrap + # `thread` attached here is intentional -- its detach already + # failed above, so we do NOT detach it again; the retry path + # (_retry_pending_teardown_locked) attaches its own fresh + # worker to re-attempt teardown. _lib, _lib_path, _isolate = lib, lib_path, isolate _teardown_needed = True print( @@ -210,9 +226,18 @@ def _release_isolate() -> None: try: _tear_down(lib, worker) except BaseException: - # Teardown failed: keep the isolate live, arm a retry, do NOT null - # globals (nulling would let the next initialize() build a second - # live isolate). Mirrors Node's g_teardown_needed retryable model. + # Teardown failed: detach the worker we just attached FIRST (best- + # effort) so it does not stay attached and block a later retry, which + # attaches its own fresh worker. Then keep the isolate live, arm a + # retry, and do NOT null globals (nulling would let the next + # initialize() build a second live isolate). Mirrors Node's + # g_teardown_needed retryable model. On the SUCCESS path below the + # worker is intentionally left undetached -- after _tear_down returns + # the isolate is gone and the worker pointer is invalid. + try: + lib.graal_detach_thread(worker) + except Exception: + pass _teardown_needed = True print( "DataWeave: GraalVM isolate teardown failed; the isolate is " diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 668bb7cc..c42dffd7 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -733,6 +733,108 @@ def test_bootstrap_detach_and_teardown_both_failing_arms_retry_instead_of_leakin assert len(library.tear_down_threads) == 1 +class RetryTeardownFake: + """Fake native lib for the failed-teardown / cross-thread-retry scenario. + + Each graal_attach_thread hands out a distinct, non-null worker pointer so we + can prove WHICH worker is detached. graal_tear_down_isolate fails on the + first call and succeeds afterwards, modelling a transient teardown failure. + """ + + def __init__(self): + self.attach_workers = [] # worker addr for every attach, in order + self.detached = [] # worker addr passed to every detach + self.tear_down_workers = [] # worker addr passed to every teardown + self.attached = set() # addrs currently attached (naive bookkeeping) + self._next_worker = 1 + self._tear_down_calls = 0 + + def graal_attach_thread(self, _isolate, thread_ptr): + addr = self._next_worker * 0x1000 + self._next_worker += 1 + fake_worker = ctypes.cast(ctypes.c_void_p(addr), native.GraalIsolateThreadPointer) + ctypes.cast( + thread_ptr, ctypes.POINTER(native.GraalIsolateThreadPointer) + )[0] = fake_worker + self.attach_workers.append(addr) + self.attached.add(addr) + return 0 + + def graal_detach_thread(self, thread): + addr = ctypes.cast(thread, ctypes.c_void_p).value + self.detached.append(addr) + self.attached.discard(addr) + return 0 + + def graal_tear_down_isolate(self, thread): + self._tear_down_calls += 1 + self.tear_down_workers.append(ctypes.cast(thread, ctypes.c_void_p).value) + return 1 if self._tear_down_calls == 1 else 0 # fail once, then succeed + + +@pytest.mark.unit +def test_failed_teardown_detaches_worker_so_cross_thread_retry_succeeds(monkeypatch): + """Finding #2 (review #11): a FAILED final teardown must detach the freshly- + attached teardown worker before arming the retry. Otherwise a later cross- + thread retry attaches a SECOND worker while the first stays attached, and the + leftover attached worker blocks graal_tear_down_isolate forever. + + A SUCCESSFUL teardown must NOT detach its worker (the isolate is gone and the + thread pointer is invalid), so detaches == attaches - 1 across the scenario. + """ + fake = RetryTeardownFake() + # Set up as if one engine already acquired the shared isolate. Bypass real + # library loading -- drive the globals directly (unit/conftest resets them). + monkeypatch.setattr(native, "_lib", fake) + monkeypatch.setattr(native, "_lib_path", "/tmp/dwlib") + monkeypatch.setattr(native, "_isolate", native.GraalIsolatePointer()) + monkeypatch.setattr(native, "_isolate_ref_count", 1) + monkeypatch.setattr(native, "_teardown_needed", False) + + # Last release on the main thread -> teardown fails once -> retry armed. + with pytest.raises(native.DataWeaveError): + native._release_isolate() + + # The isolate is retained live and a retry is armed. + assert native._isolate is not None + assert native._isolate_ref_count == 0 + assert native._teardown_needed is True + # Exactly one worker was attached to attempt teardown, and it WAS detached + # (the bug: it stayed attached). No worker is left dangling attached. + assert fake.attach_workers == [0x1000] + assert fake.detached == [0x1000] + assert fake.attached == set() + + # A cross-thread retry (another OS thread) must now tear down cleanly with a + # fresh worker and no leftover attached worker blocking it. + errors = [] + + def run_retry(): + try: + with native._isolate_lock: + native._retry_pending_teardown_locked() + except BaseException as error: # pragma: no cover - surfaced via assert + errors.append(error) + + thread = Thread(target=run_retry) + thread.start() + thread.join(5) + + assert not thread.is_alive() + assert not errors + # Retry succeeded: flag cleared and globals nulled. + assert native._teardown_needed is False + assert native._isolate is None + assert native._lib is None + # A second, fresh worker was attached for the retry and used for the + # successful teardown. Its worker is intentionally NOT detached (isolate + # destroyed -> pointer invalid), so only the FAILED-teardown worker (0x1000) + # is ever detached. + assert fake.attach_workers == [0x1000, 0x2000] + assert fake.tear_down_workers == [0x1000, 0x2000] + assert fake.detached == [0x1000] # success path does not detach + + @pytest.mark.unit def test_two_engines_dispatch_to_their_own_resolver(monkeypatch): library = FakeLibrary() From 1ef900fe03a22a0507e9576ca14075db103f6107 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 12:43:22 -0300 Subject: [PATCH 173/216] fix(python): guard module-level global engine creation/cleanup with a lock (review #11 #3) --- native-lib/python/src/dataweave/__init__.py | 25 ++++--- native-lib/python/tests/unit/test_facade.py | 72 ++++++++++++++++++++- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/native-lib/python/src/dataweave/__init__.py b/native-lib/python/src/dataweave/__init__.py index a446621a..5168ab93 100644 --- a/native-lib/python/src/dataweave/__init__.py +++ b/native-lib/python/src/dataweave/__init__.py @@ -1,6 +1,7 @@ """Public facade for the DataWeave Python native binding.""" import ctypes +import threading from typing import Any, Dict, Iterable, Optional @@ -34,16 +35,19 @@ _global_instance: Optional[DataWeave] = None +_global_lock = threading.Lock() def _get_global_instance() -> DataWeave: global _global_instance - if _global_instance is None: - import atexit - _global_instance = DataWeave() - _global_instance.initialize() - atexit.register(cleanup) - return _global_instance + with _global_lock: + if _global_instance is None: + import atexit + candidate = DataWeave() + candidate.initialize() + _global_instance = candidate + atexit.register(cleanup) + return _global_instance def run(script: str, inputs: Optional[Dict[str, Any]] = None, raise_on_error: bool = False) -> ExecutionResult: @@ -68,9 +72,12 @@ def run_input_output_callback(script: str, input_name: str, input_mime_type: str def cleanup() -> None: global _global_instance - if _global_instance is not None: - _global_instance.cleanup() - _global_instance = None + with _global_lock: + if _global_instance is not None: + instance, _global_instance = _global_instance, None + else: + return + instance.cleanup() __all__ = [ diff --git a/native-lib/python/tests/unit/test_facade.py b/native-lib/python/tests/unit/test_facade.py index 3e2504bc..e814c2ac 100644 --- a/native-lib/python/tests/unit/test_facade.py +++ b/native-lib/python/tests/unit/test_facade.py @@ -1,4 +1,6 @@ import inspect +import threading +import time import pytest @@ -164,7 +166,7 @@ def test_cleanup_is_noop_without_global_runtime(): @pytest.mark.unit -def test_global_cleanup_retains_failed_runtime_for_retry(monkeypatch): +def test_global_cleanup_clears_global_before_reraising_on_failure(monkeypatch): created = [] class FakeRuntime: @@ -178,11 +180,77 @@ def cleanup(self): raise dataweave.DataWeaveError("teardown failed") monkeypatch.setattr(dataweave, "DataWeave", FakeRuntime) + monkeypatch.setattr("atexit.register", lambda _fn: None) first = dataweave._get_global_instance() + # cleanup() nulls the global under _global_lock *before* running the + # (potentially slow) instance.cleanup() outside the lock, so a failing + # teardown does not strand the lock held nor leave a half-torn-down + # instance published. The instance identity is not retained for retry -- + # that's fine because isolate-level teardown retry lives one layer down + # in dataweave.native (_teardown_needed), independent of which Python + # DataWeave wrapper object is holding the reference. with pytest.raises(dataweave.DataWeaveError, match="teardown failed"): dataweave.cleanup() + assert dataweave._global_instance is None + second = dataweave._get_global_instance() - assert second is first + assert second is not first + assert len(created) == 2 dataweave._global_instance = None + + +@pytest.mark.unit +def test_get_global_instance_publishes_exactly_one_instance_under_concurrent_first_use(monkeypatch): + thread_count = 8 + barrier = threading.Barrier(thread_count) + counts_lock = threading.Lock() + counts = {"created": 0, "initialized": 0} + + class SlowRuntime: + def __init__(self): + with counts_lock: + counts["created"] += 1 + # Widen the window between the "is it published yet" check and + # publication so concurrent first-callers are very likely to + # overlap while racing to construct+initialize a candidate. + time.sleep(0.05) + + def initialize(self): + with counts_lock: + counts["initialized"] += 1 + + def cleanup(self): + pass + + monkeypatch.setattr(dataweave, "DataWeave", SlowRuntime) + monkeypatch.setattr("atexit.register", lambda _fn: None) + + results = [None] * thread_count + errors = [] + + def worker(index): + barrier.wait() + try: + results[index] = dataweave._get_global_instance() + except Exception as exc: # pragma: no cover - defensive, surfaced via `errors` + errors.append(exc) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(thread_count)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + try: + assert not errors + # Exactly one instance is ever constructed and initialized: creation, + # initialization, and publication all happen under _global_lock, so a + # losing thread never builds (and leaks) a candidate engine. + assert counts["created"] == 1 + assert counts["initialized"] == 1 + assert len({id(result) for result in results}) == 1 + assert results[0] is dataweave._global_instance + finally: + dataweave._global_instance = None From c4f3f4ae449ac53cf818a07d2c67d703c000efbb Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 12:52:03 -0300 Subject: [PATCH 174/216] fix(native-lib): fail closed on malformed input JSON instead of executing on partial bindings (review #11 #5) --- .../org/mule/weave/lib/ScriptRuntime.java | 92 +++++++++---------- .../org/mule/weave/lib/ScriptRuntimeTest.java | 62 +++++++++++++ 2 files changed, 108 insertions(+), 46 deletions(-) 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 cb46317e..b8857313 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 @@ -120,10 +120,10 @@ public String run(String script) { * @return a JSON string describing either the successful result or an error */ public String run(String script, String inputsJson) { - ScriptingBindings bindings = parseJsonInputsToBindings(inputsJson); - String[] inputs = bindings.bindingNames(); - try { + ScriptingBindings bindings = parseJsonInputsToBindings(inputsJson); + String[] inputs = bindings.bindingNames(); + DWScript compiled = engine.compileDWScript(script, inputs); DWResult dwResult = compiled.writeDWResult(bindings); @@ -168,10 +168,10 @@ public String run(String script, String inputsJson) { * @return a {@link StreamSession} with the result stream and metadata, or an error session */ public StreamSession runStreaming(String script, String inputsJson) { - ScriptingBindings bindings = parseJsonInputsToBindings(inputsJson); - String[] inputs = bindings.bindingNames(); - try { + ScriptingBindings bindings = parseJsonInputsToBindings(inputsJson); + String[] inputs = bindings.bindingNames(); + DWScript compiled = engine.compileDWScript(script, inputs); DWResult dwResult = compiled.writeDWResult(bindings); @@ -201,48 +201,48 @@ private ScriptingBindings parseJsonInputsToBindings(String inputsJson) { return bindings; } - try { - JSONObject root = new JSONObject(inputsJson); - - for (String name : root.keySet()) { - JSONObject entry = root.getJSONObject(name); - - if (entry.has("streamHandle")) { - long streamHandle = Long.parseLong(entry.getString("streamHandle")); - InputStreamSession inputSession = InputStreamSession.get(streamHandle); - if (inputSession == null) { - throw new RuntimeException("Invalid streamHandle " + streamHandle + " for input '" + name + "'"); - } - String mimeTypeRaw = entry.optString("mimeType", inputSession.getMimeType()); - String charsetRaw = entry.optString("charset", inputSession.getCharset()); - Charset charset = Charset.forName(charsetRaw); - Option mimeType = Option.apply(mimeTypeRaw); - - BindingValue bindingValue = new BindingValue(inputSession.getInputStream(), mimeType, Map$.MODULE$.empty(), charset); - bindings.addBinding(name, bindingValue); - - } else if (entry.has("content")) { - String contentRaw = entry.getString("content"); - String mimeTypeRaw = entry.optString("mimeType", null); - String charsetRaw = entry.optString("charset", "UTF-8"); - - Map properties = Map$.MODULE$.empty(); - if (entry.has("properties") && !entry.isNull("properties")) { - JSONObject propsObj = entry.getJSONObject("properties"); - properties = parseJsonProperties(propsObj); - } - - Charset charset = Charset.forName(charsetRaw); - Option mimeType = Option.apply(mimeTypeRaw); - - byte[] content = Base64.getDecoder().decode(contentRaw); - BindingValue bindingValue = new BindingValue(content, mimeType, properties, charset); - bindings.addBinding(name, bindingValue); + // Fail closed: any malformed entry (bad JSON / base64 / charset / streamHandle / + // properties) must propagate so the caller returns an error result rather than + // silently executing on partial/empty bindings. Because `bindings` is only + // returned after the loop completes, a propagated exception discards any + // partially-built bindings automatically. + JSONObject root = new JSONObject(inputsJson); + + for (String name : root.keySet()) { + JSONObject entry = root.getJSONObject(name); + + if (entry.has("streamHandle")) { + long streamHandle = Long.parseLong(entry.getString("streamHandle")); + InputStreamSession inputSession = InputStreamSession.get(streamHandle); + if (inputSession == null) { + throw new RuntimeException("Invalid streamHandle " + streamHandle + " for input '" + name + "'"); } + String mimeTypeRaw = entry.optString("mimeType", inputSession.getMimeType()); + String charsetRaw = entry.optString("charset", inputSession.getCharset()); + Charset charset = Charset.forName(charsetRaw); + Option mimeType = Option.apply(mimeTypeRaw); + + BindingValue bindingValue = new BindingValue(inputSession.getInputStream(), mimeType, Map$.MODULE$.empty(), charset); + bindings.addBinding(name, bindingValue); + + } else if (entry.has("content")) { + String contentRaw = entry.getString("content"); + String mimeTypeRaw = entry.optString("mimeType", null); + String charsetRaw = entry.optString("charset", "UTF-8"); + + Map properties = Map$.MODULE$.empty(); + if (entry.has("properties") && !entry.isNull("properties")) { + JSONObject propsObj = entry.getJSONObject("properties"); + properties = parseJsonProperties(propsObj); + } + + Charset charset = Charset.forName(charsetRaw); + Option mimeType = Option.apply(mimeTypeRaw); + + byte[] content = Base64.getDecoder().decode(contentRaw); + BindingValue bindingValue = new BindingValue(content, mimeType, properties, charset); + bindings.addBinding(name, bindingValue); } - } catch (Exception e) { - System.err.println("Error parsing JSON inputs: " + e.getMessage()); - e.printStackTrace(); } return bindings; 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 4a3edfc2..77fa2a43 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 @@ -690,6 +690,68 @@ void unknownEngineHandleProducesExactErrorJson() { NativeLib.UNKNOWN_ENGINE_HANDLE_JSON); } + // ── Fail-closed input parsing (review #11 #5) ────────────────────────── + + /** (a) Malformed inputs JSON must fail closed, not silently run on empty bindings. */ + @Test + void runMalformedInputsJsonFailsClosed() { + ScriptRuntime runtime = new ScriptRuntime(); + // Script has no input dependency, so pre-fix the swallowed parse error + // would let this run on empty bindings and return success:true. + String result = runtime.run("1 + 1", "{not json"); + assertTrue(result.contains("\"success\":false"), + "Expected success:false for malformed inputs JSON, got: " + result); + assertFalse(Result.parse(result).success); + } + + /** + * (b) A malformed SECOND entry (invalid base64 content) must fail the whole run, + * not silently drop the entry and execute bound to only the first entry. + */ + @Test + void runMalformedSecondEntryFailsClosed() { + ScriptRuntime runtime = new ScriptRuntime(); + + // First entry valid; second entry has invalid base64 content. + String inputsJson = String.format( + "{\"num1\": {\"content\": \"%s\", \"mimeType\": \"application/json\"}, " + + "\"num2\": {\"content\": \"@@@not-valid-base64@@@\", \"mimeType\": \"application/json\"}}", + encode(10)); + + // Script references only the first binding: pre-fix the second (malformed) + // entry would be silently dropped and this would wrongly succeed on num1 alone. + String result = runtime.run("num1", inputsJson); + assertFalse(Result.parse(result).success, + "Expected fail-closed on malformed second entry, got: " + result); + assertNotNull(Result.parse(result).error); + + // And a script that references the malformed binding must not run on a missing var. + String result2 = runtime.run("num1 + num2", inputsJson); + assertFalse(Result.parse(result2).success, + "Expected fail-closed when referencing malformed binding, got: " + result2); + } + + /** (c) A valid single-entry inputs doc must still run successfully (no regression). */ + @Test + void runValidSingleEntryStillSucceeds() { + ScriptRuntime runtime = new ScriptRuntime(); + String inputsJson = String.format( + "{\"num1\": {\"content\": \"%s\", \"mimeType\": \"application/json\"}}", + encode(41)); + String result = runtime.run("num1 + 1", inputsJson); + assertTrue(Result.parse(result).success, "Expected success for valid inputs, got: " + result); + assertEquals("42", Result.parse(result).result); + } + + /** runStreaming must return an error session on malformed inputs JSON. */ + @Test + void runStreamingMalformedInputsJsonReturnsErrorSession() { + ScriptRuntime runtime = new ScriptRuntime(); + StreamSession session = runtime.runStreaming("1 + 1", "{not json"); + assertTrue(session.isError(), "Expected error session for malformed inputs JSON"); + assertNotNull(session.getError()); + } + static class Result { boolean success; String result; From ca2c28ad35853d1856e172abb649ddc098ed3635 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 13:04:17 -0300 Subject: [PATCH 175/216] test(tck): run known execution failures as strict expected failures instead of skips (review #11 #7) --- native-lib/node/tests/tck/ignore-list.ts | 65 +++++++++++++++++-- native-lib/node/tests/tck/tck.test.ts | 46 +++++++++++-- native-lib/node/tests/unit/tck-policy.test.ts | 8 ++- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/native-lib/node/tests/tck/ignore-list.ts b/native-lib/node/tests/tck/ignore-list.ts index 6fb913a7..d973f4cb 100644 --- a/native-lib/node/tests/tck/ignore-list.ts +++ b/native-lib/node/tests/tck/ignore-list.ts @@ -91,10 +91,10 @@ const LEGACY_IGNORED_CASES: Readonly> = { "multipart-binary-out.multipart": { reason: "multipart: boundary nondeterminism + binary part encoding" }, "multipart-class-cast-issue-out.multipart": { reason: "multipart: boundary nondeterminism" }, "multipart-empty-part-out.multipart": { reason: "multipart: boundary nondeterminism + empty part handling" }, - "multipart-mixed-message-out.multipart": { reason: "multipart: execution fails — 'Multipart Object has empty `parts`' (skip, not an output-mismatch xfail)" }, + "multipart-mixed-message-out.multipart": { reason: "multipart: execution fails — 'Multipart Object has empty `parts`' (see EXPECTED_EXECUTION_FAILURES, not a skip)" }, "multipart-write-binary-out.json": { reason: "multipart: binary part write" }, - "multipart-write-message-out.multipart": { reason: "multipart: execution fails — 'Multipart Object has empty `parts`' (skip, not an output-mismatch xfail)" }, - "multipart-write-subtype-override-out.multipart": { reason: "multipart: execution fails — 'Multipart Object has empty `parts`' (skip, not an output-mismatch xfail)" }, + "multipart-write-message-out.multipart": { reason: "multipart: execution fails — 'Multipart Object has empty `parts`' (see EXPECTED_EXECUTION_FAILURES, not a skip)" }, + "multipart-write-subtype-override-out.multipart": { reason: "multipart: execution fails — 'Multipart Object has empty `parts`' (see EXPECTED_EXECUTION_FAILURES, not a skip)" }, // slow — passes but risks exceeding the 30s test timeout on CI "big_intersection-out.json": { reason: "slow: 500-way intersection type exceeds the test timeout" }, @@ -108,10 +108,10 @@ const LEGACY_IGNORED_CASES: Readonly> = { "properties-writer-out.properties": { reason: "nondeterministic: properties output embeds a timestamp comment" }, // coercion/runtime behavior (also CLI-ignored) - "access_raw_value-out.json": { reason: "coercion/runtime: Cannot coerce Null to String" }, + "access_raw_value-out.json": { reason: "coercion/runtime: execution fails — 'Cannot coerce Null to String' (see EXPECTED_EXECUTION_FAILURES, not a skip)" }, "csv-invalid-utf8-out.csv": { reason: "coercion/runtime: csv invalid utf8 handling" }, - "read-concat-out.json": { reason: "coercion/runtime: Cannot coerce Null to String" }, - "update-op-out.dwl": { reason: "coercion/runtime: Cannot coerce Null to Number" }, + "read-concat-out.json": { reason: "coercion/runtime: execution fails — 'Cannot coerce Null to String' (see EXPECTED_EXECUTION_FAILURES, not a skip)" }, + "update-op-out.dwl": { reason: "coercion/runtime: execution fails — 'Cannot coerce Null (null) to Number' (see EXPECTED_EXECUTION_FAILURES, not a skip)" }, // xml — attribute selector runtime behavior or serialization differences "multi_attribute_selector_after_empty_filter_slot-out.json": { reason: "xml: attribute selector runtime behavior" }, @@ -186,10 +186,29 @@ export const REENABLED_CASES = [ "runtime/repeated_attribute_selector_map_slot_permutations-out.json", ] as const; +// Cases that are known to FAIL AT EXECUTION (not merely produce a mismatched +// output). These run — they are not skipped — and the harness asserts +// `result.success === false` plus a stable error-message discriminator, so a +// behavior recovery (fixed upstream) or a different failure (regression) both +// turn the case red instead of staying silently green under a skip. +export const EXPECTED_EXECUTION_FAILURES: Readonly> = { + "core-modules/multipart-mixed-message-out.multipart:out.multipart": { errorMatch: "Multipart Object has empty `parts`" }, + "core-modules/multipart-write-message-out.multipart:out.multipart": { errorMatch: "Multipart Object has empty `parts`" }, + "core-modules/multipart-write-subtype-override-out.multipart:out.multipart": { errorMatch: "Multipart Object has empty `parts`" }, + "runtime/access_raw_value-out.json:out.json": { errorMatch: "Cannot coerce Null to String" }, + "runtime/read-concat-out.json:out.json": { errorMatch: "Cannot coerce Null to String" }, + "runtime/update-op-out.dwl:out.dwl": { errorMatch: "Cannot coerce Null (null) to Number" }, +} as const; + +const EXPECTED_EXECUTION_FAILURE_CASES = new Set( + Object.keys(EXPECTED_EXECUTION_FAILURES).map((identifier) => identifier.slice(0, identifier.lastIndexOf(":"))) +); + export const CAPABILITY_EXCLUSIONS = Object.fromEntries( Object.entries(LEGACY_POLICY).filter(([identifier]) => !Object.keys(ACCEPTED_BASELINE_MISMATCHES).some((scenario) => scenario.startsWith(`${identifier}:`)) && !REENABLED_CASES.includes(identifier as typeof REENABLED_CASES[number]) + && !EXPECTED_EXECUTION_FAILURE_CASES.has(identifier) ) ); @@ -250,6 +269,7 @@ export function validateReconciledPolicy( expectedFailures: ExpectedFailurePolicy, reenabledCases: readonly string[] = [], runnableScenarios?: ReadonlySet, + expectedExecutionFailures: Readonly> = {}, ): string[] { const errors: string[] = []; const reenabledCounts = new Map(); @@ -270,9 +290,15 @@ export function validateReconciledPolicy( const expectedFailureCases = new Set( Object.keys(expectedFailures).map((identifier) => identifier.slice(0, identifier.lastIndexOf(":"))) ); + const execFailureCases = new Set( + Object.keys(expectedExecutionFailures).map((identifier) => identifier.slice(0, identifier.lastIndexOf(":"))) + ); errors.push(...[...reenabledCounts.keys()] .filter((identifier) => expectedFailureCases.has(identifier)) .map((identifier) => `${identifier}: case is both re-enabled and expected to fail`)); + errors.push(...[...reenabledCounts.keys()] + .filter((identifier) => execFailureCases.has(identifier)) + .map((identifier) => `${identifier}: case is both re-enabled and expected to fail execution`)); for (const [identifier, reason] of Object.entries(expectedFailures)) { if (!reason.trim()) errors.push(`${identifier}: missing expected-failure reason`); if (runnableScenarios && !runnableScenarios.has(identifier)) { @@ -282,6 +308,19 @@ export function validateReconciledPolicy( if (Object.prototype.hasOwnProperty.call(exclusions, caseIdentifier)) { errors.push(`${identifier}: case is both skipped and expected to fail`); } + if (execFailureCases.has(caseIdentifier)) { + errors.push(`${identifier}: case is both an output-mismatch xfail and an expected execution failure`); + } + } + for (const [identifier, entry] of Object.entries(expectedExecutionFailures)) { + if (!entry.errorMatch.trim()) errors.push(`${identifier}: missing errorMatch discriminator`); + if (runnableScenarios && !runnableScenarios.has(identifier)) { + errors.push(`${identifier}: not a discovered runnable scenario`); + } + const caseIdentifier = identifier.slice(0, identifier.lastIndexOf(":")); + if (Object.prototype.hasOwnProperty.call(exclusions, caseIdentifier)) { + errors.push(`${identifier}: case is both skipped and an expected execution failure`); + } } return errors.sort(); } @@ -310,3 +349,17 @@ export function isIgnored(caseIdentifier: string): boolean { export function ignoreReason(caseIdentifier: string): string | undefined { return IGNORED_CASES[caseIdentifier]?.reason; } + +/** + * Whether a scenario (full `/:` id) is a strict expected + * execution failure — it must run and fail with the returned discriminator, + * rather than being skipped. + */ +export function isExpectedExecutionFailure(scenarioId: string): { errorMatch: string } | undefined { + return EXPECTED_EXECUTION_FAILURES[scenarioId]; +} + +/** Whether a case identifier (`/`) has an expected execution failure scenario. */ +export function isExpectedExecutionFailureCase(caseIdentifier: string): boolean { + return EXPECTED_EXECUTION_FAILURE_CASES.has(caseIdentifier); +} diff --git a/native-lib/node/tests/tck/tck.test.ts b/native-lib/node/tests/tck/tck.test.ts index 821de12d..0693a628 100644 --- a/native-lib/node/tests/tck/tck.test.ts +++ b/native-lib/node/tests/tck/tck.test.ts @@ -14,10 +14,13 @@ import { hasAdjacentDwlModule, parseCase, MAIN_TRANSFORM, type TckScenario } fro import { compareOutput } from "./compare"; import { ACCEPTED_BASELINE_MISMATCHES, + EXPECTED_EXECUTION_FAILURES, IGNORED_CASES, REENABLED_CASES, STRUCTURAL_MODULE_CASES, isIgnored, + isExpectedExecutionFailure, + isExpectedExecutionFailureCase, ignoreReason, validateIgnorePolicy, validateInventoryPolicy, @@ -110,7 +113,13 @@ if (!existsSync(SUITES_DIR)) { const policyErrors = [ ...validateInventoryPolicy(cases.length, skipped), ...validateIgnorePolicy(IGNORED_CASES, runnableCases), - ...validateReconciledPolicy(IGNORED_CASES, ACCEPTED_BASELINE_MISMATCHES, REENABLED_CASES, runnableScenarios), + ...validateReconciledPolicy( + IGNORED_CASES, + ACCEPTED_BASELINE_MISMATCHES, + REENABLED_CASES, + runnableScenarios, + EXPECTED_EXECUTION_FAILURES, + ), ...validateStructuralModulePolicy(STRUCTURAL_MODULE_CASES, structuralModuleCases), ]; if (policyErrors.length > 0) { @@ -128,20 +137,32 @@ if (!existsSync(SUITES_DIR)) { console.log( `TCK: ${cases.length} runnable cases, ${skipped} structurally skipped, ` + `${structuralModuleCases.size} structural module cases, ${Object.keys(IGNORED_CASES).length} exclusions, ` - + `${Object.keys(ACCEPTED_BASELINE_MISMATCHES).length} expected failures` + + `${Object.keys(ACCEPTED_BASELINE_MISMATCHES).length} expected output-mismatch failures, ` + + `${Object.keys(EXPECTED_EXECUTION_FAILURES).length} expected execution failures` ); dw.initialize(); for (const c of cases) { - const ignored = isIgnored(c.caseIdentifier); + // Cases with an expected execution failure must run — the harness + // asserts result.success === false plus a stable error discriminator + // for them, so they are never skipped even though they're also + // recorded in the legacy ignore registry for suite-routing purposes. + const ignored = isIgnored(c.caseIdentifier) && !isExpectedExecutionFailureCase(c.caseIdentifier); for (const scenario of c.scenarios) { const expectedFailure = ACCEPTED_BASELINE_MISMATCHES[scenario.name]; + const execFail = isExpectedExecutionFailure(scenario.name); const testFn = ignored ? it.skip : it; const label = ignored ? `${scenario.name} [skip: ${ignoreReason(c.caseIdentifier)}]` - : expectedFailure - ? `${scenario.name} [xfail: ${expectedFailure}]` - : scenario.name; + : execFail + // Reuse the "[xfail:" marker (not "[exec-xfail:") so the TCK + // accounting reporter's `scenarioIdentifier`/xfailed detection + // (tests/tck/reporter.ts), which only recognizes the literal + // "skip:"/"xfail:" prefixes, still classifies these correctly. + ? `${scenario.name} [xfail: execution failure: ${execFail.errorMatch}]` + : expectedFailure + ? `${scenario.name} [xfail: ${expectedFailure}]` + : scenario.name; testFn(label, () => { const script = readFileSync(join(c.dir, MAIN_TRANSFORM), "utf-8"); @@ -153,6 +174,19 @@ if (!existsSync(SUITES_DIR)) { ); const result = dw.run(script, inputs); + + if (execFail) { + expect( + result.success, + `${scenario.name}: expected execution failure but it succeeded — remove it from EXPECTED_EXECUTION_FAILURES` + ).toBe(false); + expect( + result.error ?? "", + `${scenario.name}: execution failed but error changed — update the errorMatch discriminator` + ).toContain(execFail.errorMatch); + return; + } + expect(result.success, `script failed: ${result.error}`).toBe(true); const actual = result.getBytes()!; diff --git a/native-lib/node/tests/unit/tck-policy.test.ts b/native-lib/node/tests/unit/tck-policy.test.ts index 80ccede1..3188fe07 100644 --- a/native-lib/node/tests/unit/tck-policy.test.ts +++ b/native-lib/node/tests/unit/tck-policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { ACCEPTED_BASELINE_MISMATCHES, CAPABILITY_EXCLUSIONS, + EXPECTED_EXECUTION_FAILURES, IGNORED_CASES, REENABLED_CASES, STRUCTURAL_MODULE_CASES, @@ -43,9 +44,10 @@ describe("TCK ignore policy", () => { ]); }); - it("reconciles exclusions into capability skips and strict xfails", () => { - expect(Object.keys(CAPABILITY_EXCLUSIONS)).toHaveLength(38); + it("reconciles exclusions into capability skips, strict xfails, and strict exec-xfails", () => { + expect(Object.keys(CAPABILITY_EXCLUSIONS)).toHaveLength(32); expect(Object.keys(ACCEPTED_BASELINE_MISMATCHES)).toHaveLength(15); + expect(Object.keys(EXPECTED_EXECUTION_FAILURES)).toHaveLength(6); expect(REENABLED_CASES).toHaveLength(6); expect(CAPABILITY_EXCLUSIONS).toHaveProperty("runtime/big_intersection-out.json"); expect(IGNORED_CASES).toBe(CAPABILITY_EXCLUSIONS); @@ -53,6 +55,8 @@ describe("TCK ignore policy", () => { CAPABILITY_EXCLUSIONS, ACCEPTED_BASELINE_MISMATCHES, REENABLED_CASES, + undefined, + EXPECTED_EXECUTION_FAILURES, )).toEqual([]); }); From feb84746b8a1845cbdde3775c2ac5075def6655c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 13:10:42 -0300 Subject: [PATCH 176/216] test(tck): table-driven policy checks for expected-execution-failure scenarios (review #11 #8) --- native-lib/node/tests/unit/tck-policy.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/native-lib/node/tests/unit/tck-policy.test.ts b/native-lib/node/tests/unit/tck-policy.test.ts index 3188fe07..dd8a799a 100644 --- a/native-lib/node/tests/unit/tck-policy.test.ts +++ b/native-lib/node/tests/unit/tck-policy.test.ts @@ -95,6 +95,27 @@ describe("TCK ignore policy", () => { "expected 193 structurally skipped cases, discovered 194", ]); }); + + const EXEC_FAILURE_TABLE = [ + ["core-modules/multipart-mixed-message-out.multipart:out.multipart", "Multipart Object has empty `parts`"], + ["core-modules/multipart-write-message-out.multipart:out.multipart", "Multipart Object has empty `parts`"], + ["core-modules/multipart-write-subtype-override-out.multipart:out.multipart", "Multipart Object has empty `parts`"], + ["runtime/access_raw_value-out.json:out.json", "Cannot coerce Null to String"], + ["runtime/read-concat-out.json:out.json", "Cannot coerce Null to String"], + ["runtime/update-op-out.dwl:out.dwl", "Cannot coerce Null (null) to Number"], + ] as const; + + it("has exactly the expected 6 execution-failure scenario identifiers", () => { + expect(Object.keys(EXPECTED_EXECUTION_FAILURES)).toEqual(EXEC_FAILURE_TABLE.map(([scenarioId]) => scenarioId)); + }); + + it.each(EXEC_FAILURE_TABLE)("classifies %s as a strict expected execution failure", (scenarioId, errorMatch) => { + expect(EXPECTED_EXECUTION_FAILURES).toHaveProperty([scenarioId]); + expect(EXPECTED_EXECUTION_FAILURES[scenarioId].errorMatch).toBe(errorMatch); + const caseId = scenarioId.slice(0, scenarioId.lastIndexOf(":")); + expect(CAPABILITY_EXCLUSIONS).not.toHaveProperty([caseId]); + expect(ACCEPTED_BASELINE_MISMATCHES).not.toHaveProperty([scenarioId]); + }); }); describe("TCK structural-module policy", () => { From 0450b4f59a8bc6e331acf6ea6a09940ea4a31015 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 13:23:49 -0300 Subject: [PATCH 177/216] docs(native-lib): correct feeder-error read comment to state the volatile-read guarantee (review #11 final) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/java/org/mule/weave/lib/NativeLib.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 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 11063e52..4bf6e2a9 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 @@ -158,12 +158,13 @@ private static CCharPointer transformViaCallbacks( session.closeStream(); } - // The feeder ran concurrently; by the time output streaming reached EOF it has - // finished. If it stopped on a read-callback contract violation (out-of-range - // length), the input was truncated — surface that as an error rather than presenting - // a success envelope built on partial input. Safe to read here: cleanupFeeder (in the - // finally) does not null feederRunnable. The engine usually errors first via - // session.isError(); this catches the case where it tolerated the truncated input. + // The feeder ran concurrently. If it stopped on a read-callback contract violation + // (out-of-range length), the input was truncated — surface that as an error rather + // than presenting a success envelope built on partial input. getError() is a volatile + // read, correct to observe here regardless of exactly when the feeder finished; and + // cleanupFeeder (in the finally) does not null feederRunnable, so the reference stays + // valid. The engine usually errors first via session.isError(); this catches the case + // where it tolerated the truncated input. String feederError = feederRunnable.getError(); if (feederError != null) { return toUnmanagedCString("{\"success\":false,\"error\":\"" From cb281a3cbf73551ea43ed517c13da6befa3f626b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 16:20:37 -0300 Subject: [PATCH 178/216] fix(native-lib): join input feeder before reading its terminal error in transformViaCallbacks (review #12 #1) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/org/mule/weave/lib/NativeLib.java | 29 +++++++---- .../mule/weave/lib/NativeLibFeederTest.java | 50 +++++++++++++++++++ 2 files changed, 69 insertions(+), 10 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 4bf6e2a9..84bb0fb3 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 @@ -116,6 +116,7 @@ private static CCharPointer transformViaCallbacks( InputCallbackFeeder feederRunnable = null; Thread feeder = null; + boolean cleaned = false; try { // Start a background thread that calls the readCallback and feeds data into the pipe. // Word types (CCharPointer, CFunctionPointer, PointerBase) cannot be captured in @@ -158,13 +159,18 @@ private static CCharPointer transformViaCallbacks( session.closeStream(); } - // The feeder ran concurrently. If it stopped on a read-callback contract violation - // (out-of-range length), the input was truncated — surface that as an error rather - // than presenting a success envelope built on partial input. getError() is a volatile - // read, correct to observe here regardless of exactly when the feeder finished; and - // cleanupFeeder (in the finally) does not null feederRunnable, so the reference stays - // valid. The engine usually errors first via session.isError(); this catches the case - // where it tolerated the truncated input. + // Stop and JOIN the feeder BEFORE reading its terminal error: an in-flight read + // callback that fails *after* output reached EOF sets feederError only once it + // returns, so we must wait for run() to finish or a late failure would be missed and + // success returned. cleanupFeeder cancels, closes the input session (unblocking a + // feeder parked on pipe backpressure so the join cannot hang), and joins. If it + // stopped on a read-callback contract violation (out-of-range length), the input was + // truncated — surface that as an error rather than presenting a success envelope + // built on partial input. The engine usually errors first via session.isError(); this + // catches the case where it tolerated the truncated input. + cleanupFeeder(feederRunnable, feeder, inputHandle); + cleaned = true; + String feederError = feederRunnable.getError(); if (feederError != null) { return toUnmanagedCString("{\"success\":false,\"error\":\"" @@ -185,9 +191,12 @@ private static CCharPointer transformViaCallbacks( return toUnmanagedCString("{\"success\":false,\"error\":\"" + escapeJsonString(m) + "\"}"); } finally { - // Sole close of the input handle for every path once the feeder region is entered. - // Safe (and a no-op cancel/join) when the feeder never started. - cleanupFeeder(feederRunnable, feeder, inputHandle); + // Sole close of the input handle + feeder join for every path that did not already + // clean up in-try (exception paths and the early error returns). Safe (and a no-op + // cancel/join) when the feeder never started. + if (!cleaned) { + cleanupFeeder(feederRunnable, feeder, inputHandle); + } } } diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java index bcf92fa1..aee16df8 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -269,4 +269,54 @@ public void setFeeder(NativeLib.InputCallbackFeeder feeder) { private static final class NativeLibFeederConstants { static final int BUFFER = 8 * 1024; } + + // ── Join-before-getError ordering contract (review #12 #1, High) ───── + + /** + * Contract test for the ordering {@code transformViaCallbacks} relies on: a terminal feeder + * error set by an in-flight {@code readChunk} is only guaranteed visible after + * {@code cleanupFeeder} has joined the feeder thread, not before. Before the round-12 fix, + * {@code transformViaCallbacks} read {@code getError()} before joining the feeder in its + * in-try path, so a callback that was still running when output reached EOF and failed only + * after returning could have its failure missed and a {@code success:true} envelope returned + * instead. This test proves the invariant the fix depends on: pre-join the error is not yet + * observable, and {@code cleanupFeeder} does not return until the join completes and the + * error becomes visible. + */ + @Test + void getErrorReflectsLateFailureOnlyAfterJoin() throws Exception { + CountDownLatch release = new CountDownLatch(1); + InputStreamSession session = new InputStreamSession("application/json", null); + long handle = session.register(); + // A feeder whose read callback blocks until released, then reports an out-of-range + // length (the "in-flight callback fails after output EOF" case). Returning the + // out-of-range value directly (rather than calling the private rejectOutOfRange helper, + // which isn't visible to this subclass) exercises run()'s own defence-in-depth check, + // exactly like readCallbackLengthAboveMaxIsRejectedAsError above. + NativeLib.InputCallbackFeeder feeder = new NativeLib.InputCallbackFeeder(0L, 0L, session) { + @Override + int readChunk(byte[] dest, int max) { + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return max + 1; // one past the buffer: recorded as a feeder error, loop breaks + } + }; + Thread t = new Thread(feeder, "dw-input-callback-feeder-test"); + t.setDaemon(true); + t.start(); + + // Pre-join: the callback is still blocked, so no terminal error is visible yet. + assertNull(feeder.getError()); + + // Releasing + joining (via cleanupFeeder) must wait for run() to finish and make the + // late failure observable. + release.countDown(); + NativeLib.cleanupFeeder(feeder, t, handle); + + assertFalse(t.isAlive()); + assertNotNull(feeder.getError()); + } } From 7b79b1cad81780a76701d5da97289d0ca2e975f1 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 16:39:42 -0300 Subject: [PATCH 179/216] test(native-lib): add regression guard for join-before-getError ordering (review #12 #1 round 1) Extract the join-then-select-result step of transformViaCallbacks into a package-private selectTransformResult(...) helper that returns a plain String, so the exact ordering (cleanupFeeder join, then getError()) is directly JVM-testable without the GraalVM Word-typed CCharPointer return. The prior test only exercised cleanupFeeder's own join contract, which does not fail if the bug is reintroduced one level up; the new test drives selectTransformResult with a still-blocked read callback and would observe a frozen success:true if getError() were ever read before the join. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/org/mule/weave/lib/NativeLib.java | 65 ++++++++++++------- .../mule/weave/lib/NativeLibFeederTest.java | 61 +++++++++++++++++ 2 files changed, 104 insertions(+), 22 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 84bb0fb3..7abf0832 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 @@ -159,29 +159,12 @@ private static CCharPointer transformViaCallbacks( session.closeStream(); } - // Stop and JOIN the feeder BEFORE reading its terminal error: an in-flight read - // callback that fails *after* output reached EOF sets feederError only once it - // returns, so we must wait for run() to finish or a late failure would be missed and - // success returned. cleanupFeeder cancels, closes the input session (unblocking a - // feeder parked on pipe backpressure so the join cannot hang), and joins. If it - // stopped on a read-callback contract violation (out-of-range length), the input was - // truncated — surface that as an error rather than presenting a success envelope - // built on partial input. The engine usually errors first via session.isError(); this - // catches the case where it tolerated the truncated input. - cleanupFeeder(feederRunnable, feeder, inputHandle); + // Join the feeder and select the success/error envelope. Delegated to a helper that + // returns a plain String (rather than inlined here) so a JVM unit test can assert the + // join-before-getError() ordering directly against the exact production code path. + String resultJson = selectTransformResult(feederRunnable, feeder, inputHandle, session); cleaned = true; - - String feederError = feederRunnable.getError(); - if (feederError != null) { - return toUnmanagedCString("{\"success\":false,\"error\":\"" - + escapeJsonString(feederError) + "\"}"); - } - - return toUnmanagedCString("{\"success\":true" - + ",\"mimeType\":\"" + session.getMimeType() + "\"" - + ",\"charset\":\"" + session.getCharset() + "\"" - + ",\"binary\":" + session.isBinary() - + "}"); + return toUnmanagedCString(resultJson); } catch (Exception e) { // No Java exception may escape this @CEntryPoint: convert to an error envelope. String m = e.getMessage(); @@ -200,6 +183,44 @@ private static CCharPointer transformViaCallbacks( } } + /** + * Joins the input feeder — via {@link #cleanupFeeder} — and only then reads its + * terminal error, returning the {@code success:false} envelope if it failed or the + * {@code success:true} envelope built from {@code outputSession} otherwise. + * + *

Ordering is the entire point of this method: an in-flight read + * callback that fails after output reached EOF sets {@link InputCallbackFeeder}'s + * terminal error only once it returns, so {@link InputCallbackFeeder#getError()} must not be + * read until {@code cleanupFeeder} has cancelled, unblocked (by closing the input session), + * and joined the feeder thread to completion — otherwise a late failure is missed and + * {@code success:true} is returned over truncated input. The engine usually errors first via + * {@code StreamSession.isError()} (checked by the caller before this method runs); this + * covers the case where it tolerated the truncated input instead.

+ * + *

Package-private (rather than folded inline into {@link #transformViaCallbacks}) so a JVM + * unit test can assert the join-then-read ordering against this exact code path: + * {@code transformViaCallbacks} itself returns a GraalVM {@code CCharPointer}, which cannot be + * exercised from a hosted JVM, but this method returns a plain {@link String}. A test driving + * an in-flight failing read callback through this method would observe {@code success:true} + * instead of the feeder's error if {@code getError()} were ever read before the join — the + * exact regression this method's ordering prevents.

+ */ + static String selectTransformResult(InputCallbackFeeder feederRunnable, Thread feeder, + long inputHandle, StreamSession outputSession) { + cleanupFeeder(feederRunnable, feeder, inputHandle); + + String feederError = feederRunnable.getError(); + if (feederError != null) { + return "{\"success\":false,\"error\":\"" + escapeJsonString(feederError) + "\"}"; + } + + return "{\"success\":true" + + ",\"mimeType\":\"" + outputSession.getMimeType() + "\"" + + ",\"charset\":\"" + outputSession.getCharset() + "\"" + + ",\"binary\":" + outputSession.isBinary() + + "}"; + } + /** * Registers a new {@link InputStreamSession} for the callback-supplied input and merges its * stream-handle entry into {@code inputs}. diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java index aee16df8..9a5dff0d 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -319,4 +320,64 @@ int readChunk(byte[] dest, int max) { assertFalse(t.isAlive()); assertNotNull(feeder.getError()); } + + /** + * Regression guard for review #12 #1 round 1 follow-up: {@code getErrorReflectsLateFailureOnlyAfterJoin} + * above only proves {@code cleanupFeeder}'s own join contract — it does not touch + * {@code transformViaCallbacks}'s (now {@link NativeLib#selectTransformResult}'s) ordering of + * "join, then read {@code getError()}". This test drives {@code selectTransformResult} + * itself: a read callback blocks (models an in-flight {@code cb.invoke}) and is only released + * from a background thread strictly after the call under test has begun, so the feeder is + * guaranteed still running — and its error not yet recorded — at the moment + * {@code selectTransformResult} is invoked. + * + *

If {@code selectTransformResult} ever read {@code getError()} before joining the feeder + * (i.e. reintroduced the exact round-12 #1 bug inside the extracted method), this test would + * observe a frozen {@code success:true} envelope decided before the late failure was recorded + * — this assertion is what would catch that regression.

+ */ + @Test + void selectTransformResultObservesLateFailureOnlyAfterJoin() throws Exception { + CountDownLatch release = new CountDownLatch(1); + InputStreamSession inputSession = new InputStreamSession("application/json", null); + long inputHandle = inputSession.register(); + NativeLib.InputCallbackFeeder feeder = new NativeLib.InputCallbackFeeder(0L, 0L, inputSession) { + @Override + int readChunk(byte[] dest, int max) { + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return max + 1; // one past the buffer: recorded as a feeder error once this returns + } + }; + Thread t = new Thread(feeder, "dw-select-result-test"); + t.setDaemon(true); + t.start(); + + // Release the blocked callback ~100ms from now, on a separate thread, so the call under + // test below begins while the feeder is still guaranteed to be blocked (no error + // recorded yet). A correct implementation's join (inside cleanupFeeder) then waits for + // this release before reading getError(); a buggy re-ordering would read getError() -- and + // freeze the (wrong) success decision -- immediately, before the release even fires. + Thread releaser = new Thread(() -> { + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + } + release.countDown(); + }); + releaser.setDaemon(true); + releaser.start(); + + StreamSession outputSession = new StreamSession( + new ByteArrayInputStream(new byte[0]), "application/json", "UTF-8", false); + + String resultJson = NativeLib.selectTransformResult(feeder, t, inputHandle, outputSession); + + assertFalse(t.isAlive()); + assertTrue(resultJson.contains("\"success\":false"), + "selectTransformResult must observe the feeder's late failure, was: " + resultJson); + } } From d4302bd617bdcadd8cce9247b902ba2fe6a59f38 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 16:52:02 -0300 Subject: [PATCH 180/216] fix(python): serialize resolver install and engine init under a per-instance lifecycle lock (review #12 #2) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/runtime.py | 47 +++++--- native-lib/python/tests/unit/test_facade.py | 123 +++++++++++++++++++- 2 files changed, 155 insertions(+), 15 deletions(-) diff --git a/native-lib/python/src/dataweave/runtime.py b/native-lib/python/src/dataweave/runtime.py index 967230a1..377c60f1 100644 --- a/native-lib/python/src/dataweave/runtime.py +++ b/native-lib/python/src/dataweave/runtime.py @@ -39,25 +39,44 @@ def __init__( self._stream_workers = set() self._stream_workers_lock = Lock() self._cleaning_up = False + self._lifecycle_lock = Lock() def initialize(self): - if self._native.initialized: - return - if self._resolve_module is not None: - self._native.install_resolver(self._resolve_module) - self._native.initialize() + # Holds the lock across the whole install-resolver + native-init + # transition so two concurrent initialize() calls on this instance + # cannot both pass the `initialized` guard and both call + # install_resolver(), which would mint and register a second resolver + # token in the module-global registry and orphan it (review #12 #2). + # Always the outermost lock: NativeRuntime._init_lock and the module- + # global _resolver_lock_global are only ever taken INSIDE + # self._native.initialize()/cleanup(), nested within this one. + with self._lifecycle(): + if self._native.initialized: + return + if self._resolve_module is not None: + self._native.install_resolver(self._resolve_module) + self._native.initialize() def cleanup(self): - workers, lock = self._worker_registry() - with lock: - if workers: - raise DataWeaveError("Cannot clean up DataWeave runtime while an active streaming worker is attached.") - self._cleaning_up = True - try: - self._native.cleanup() - finally: + # Symmetric with initialize(): install_resolver() and + # NativeRuntime.cleanup() both mutate the module-global resolver + # registry, so cleanup() takes the same instance-level lock. + with self._lifecycle(): + workers, lock = self._worker_registry() with lock: - self._cleaning_up = False + if workers: + raise DataWeaveError("Cannot clean up DataWeave runtime while an active streaming worker is attached.") + self._cleaning_up = True + try: + self._native.cleanup() + finally: + with lock: + self._cleaning_up = False + + def _lifecycle(self): + if not hasattr(self, "_lifecycle_lock"): + self._lifecycle_lock = Lock() + return self._lifecycle_lock def _worker_registry(self): if not hasattr(self, "_stream_workers"): diff --git a/native-lib/python/tests/unit/test_facade.py b/native-lib/python/tests/unit/test_facade.py index e814c2ac..23ec8de6 100644 --- a/native-lib/python/tests/unit/test_facade.py +++ b/native-lib/python/tests/unit/test_facade.py @@ -1,3 +1,4 @@ +import ctypes import inspect import threading import time @@ -5,7 +6,7 @@ import pytest import dataweave -from dataweave import runtime +from dataweave import native, runtime class FakeNativeRuntime: @@ -254,3 +255,123 @@ def worker(index): assert results[0] is dataweave._global_instance finally: dataweave._global_instance = None + + +class _FakeCallable: + """A settable-attribute stand-in for a ctypes function pointer: plain + objects (unlike bound methods) accept `.argtypes`/`.restype` assignment, + which `native._bind_abi` performs on every ABI export it binds.""" + + def __init__(self, callback=None): + self._callback = callback + + def __call__(self, *args): + return self._callback(*args) if self._callback else 0 + + +class FakeLifecycleLibrary: + """Minimal ctypes-library stand-in that lets `NativeRuntime.initialize()`/ + `install_resolver()`/`cleanup()` run for real -- exercising the actual + module-global `_resolver_registry` / `_isolate_ref_count` bookkeeping in + `dataweave.native` -- without touching a real native library.""" + + def __init__(self): + self._next_handle = 1 + self.graal_create_isolate = _FakeCallable(lambda _params, _isolate, _thread: 0) + self.graal_attach_thread = _FakeCallable(self._attach_thread) + self.graal_detach_thread = _FakeCallable(lambda _thread: 0) + self.graal_tear_down_isolate = _FakeCallable(lambda _thread: 0) + self.free_cstring = _FakeCallable() + self.create_engine = _FakeCallable(self._create_engine) + self.create_engine_with_resolver = _FakeCallable(self._create_engine_with_resolver) + self.destroy_engine = _FakeCallable() + self.run_script_engine = _FakeCallable() + self.run_script_callback_engine = _FakeCallable() + self.run_script_input_output_callback_engine = _FakeCallable() + + @staticmethod + def _attach_thread(_isolate, thread_out): + ctypes.cast(thread_out, ctypes.POINTER(native.GraalIsolateThreadPointer))[0] = ( + native.GraalIsolateThreadPointer() + ) + return 0 + + def _create_engine(self, _thread): + handle = self._next_handle + self._next_handle += 1 + return handle + + def _create_engine_with_resolver(self, _thread, _callback, _ctx): + handle = self._next_handle + self._next_handle += 1 + return handle + + +@pytest.mark.unit +def test_concurrent_initialize_installs_exactly_one_resolver_token(monkeypatch): + # review #12 finding #2 (Medium): DataWeave.initialize() called + # self._native.install_resolver(...) then self._native.initialize() with no + # instance-level lock. Concurrent initialize() calls on the SAME instance + # can each pass the `if self._native.initialized: return` fast-path and + # each call install_resolver(), which allocates a fresh token and + # registers it in the module-global registry before any of them reaches + # NativeRuntime's own _init_lock-guarded engine creation. Only the last + # writer's token survives on `self._native._resolver_token`, so cleanup() + # (which only pops that one token) leaks every earlier registration. + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: FakeLifecycleLibrary()) + + before = set(native._resolver_registry.keys()) + dw = dataweave.DataWeave(lib_path="/tmp/dwlib", resolve_module=lambda _path: None) + + thread_count = 8 + + # A thread_count-party barrier with a timeout, patched into + # NativeRuntime.initialize (the real engine-creation call, invoked AFTER + # install_resolver() in DataWeave.initialize()). On the UNFIXED code every + # thread passes the `if self._native.initialized: return` fast-path + # concurrently (none of them has finished a full initialize() yet, so the + # flag is still False for all), so every thread reaches this point having + # ALREADY called install_resolver() -- reproducing thread_count + # independent install_resolver() calls, each minting and registering its + # own token, before any of them performs the real (locked) engine + # creation. On the FIXED (per-instance-locked) code only one thread is + # ever inside initialize() at a time, so it is the only caller that ever + # reaches this barrier; the other threads see `initialized` already True + # once they acquire the lock and never call install_resolver or this + # method at all. The lone caller's wait times out, the barrier breaks, + # and it proceeds normally -- this must NOT deadlock the fixed code. + native_initialize_barrier = threading.Barrier(thread_count) + orig_native_initialize = dw._native.initialize + + def synchronized_native_initialize(): + try: + native_initialize_barrier.wait(timeout=0.5) + except threading.BrokenBarrierError: + pass + return orig_native_initialize() + + monkeypatch.setattr(dw._native, "initialize", synchronized_native_initialize) + + barrier = threading.Barrier(thread_count) + errors = [] + + def go(): + barrier.wait() + try: + dw.initialize() + except Exception as exc: # noqa: BLE001 + errors.append(exc) + + threads = [threading.Thread(target=go) for _ in range(thread_count)] + for t in threads: + t.start() + for t in threads: + t.join() + + try: + assert not errors + new_tokens = set(native._resolver_registry.keys()) - before + assert len(new_tokens) == 1 # exactly one token, no orphan + assert native._isolate_ref_count == 1 # exactly one engine reference + finally: + dw.cleanup() From d93b89cf286e60f8902c403fbb74d09eaba554cf Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 17:14:51 -0300 Subject: [PATCH 181/216] fix(node): keep the owner-env cleanup hook for stranded resolver bridges so napi_ref is deleted on the owner thread (review #12 #3, #13) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 205 ++++++++++++++++-- .../integration/engine-strand-hook.test.ts | 147 +++++++++++++ native-lib/node/vitest.config.ts | 7 + 3 files changed, 338 insertions(+), 21 deletions(-) create mode 100644 native-lib/node/tests/integration/engine-strand-hook.test.ts diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 4ffbffa2..e47e6038 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -104,6 +104,14 @@ typedef struct engine_bridge { // otherwise a resolver-backed engine's ScriptRuntime is left registered with // a CallbackWeaveResourceResolver whose ctx points at the freed bridge (UAF). bool deferred_registry_remove; + // True while THIS bridge's napi_add_env_cleanup_hook(bridge_env_cleanup) is + // registered. The env cleanup hook is the only owner-thread finalizer that may + // delete resolver_js, so a strand taken on the owner thread (env alive) keeps + // the hook instead of enqueuing on g_stranded_bridges (whose off-thread drain + // skips napi_delete_reference and would leak the ref). Mutated only on the + // owner thread (creation, destroyEngine, bridge_env_cleanup) under the usual + // owner-thread-serialization contract. + bool hook_registered; struct engine_bridge* next; } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex @@ -121,6 +129,27 @@ static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex // (the Java registry died with the isolate). All access under g_mutex. static engine_bridge_t* g_stranded_bridges = NULL; // linked list, guarded by g_mutex +// --- Test-only fault injection & introspection (review #12 #3 / #13) --- +// +// These are INERT in production: the __test_* N-API functions are registered +// only when the process sets DATAWEAVE_TEST_HOOKS to a non-empty value (checked +// once in Init on the main JS thread, before any engine exists). g_test_hooks +// gates the two extra branches in the finalize path so a production build never +// takes an extra lock or check. g_test_force_strand_once starts false and can +// only be armed via __test_forceStrandOnce(). +// +// The Node strand regression test uses these to deterministically force a SINGLE +// live-isolate strand (an fn_attach_thread failure while the isolate is live) +// inside bridge_finalize_registry and observe the outcome: pre-fix the bridge is +// enqueued on g_stranded_bridges (resolver_js ref leaked / drained undeleted); +// post-fix it is kept by its owner-env cleanup hook and the ref is deleted on the +// owner thread at env teardown (g_test_resolver_ref_deletes counts those deletes). +// g_test_hooks is written once in Init before any reader runs; g_test_force_strand_once +// and g_test_resolver_ref_deletes are accessed only under g_mutex. +static bool g_test_hooks = false; +static bool g_test_force_strand_once = false; +static long long g_test_resolver_ref_deletes = 0; + // One record per napi_env that has ever taken an init reference (via // initialize()). init_refs is that env's net initialize()-minus-cleanup() // balance. Created lazily on the env's first initialize(); registers exactly @@ -341,6 +370,19 @@ static int env_init_refs_total_locked(void) { // the bridge (bridge_retain_stranded) and retry later (round-15, svacas P1). static bool bridge_finalize_registry(engine_bridge_t* b) { if (b == NULL || fn_destroy_engine == NULL) return true; + // Test-only: force ONE live-isolate strand (simulate fn_attach_thread failing + // while the isolate is live -> destroy SKIPPED). Inert unless a test both + // enabled the hooks (DATAWEAVE_TEST_HOOKS) and armed it via + // __test_forceStrandOnce(); one-shot, so exactly one finalize is diverted. + if (g_test_hooks) { + uv_mutex_lock(&g_mutex); + if (g_test_force_strand_once) { + g_test_force_strand_once = false; + uv_mutex_unlock(&g_mutex); + return false; // caller must retain/keep the bridge (ctx still live in Java) + } + uv_mutex_unlock(&g_mutex); + } uv_mutex_lock(&g_mutex); // If the waiter already committed to physical teardown (TEARING_DOWN) or the // isolate is already gone, the Java registry died/dies with it -- nothing to @@ -377,6 +419,11 @@ static bool bridge_finalize_registry(engine_bridge_t* b) { return destroyed; } +// Forward declaration: the env cleanup hook. bridge_finalize re-registers/keeps +// it on an owner-thread live-isolate strand (may_rehook) and removes it on the +// owner-thread free path; the definition is below (after drain_stranded_bridges). +static void bridge_env_cleanup(void* arg); + // The non-isolate finalize phase: delete the resolver napi_ref (owner JS thread // only, and only while its env is alive -- resolver-gated), free tracked result // buffers, free the record. Touches no GraalVM isolate state, so it is safe to @@ -385,6 +432,14 @@ static void bridge_finalize_free(engine_bridge_t* b, bool env_still_alive) { if (b == NULL) return; if (env_still_alive && b->resolver_js != NULL && b->env != NULL) { napi_delete_reference(b->env, b->resolver_js); + // Test-only: count owner-thread resolver-ref deletions so the strand + // regression test can prove the ref was finalized (not leaked / not + // drained undeleted). Inert unless DATAWEAVE_TEST_HOOKS is set. + if (g_test_hooks) { + uv_mutex_lock(&g_mutex); + g_test_resolver_ref_deletes++; + uv_mutex_unlock(&g_mutex); + } } resolver_results_free_all(b); free(b); @@ -400,12 +455,43 @@ static void bridge_finalize_free(engine_bridge_t* b, bool env_still_alive) { // and a later drain retries the destroy and frees it. When do_registry_remove is // false there is nothing registered (handle <= 0 construction failures), so the // free is unconditional as before. -static void bridge_finalize(engine_bridge_t* b, bool env_still_alive, bool do_registry_remove) { +// `may_rehook` is true only when the caller is on the bridge's OWNER thread with +// the env alive and continuing (destroyEngine's immediate path, bridge_end_op on +// the owner env). On a live-isolate strand there, ownership of resolver_js's +// deletion stays with the env cleanup hook: keep (or re-register) the hook and +// return WITHOUT enqueuing on g_stranded_bridges, so the OWNER thread deletes the +// ref and frees the record at env teardown -- never the off-thread drain (which +// skips napi_delete_reference and would leak the ref). When may_rehook is false +// (env tearing down, or a creation abort) there is no live owner hook to keep, so +// a strand falls back to bridge_retain_stranded and the drain frees it later. +static void bridge_finalize(engine_bridge_t* b, bool env_still_alive, + bool do_registry_remove, bool may_rehook) { if (b == NULL) return; if (do_registry_remove && !bridge_finalize_registry(b)) { - bridge_retain_stranded(b); // keep ctx valid; retry destroy + free later + // Strand: isolate live, attach failed, registry entry NOT removed. + if (may_rehook && env_still_alive && b->env != NULL) { + // On the owner thread with the env alive & continuing. Give the bridge + // to its env cleanup hook (still registered here, since the strand + // paths no longer pre-remove it) so the OWNER thread deletes + // resolver_js and frees at env teardown -- never the off-thread drain. + if (!b->hook_registered + && napi_add_env_cleanup_hook(b->env, bridge_env_cleanup, b) == napi_ok) { + b->hook_registered = true; + } + if (b->hook_registered) { + return; // single owner = the hook; NOT on g_stranded_bridges + } + // hook unavailable: fall through to drain (best effort). + } + bridge_retain_stranded(b); // env dead / hook gone: drain frees (ref auto-reclaimed or none) return; } + // Free path: remove the hook first (owner thread only) so Node never invokes + // it on freed memory, then delete the ref (env alive) + free. + if (env_still_alive && b->hook_registered && b->env != NULL) { + napi_remove_env_cleanup_hook(b->env, bridge_env_cleanup, b); + b->hook_registered = false; + } bridge_finalize_free(b, env_still_alive); } @@ -455,6 +541,11 @@ static void bridge_env_cleanup(void* arg) { engine_bridge_t* b = (engine_bridge_t*)arg; if (b == NULL) return; + // Node auto-removes this hook as it fires it, so it is no longer registered. + // Clear the flag first so bridge_finalize (may_rehook=false below, but also + // the deferred bridge_end_op path) never tries to remove an already-gone hook. + b->hook_registered = false; + uv_mutex_lock(&g_mutex); // Unlink from g_bridges if still present (destroyEngine may have already // unlinked it while deferring a free — see below). @@ -500,7 +591,10 @@ static void bridge_env_cleanup(void* arg) { // guards on g_isolate for the main-env-after-isolate-teardown corner). Not // removing it would leave a CallbackWeaveResourceResolver whose ctx is the // freed bridge -> UAF on a later invocation of this handle. - bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/true); + // may_rehook=false: the env is tearing down, so do NOT re-register the hook on + // a strand -- a strand here falls back to g_stranded_bridges (Node reclaims the + // ref at env teardown; the off-thread drain frees the record later). + bridge_finalize(b, /*env_still_alive=*/true, /*do_registry_remove=*/true, /*may_rehook=*/false); } // Increment this engine's in_flight while g_mutex is ALREADY held. Used by the @@ -549,7 +643,12 @@ static void bridge_end_op(engine_bridge_t* b, bool env_still_alive) { // cleanup hook (round-10 #1) deferred the registry removal while this op was // in flight; the draining op performs it exactly once here. bridge_finalize // guards the call on g_isolate, so a teardown that raced ahead is a no-op. - if (finalize) bridge_finalize(b, env_still_alive, /*do_registry_remove=*/remove_registry); + // env_still_alive here means we are draining on the owner thread with the env + // alive, so a live-isolate strand may keep the env cleanup hook (may_rehook). + // When env_still_alive is false (env == NULL sentinel path) a strand falls back + // to the drain, which is correct: the owner env is gone. + if (finalize) bridge_finalize(b, env_still_alive, /*do_registry_remove=*/remove_registry, + /*may_rehook=*/env_still_alive); } // --- Initialization --- @@ -2082,7 +2181,9 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // destroyEngine removes this hook before an early free so Node never invokes // it on freed memory. napi_status hook_st = napi_add_env_cleanup_hook(env, bridge_env_cleanup, rec); - if (hook_st != napi_ok) { + if (hook_st == napi_ok) { + rec->hook_registered = true; + } else { // Creation must be all-or-nothing (round-12 #6): without a cleanup hook a // Worker that abandons this engine would strand the record and the Java // registry entry. Unlink, remove the registry entry, free, and throw -- @@ -2105,7 +2206,9 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // round-15 (svacas P1): go through bridge_finalize (do_registry_remove=true) // so a destroy skipped on a transient attach failure retains the record for // retry instead of freeing it while the Java registry still references it. - bridge_finalize(rec, /*env_still_alive=*/true, /*do_registry_remove=*/true); + // may_rehook=false: this hook never registered (hook_registered stayed + // false), and creation is aborting all-or-nothing -- do not (re-)hook. + bridge_finalize(rec, /*env_still_alive=*/true, /*do_registry_remove=*/true, /*may_rehook=*/false); uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to register engine cleanup hook"); return NULL; @@ -2167,8 +2270,10 @@ 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) { uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); - // Synchronous call on the JS thread -- env is live here. - bridge_finalize(bridge, /*env_still_alive=*/true, /*do_registry_remove=*/false); + // Synchronous call on the JS thread -- env is live here. may_rehook=false: + // no hook was ever registered for this bridge and creation is aborting; with + // do_registry_remove=false there is nothing registered to strand on anyway. + bridge_finalize(bridge, /*env_still_alive=*/true, /*do_registry_remove=*/false, /*may_rehook=*/false); napi_throw_error(env, NULL, "create_engine_with_resolver returned an invalid handle"); return NULL; } @@ -2180,7 +2285,9 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i // no longer touches bridge refs. destroyEngine removes this hook before an // early free so Node never calls it on freed memory. napi_status hook_st = napi_add_env_cleanup_hook(env, bridge_env_cleanup, bridge); - if (hook_st != napi_ok) { + if (hook_st == napi_ok) { + bridge->hook_registered = true; + } else { // Creation must be all-or-nothing (round-12 #6): without a cleanup hook a // Worker that abandons this engine would strand the record and the Java // registry entry. Unlink, remove the registry entry, free, and throw -- @@ -2205,7 +2312,9 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i // round-15 (svacas P1): go through bridge_finalize (do_registry_remove=true) // so a destroy skipped on a transient attach failure retains the bridge for // retry instead of freeing it while the Java registry still references it. - bridge_finalize(bridge, /*env_still_alive=*/true, /*do_registry_remove=*/true); + // may_rehook=false: this hook never registered (hook_registered stayed + // false), and creation is aborting all-or-nothing -- do not (re-)hook. + bridge_finalize(bridge, /*env_still_alive=*/true, /*do_registry_remove=*/true, /*may_rehook=*/false); uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to register engine cleanup hook"); return NULL; @@ -2276,22 +2385,28 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { uv_mutex_unlock(&g_mutex); if (found != NULL) { - // Drop the env cleanup hook. Round-11 (#1): every engine now registers - // one at creation (napi_create_engine / napi_create_engine_with_resolver), - // so this removal must run unconditionally, not just for resolver-backed - // engines. 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. - napi_remove_env_cleanup_hook(env, bridge_env_cleanup, found); + // Do NOT pre-remove the env cleanup hook here (review #12 #3, #13). The two + // finalize paths now own the hook themselves: bridge_finalize's FREE path + // removes it (owner thread, before the free) so Node never invokes it on + // freed memory, while its live-isolate STRAND path KEEPS the hook so the + // owner env deletes resolver_js at teardown instead of the off-thread drain + // (which skips napi_delete_reference and would leak the ref). Removing it + // unconditionally here would strand the bridge with the ref undeleted AND no + // hook to delete it -> a guaranteed resolver-ref leak on the strand path. 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); + // once regardless of path. may_rehook=true: we are on the owner thread + // with the env alive, so a live-isolate strand keeps the hook (owner + // env finalizes resolver_js later) rather than enqueuing on the drain. + bridge_finalize(found, /*env_still_alive=*/true, /*do_registry_remove=*/true, /*may_rehook=*/true); } - // else: the draining op's bridge_end_op -> bridge_finalize performs both - // the registry removal and the free (see Step 5). + // else: in flight -> leave the hook in place (do NOT remove it). The + // draining op's bridge_end_op -> bridge_finalize performs both the registry + // removal and the free (or keeps the hook on its own strand) on the owner + // thread. Should the owner env instead tear down first while still in + // flight, bridge_env_cleanup fires and takes over the deferred finalize. } 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 @@ -3046,6 +3161,39 @@ static void init_g_mutex(void) { uv_cond_init(&g_teardown_cond); } +// --- Test-only N-API entrypoints (review #12 #3 / #13) --- +// Registered only when DATAWEAVE_TEST_HOOKS is set (see Init). They let the Node +// strand regression test arm a single forced live-isolate strand and inspect the +// resulting bookkeeping. None of these touch thread-affine napi state beyond +// creating a plain return value on the calling env, so they are callable from any +// JS thread (main or Worker) that loaded this addon. +static napi_value napi_test_force_strand_once(napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + g_test_force_strand_once = true; + uv_mutex_unlock(&g_mutex); + return NULL; +} + +static napi_value napi_test_stranded_count(napi_env env, napi_callback_info info) { + (void)info; + long long n = 0; + uv_mutex_lock(&g_mutex); + for (engine_bridge_t* b = g_stranded_bridges; b != NULL; b = b->next) n++; + uv_mutex_unlock(&g_mutex); + napi_value out; napi_create_int64(env, (int64_t)n, &out); + return out; +} + +static napi_value napi_test_resolver_ref_delete_count(napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + long long n = g_test_resolver_ref_deletes; + uv_mutex_unlock(&g_mutex); + napi_value out; napi_create_int64(env, (int64_t)n, &out); + return out; +} + static napi_value Init(napi_env env, napi_value exports) { uv_once(&g_mutex_once, init_g_mutex); @@ -3075,6 +3223,21 @@ static napi_value Init(napi_env env, napi_value exports) { napi_create_function(env, "cleanup", NAPI_AUTO_LENGTH, napi_cleanup, NULL, &fn); napi_set_named_property(env, exports, "cleanup", fn); + // Test-only entrypoints, registered only when the process opts in via + // DATAWEAVE_TEST_HOOKS (non-empty). getenv() is safe here: Init runs once per + // env on the main JS thread at module load, before any engine/finalize can run, + // so this write-once flag is visible to every later reader without a barrier. + const char* test_hooks = getenv("DATAWEAVE_TEST_HOOKS"); + if (test_hooks != NULL && test_hooks[0] != '\0') { + g_test_hooks = true; + napi_create_function(env, "__test_forceStrandOnce", NAPI_AUTO_LENGTH, napi_test_force_strand_once, NULL, &fn); + napi_set_named_property(env, exports, "__test_forceStrandOnce", fn); + napi_create_function(env, "__test_strandedCount", NAPI_AUTO_LENGTH, napi_test_stranded_count, NULL, &fn); + napi_set_named_property(env, exports, "__test_strandedCount", fn); + napi_create_function(env, "__test_resolverRefDeleteCount", NAPI_AUTO_LENGTH, napi_test_resolver_ref_delete_count, NULL, &fn); + napi_set_named_property(env, exports, "__test_resolverRefDeleteCount", fn); + } + return exports; } diff --git a/native-lib/node/tests/integration/engine-strand-hook.test.ts b/native-lib/node/tests/integration/engine-strand-hook.test.ts new file mode 100644 index 00000000..0c871bec --- /dev/null +++ b/native-lib/node/tests/integration/engine-strand-hook.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { Worker } from "node:worker_threads"; +import { join } from "node:path"; +import { findLibrary } from "../../src/utils"; + +// W-23692110 PR #157 reviews #12 #3 / #13. +// +// napi_destroy_engine used to remove the env cleanup hook and THEN finalize. If +// the finalize took a live-isolate strand (fn_attach_thread failed while the +// isolate was still alive) the bridge was enqueued on g_stranded_bridges WITHOUT +// deleting resolver_js. drain_stranded_bridges() later runs off the owner thread +// and frees with env_still_alive=false -- SKIPPING napi_delete_reference -- so the +// resolver JS function leaked while the owner env was still alive. +// +// The fix keeps the napi_ref deletion owned by the owner-thread env cleanup hook +// whenever a strand happens on the owner thread with the env alive: the bridge is +// kept by its (still-registered) cleanup hook instead of being enqueued on +// g_stranded_bridges, so the ref is deleted on the owner thread at env teardown. +// +// These tests use the addon's test-only entrypoints (registered only when +// DATAWEAVE_TEST_HOOKS is set -- the integration lane sets it, see +// vitest.config.ts) to deterministically force a SINGLE live-isolate strand: +// - __test_forceStrandOnce(): arm one forced strand in the next +// bridge_finalize_registry (simulates the +// fn_attach_thread failure). +// - __test_strandedCount(): length of g_stranded_bridges. +// - __test_resolverRefDeleteCount(): process-wide count of owner-thread +// napi_delete_reference(resolver_js) calls. + +const ADDON_PATH = join(__dirname, "..", "..", "build", "Release", "dwlib_addon.node"); +const LIB_PATH = findLibrary(); + +interface TestAddon { + initialize(libPath: string): void; + createEngineWithResolver(resolver: (p: string) => string | null): number; + destroyEngine(handle: number): void; + cleanup(): Promise; + __test_forceStrandOnce(): void; + __test_strandedCount(): number; + __test_resolverRefDeleteCount(): number; +} + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const addon = require(ADDON_PATH) as TestAddon; + +describe("owner-env-hook-retained finalization for stranded resolver bridges (review #12 #3, #13)", () => { + afterAll(async () => { + // Balance any main-thread init reference this file took so it does not + // perturb sibling integration files sharing the vitest worker process. + await addon.cleanup(); + }); + + it("exposes the test-only strand hooks (integration lane sets DATAWEAVE_TEST_HOOKS)", () => { + // Guards against a silently-inert test: if the hooks were not registered the + // strand assertions below would all trivially pass with 0-deltas. + expect(typeof addon.__test_forceStrandOnce).toBe("function"); + expect(typeof addon.__test_strandedCount).toBe("function"); + expect(typeof addon.__test_resolverRefDeleteCount).toBe("function"); + }); + + it( + "a live-isolate strand during destroyEngine keeps the resolver bridge owned by its env cleanup hook, NOT on g_stranded_bridges", + async () => { + addon.initialize(LIB_PATH); + try { + const strandedBefore = addon.__test_strandedCount(); + + const handle = addon.createEngineWithResolver((_p) => null); + // Arm exactly one forced strand: the next bridge_finalize_registry (the + // one inside this destroyEngine) reports the destroy as skipped with the + // isolate still live -- the exact fault the finding is about. + addon.__test_forceStrandOnce(); + expect(() => addon.destroyEngine(handle)).not.toThrow(); + + const strandedAfter = addon.__test_strandedCount(); + // POST-FIX: the strand is kept by the owner-env cleanup hook, so the + // bridge is NOT enqueued on g_stranded_bridges (delta 0). PRE-FIX: the + // bridge was stranded (delta 1) with resolver_js left undeleted, and the + // hook had already been removed -> the ref would leak. + expect(strandedAfter - strandedBefore).toBe(0); + } finally { + // Release this test's init reference. The kept-hook bridge is finalized + // (ref deleted, record freed) by the main env's cleanup hook at process + // teardown; the isolate teardown here makes that a safe no-op registry + // removal. + await addon.cleanup(); + } + } + ); + + it( + "a strand in a Worker that then exits deletes the resolver ref on the owner thread (not leaked, not drained undeleted)", + async () => { + // Main thread holds an init reference so the shared isolate stays live + // while the Worker's env tears down -- the Worker's env cleanup hook needs a + // live isolate to remove the Java registry entry, and (post-fix) to delete + // resolver_js on the Worker's owner thread. + addon.initialize(LIB_PATH); + try { + const deletesBefore = addon.__test_resolverRefDeleteCount(); + + const body = ` + const { parentPort, workerData } = require('node:worker_threads'); + const addon = require(workerData.addonPath); + addon.initialize(workerData.libPath); // this env's own init reference + const handle = addon.createEngineWithResolver((p) => null); + addon.__test_forceStrandOnce(); + addon.destroyEngine(handle); + // Report the stranded-list delta observed on the Worker thread right + // after the forced strand, then return WITHOUT cleanup() so the env + // cleanup hook fires as this Worker env tears down (post-fix: deletes + // resolver_js on THIS owner thread). + parentPort.postMessage({ strandedCount: addon.__test_strandedCount() }); + `; + const workerMsg = await new Promise<{ strandedCount: number }>((resolve, reject) => { + const w = new Worker(body, { + eval: true, + workerData: { addonPath: ADDON_PATH, libPath: LIB_PATH }, + }); + let msg: { strandedCount: number } | undefined; + w.once("message", (m) => { msg = m; }); + w.once("error", reject); + // Wait for EXIT (not just the message) so the Worker env's cleanup hooks + // have run before we read the process-wide delete counter. + w.once("exit", (code) => { + if (code !== 0) reject(new Error("Worker exited with code " + code + (msg ? "" : " and posted no message"))); + else if (msg === undefined) reject(new Error("Worker exited 0 without posting a result")); + else resolve(msg); + }); + }); + + // POST-FIX: the Worker's strand was kept by its cleanup hook (not stranded). + expect(workerMsg.strandedCount).toBe(0); + + const deletesAfter = addon.__test_resolverRefDeleteCount(); + // POST-FIX: the Worker's env cleanup hook deleted resolver_js on the + // Worker's owner thread at env teardown -> exactly one net delete. + // PRE-FIX: destroyEngine removed the hook and stranded the bridge, so the + // off-thread drain freed it with env_still_alive=false -> 0 deletes (leak). + expect(deletesAfter - deletesBefore).toBe(1); + } finally { + await addon.cleanup(); + } + }, + 20000 + ); +}); diff --git a/native-lib/node/vitest.config.ts b/native-lib/node/vitest.config.ts index 2a7b558d..9d0b7fc2 100644 --- a/native-lib/node/vitest.config.ts +++ b/native-lib/node/vitest.config.ts @@ -24,6 +24,13 @@ export default defineConfig({ name: "integration", include: ["tests/integration/**/*.test.ts"], testTimeout: 30000, + // Opt the integration lane into the addon's test-only entrypoints + // (__test_forceStrandOnce / __test_strandedCount / + // __test_resolverRefDeleteCount). Set before any integration worker + // loads the addon, so its Init() getenv() sees it and registers them; + // inert in every other lane and in production. Workers spawned by a + // test inherit this env, so the addon Init() in a worker sees it too. + env: { DATAWEAVE_TEST_HOOKS: "1" }, }, }, { From f863620c7d09974fa163cb99d0bb223d3ff01205 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 17:30:17 -0300 Subject: [PATCH 182/216] fix(node): remove the env cleanup hook on the destroyEngine defer path to close the double-owner window (reviews #12 #3, #13 round 1) Leaving the hook registered on the in-flight defer path reopened a double-owner window: destroy_pending only keeps bridge_env_cleanup and bridge_end_op mutually exclusive for abandoned engines (bridge_env_cleanup's in_flight==0 branch finalizes without checking destroy_pending). If the env tore down mid-flight (e.g. worker.terminate()), bridge_end_op(env dead) would free the bridge on its free path (which skips the env-alive-gated hook remove) and the later env-cleanup-hook fire would run bridge_env_cleanup on freed memory -> UAF / double-free / double fn_destroy_engine. Restore the pre-fix behavior for the defer branch only: remove the hook now (owner thread, env alive) and clear hook_registered, making bridge_end_op the sole finalizer. bridge_end_op re-registers the hook (may_rehook=env_still_alive) only if its later finalize strands on the owner thread with the env alive, so the resolver_js leak fix is preserved. Non-defer path unchanged. Adds a deterministic main-thread defer-path regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 37 +++++++++----- .../integration/engine-strand-hook.test.ts | 49 +++++++++++++++++++ 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index e47e6038..12e950a3 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2385,14 +2385,6 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { uv_mutex_unlock(&g_mutex); if (found != NULL) { - // Do NOT pre-remove the env cleanup hook here (review #12 #3, #13). The two - // finalize paths now own the hook themselves: bridge_finalize's FREE path - // removes it (owner thread, before the free) so Node never invokes it on - // freed memory, while its live-isolate STRAND path KEEPS the hook so the - // owner env deletes resolver_js at teardown instead of the off-thread drain - // (which skips napi_delete_reference and would leak the ref). Removing it - // unconditionally here would strand the bridge with the ref undeleted AND no - // hook to delete it -> a guaranteed resolver-ref leak on the strand path. if (!defer) { // Not in flight: remove the registry entry AND finalize now, on this // owner thread (env live). do_registry_remove=true folds the @@ -2400,13 +2392,32 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { // once regardless of path. may_rehook=true: we are on the owner thread // with the env alive, so a live-isolate strand keeps the hook (owner // env finalizes resolver_js later) rather than enqueuing on the drain. + // Do NOT pre-remove the hook here (review #12 #3, #13): bridge_finalize + // owns it -- its FREE path removes it before freeing (so Node never + // invokes it on freed memory), and its STRAND path KEEPS it so the owner + // env deletes resolver_js at teardown instead of the off-thread drain + // (which skips napi_delete_reference and would leak the ref). bridge_finalize(found, /*env_still_alive=*/true, /*do_registry_remove=*/true, /*may_rehook=*/true); + } else { + // In flight -> DEFER the finalize to the draining op's bridge_end_op. + // Remove the env cleanup hook NOW (round-1 fix to reviews #12 #3/#13): + // this is legal here (owner thread, env alive) and makes bridge_end_op + // the SOLE finalizer after this destroy. Leaving the hook registered + // reopens a double-owner window: destroy_pending only keeps + // bridge_env_cleanup and bridge_end_op mutually exclusive for ABANDONED + // (never-destroyed) engines, because bridge_env_cleanup's in_flight==0 + // branch finalizes WITHOUT checking destroy_pending. So if the env is + // torn down while this op is still in flight (e.g. worker.terminate()), + // the op's bridge_end_op(env_still_alive=false) frees the bridge on its + // FREE path (which skips the hook-remove -- gated on env_still_alive), + // and the later env-cleanup-hook fire would run bridge_env_cleanup on + // freed memory -> UAF / double-free / double fn_destroy_engine. + // bridge_end_op re-registers the hook (may_rehook=env_still_alive) only + // if its later finalize STRANDS on the owner thread with the env alive, + // keeping resolver_js deletion on the owner thread -> the leak fix holds. + napi_remove_env_cleanup_hook(env, bridge_env_cleanup, found); + found->hook_registered = false; } - // else: in flight -> leave the hook in place (do NOT remove it). The - // draining op's bridge_end_op -> bridge_finalize performs both the registry - // removal and the free (or keeps the hook on its own strand) on the owner - // thread. Should the owner env instead tear down first while still in - // flight, bridge_env_cleanup fires and takes over the deferred finalize. } 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 diff --git a/native-lib/node/tests/integration/engine-strand-hook.test.ts b/native-lib/node/tests/integration/engine-strand-hook.test.ts index 0c871bec..c1449761 100644 --- a/native-lib/node/tests/integration/engine-strand-hook.test.ts +++ b/native-lib/node/tests/integration/engine-strand-hook.test.ts @@ -34,6 +34,12 @@ interface TestAddon { initialize(libPath: string): void; createEngineWithResolver(resolver: (p: string) => string | null): number; destroyEngine(handle: number): void; + runScriptStreamingEngine( + handle: number, + script: string, + inputsJson: string, + chunkCb: (chunk: Buffer) => void + ): Promise; cleanup(): Promise; __test_forceStrandOnce(): void; __test_strandedCount(): number; @@ -144,4 +150,47 @@ describe("owner-env-hook-retained finalization for stranded resolver bridges (re }, 20000 ); + + it( + "a deferred destroy (destroyEngine while an op is in flight) finalizes exactly once on the owner thread, hook removed up-front (round-1 double-owner guard)", + async () => { + // Regression guard for the defer-path double-owner window (round-1 fix): + // destroyEngine on a resolver-backed engine WHILE a streaming op is in + // flight takes the defer path -- it must remove the env cleanup hook NOW + // (owner thread, env alive) so the draining bridge_end_op is the SOLE + // finalizer. Deterministic: the round-11 pin is taken atomically at + // admission, so firing destroyEngine synchronously after starting the op + // lands AFTER in_flight==1 (see engine-handle-contract.test.ts). After the + // op drains, bridge_end_op finalizes on the owner thread with the env alive: + // resolver_js is deleted EXACTLY once (delta 1 -- not 0=leak, not 2=double + // finalize) and the bridge is never stranded. + addon.initialize(LIB_PATH); + try { + const deletesBefore = addon.__test_resolverRefDeleteCount(); + const strandedBefore = addon.__test_strandedCount(); + + const handle = addon.createEngineWithResolver((_p) => null); + const chunks: Buffer[] = []; + const resultPromise = addon.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1, 2, 3]", + "{}", + (chunk) => chunks.push(chunk) + ); + // Fire destroy synchronously after admission -> defer path (in_flight==1). + expect(() => addon.destroyEngine(handle)).not.toThrow(); + const raw = await resultPromise; + // Pin held at admission -> the op completes successfully. + expect(JSON.parse(raw).success).toBe(true); + + const deletesAfter = addon.__test_resolverRefDeleteCount(); + const strandedAfter = addon.__test_strandedCount(); + expect(strandedAfter - strandedBefore).toBe(0); + expect(deletesAfter - deletesBefore).toBe(1); + } finally { + await addon.cleanup(); + } + }, + 20000 + ); }); From 711b2f3a5fafc6ae9d1e421cb89ccb1ef685ae06 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 17:53:12 -0300 Subject: [PATCH 183/216] test(node): replace crashing terminate-mid-flight regression with honest robustness smoke; document env-dead defer path is unreachable from JS (reviews #12 #3, #13 round 2) The round-1 main-thread "double-owner guard" ran with env_still_alive=TRUE, where bridge_finalize's FREE path removes the hook regardless of the defer-branch fix -- so it passed with AND without b06b917 and could not guard the fix. Relabeled it honestly as a SMOKE test. The env_still_alive=FALSE window (the one the fix closes) is not reachable from JS in this harness. Both approaches were exercised empirically: - Path 1 (background compute thread alive at teardown -> napi_closing branch): worker.terminate() mid-streaming-op orphans a GraalVM-attached thread that aborts the process (SIGABRT) on completion, independent of the hook. - Path 2 (Node drains the queued sentinel with env==NULL): terminate() DROPS the queued threadsafe-function callback, so bridge_end_op(false) never runs, in_flight stays pinned, and bridge_env_cleanup defers (in_flight>0 branch) -- no free, no UAF, with or without the fix (verified: 5/5 clean each direction). So no deterministic worker-terminate regression test is achievable; the fix's correctness rests on the four-invariant code review (confirmed in round 1). Removed the crashing Runtime::wait-based REGRESSION test (aborted the suite regardless of the fix) and added a non-crashing ROBUSTNESS smoke test asserting the shared isolate/native registry survives defer+terminate. Kept the effective leak-fix guards (main-thread strand delta 0; worker strand ref-delete delta 1). Integration lane: 15 files / 99 tests pass; strand-hook 5/5 stable over 3 runs. addon.c logic unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integration/engine-strand-hook.test.ts | 106 ++++++++++++++++-- 1 file changed, 95 insertions(+), 11 deletions(-) diff --git a/native-lib/node/tests/integration/engine-strand-hook.test.ts b/native-lib/node/tests/integration/engine-strand-hook.test.ts index c1449761..6219973b 100644 --- a/native-lib/node/tests/integration/engine-strand-hook.test.ts +++ b/native-lib/node/tests/integration/engine-strand-hook.test.ts @@ -152,18 +152,20 @@ describe("owner-env-hook-retained finalization for stranded resolver bridges (re ); it( - "a deferred destroy (destroyEngine while an op is in flight) finalizes exactly once on the owner thread, hook removed up-front (round-1 double-owner guard)", + "SMOKE (env alive): a deferred destroy with the op completing normally finalizes exactly once on the owner thread", async () => { - // Regression guard for the defer-path double-owner window (round-1 fix): - // destroyEngine on a resolver-backed engine WHILE a streaming op is in - // flight takes the defer path -- it must remove the env cleanup hook NOW - // (owner thread, env alive) so the draining bridge_end_op is the SOLE - // finalizer. Deterministic: the round-11 pin is taken atomically at - // admission, so firing destroyEngine synchronously after starting the op - // lands AFTER in_flight==1 (see engine-handle-contract.test.ts). After the - // op drains, bridge_end_op finalizes on the owner thread with the env alive: - // resolver_js is deleted EXACTLY once (delta 1 -- not 0=leak, not 2=double - // finalize) and the bridge is never stranded. + // HONEST SCOPE (round-2): this is a happy-path SMOKE test, NOT a regression + // guard for the defer-branch double-owner fix. It runs on the main thread and + // lets the op complete with the env alive, so bridge_end_op runs with + // env_still_alive=TRUE and bridge_finalize's FREE path removes the hook itself + // regardless of whether destroyEngine's defer branch removed it -- so it + // passes with OR without the b06b917 defer-branch hook-removal and cannot + // catch that regression. The double-owner UAF only manifests when + // env_still_alive=FALSE (env torn down mid-flight); that path is exercised by + // the Worker test below. What this does verify: the defer path (in_flight>0 at + // destroy time; round-11 pin taken atomically at admission) drains cleanly, + // finalizes exactly once (resolver_js deleted delta 1 -- not 0=leak, not + // 2=double finalize), and never strands. addon.initialize(LIB_PATH); try { const deletesBefore = addon.__test_resolverRefDeleteCount(); @@ -193,4 +195,86 @@ describe("owner-env-hook-retained finalization for stranded resolver bridges (re }, 20000 ); + + it( + "ROBUSTNESS (defer then Worker terminate mid-flight): the shared isolate/native state survives; state stays consistent", + async () => { + // HONEST SCOPE (round-2): this exercises the defer-then-terminate lifecycle + // safely, but it is NOT a regression guard for the b06b917 defer-branch + // hook-removal fix -- it passes WITH and WITHOUT that fix (verified: see the + // report round-2 section for the empirical revert-check). It cannot open the + // double-owner window because that window requires bridge_end_op to run with + // env_still_alive=FALSE and take its FREE path (which skips the env-gated hook + // removal) so a still-registered hook then fires on the freed bridge. Reaching + // that free needs EITHER: + // (1) the background compute thread still running at teardown so its sentinel + // enqueue returns napi_closing and it runs bridge_end_op(false) itself -- + // but that is an orphaned GraalVM-attached thread which aborts the process + // (SIGABRT) on completion, independent of the hook (see report); OR + // (2) Node draining the queued completion sentinel with env==NULL on the JS + // thread at teardown -- but worker.terminate() DROPS the queued + // threadsafe-function callback rather than draining it, so bridge_end_op + // never runs, in_flight stays pinned, and bridge_env_cleanup hits its + // in_flight>0 branch (addon.c ~L562) which DEFERS instead of freeing -- + // no free, no UAF, with or without the fix. + // So the env_still_alive=FALSE FREE-then-hook-fire window is not reachable from + // JS here without the orthogonal orphaned-thread abort. This test therefore only + // asserts that the defer+terminate path leaves the shared isolate and native + // registry intact (a real UAF / double fn_destroy_engine that did not crash + // outright would corrupt them). The double-owner fix's correctness rests on the + // code review of the four invariants (see report), not on this test. + // + // The op uses a trivial fast script so the background thread finishes and + // DETACHES from the isolate well before terminate() -- avoiding the orphaned- + // thread abort of variant (1) above -- and the Worker blocks its JS event loop + // so the completion sentinel stays queued (never processed while alive). + const ITERATIONS = 10; + for (let i = 0; i < ITERATIONS; i++) { + const body = ` + const { parentPort, workerData } = require('node:worker_threads'); + const addon = require(workerData.addonPath); + addon.initialize(workerData.libPath); + const handle = addon.createEngineWithResolver((p) => null); + // Trivial op: the background compute thread finishes and detaches fast, + // then enqueues the completion sentinel (queued, not yet processed). + addon + .runScriptStreamingEngine(handle, "%dw 2.0\\noutput application/json\\n---\\n[1,2,3]", '{}', (c) => {}) + .then(() => {}, () => {}); + // destroyEngine synchronously after admission -> in_flight==1 -> DEFER. + addon.destroyEngine(handle); + parentPort.postMessage('deferred'); + // Block the JS event loop so the queued sentinel is NOT processed while + // the env is alive; the parent terminates us during this window. + const end = Date.now() + 500; while (Date.now() < end) {} + `; + const w = new Worker(body, { + eval: true, + workerData: { addonPath: ADDON_PATH, libPath: LIB_PATH }, + }); + const workerError = new Promise((_, reject) => w.once("error", reject)); + workerError.catch(() => {}); // avoid unhandled rejection if it fires post-settle + await Promise.race([ + new Promise((resolve) => w.once("message", (m) => { if (m === "deferred") resolve(); })), + workerError, + new Promise((_, reject) => setTimeout(() => reject(new Error("worker did not signal deferred in time")), 10000)), + ]); + // Small settle so the background thread has finished and DETACHED (sentinel + // queued) before we terminate -- terminate then lands after the isolate is + // no longer attached on the worker's compute thread (no orphaned thread). + await new Promise((r) => setTimeout(r, 100)); + const exitCode = await w.terminate(); + expect(typeof exitCode).toBe("number"); + } + + // Prove the shared isolate/native state survived every terminate: the main + // thread must still initialize + create + destroy an engine cleanly (a UAF or + // double fn_destroy_engine that did not crash outright would corrupt the + // registry/isolate and break this). + addon.initialize(LIB_PATH); + const h = addon.createEngineWithResolver((_p) => null); + expect(() => addon.destroyEngine(h)).not.toThrow(); + await addon.cleanup(); + }, + 60000 + ); }); From 13866f735048c02f60671e8f6fb86a02bac88663 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 18:37:25 -0300 Subject: [PATCH 184/216] fix(python): retain the bootstrap thread on double-failure so teardown retry can succeed (review #13) When both the bootstrap-thread detach and the immediate teardown fail in _acquire_isolate, the isolate was NOT destroyed and the bootstrap thread is still attached. GraalVM teardown can never succeed while that thread stays attached, so the retry must reuse the retained bootstrap thread rather than attach a fresh worker (which could never tear the isolate down). Reset the new _pending_teardown_thread global between unit tests alongside _teardown_needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/native.py | 59 ++++++++++++++------- native-lib/python/tests/unit/conftest.py | 1 + native-lib/python/tests/unit/test_native.py | 52 ++++++++++++++++++ 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index aa09615e..8ebc4298 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -41,6 +41,13 @@ class graal_isolatethread_t(ctypes.Structure): # Node's g_teardown_needed retryable-teardown model. Only read/written while # holding _isolate_lock. _teardown_needed = False +# When a bootstrap-thread detach AND the immediate teardown BOTH fail in +# _acquire_isolate, the isolate was NOT destroyed and the bootstrap thread is +# still attached. GraalVM teardown cannot succeed while it stays attached, so the +# retry must reuse THIS thread rather than attach a fresh worker (which could +# never tear down). None except across such a double-failure window. Guarded by +# _isolate_lock. +_pending_teardown_thread = None # Per-engine resolver dispatch. The ctx passed to create_engine_with_resolver is @@ -114,34 +121,47 @@ def _retry_pending_teardown_locked() -> None: build fresh. On failure, leaves the isolate live and the flag armed, and propagates the failure so the caller does not proceed to build a second, racing isolate.""" - global _lib, _lib_path, _isolate, _teardown_needed + global _lib, _lib_path, _isolate, _teardown_needed, _pending_teardown_thread if not _teardown_needed: return lib, isolate = _lib, _isolate - worker = GraalIsolateThreadPointer() - if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: - raise DataWeaveError("Failed to attach thread to retry isolate teardown") + if _pending_teardown_thread is not None: + # Reuse the still-attached bootstrap thread from a prior double failure + # (bootstrap detach + immediate teardown both failed). Attaching a fresh + # worker would leave that thread attached and teardown could never + # succeed. Its detach already failed, so we must NOT detach it here. + worker = _pending_teardown_thread + attached_fresh = False + else: + worker = GraalIsolateThreadPointer() + if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: + raise DataWeaveError("Failed to attach thread to retry isolate teardown") + attached_fresh = True try: _tear_down(lib, worker) # raises on failure -> flag stays armed except BaseException: - # Teardown failed again: detach the worker we just attached (best-effort) - # so it does not stay attached and block the NEXT retry, which attaches - # its own fresh worker. _teardown_needed stays armed; globals not nulled. - try: - lib.graal_detach_thread(worker) - except Exception: - pass + # Teardown failed again. If we attached a fresh worker, detach it (best- + # effort) so it does not stay attached and block the NEXT retry, which + # attaches its own fresh worker. If we reused the retained bootstrap + # thread, keep it retained -- its detach already failed and the isolate + # is still live. _teardown_needed stays armed; globals not nulled. + if attached_fresh: + try: + lib.graal_detach_thread(worker) + except Exception: + pass raise # Success: the isolate (and every thread pointer into it) is now invalid, so # the worker must NOT be detached. _lib = _lib_path = _isolate = None _teardown_needed = False + _pending_teardown_thread = None def _acquire_isolate(lib_path: str): """Returns (lib, isolate), creating the shared isolate on the first reference. Increments the refcount only on success.""" - global _lib, _lib_path, _isolate, _isolate_ref_count, _teardown_needed + global _lib, _lib_path, _isolate, _isolate_ref_count, _teardown_needed, _pending_teardown_thread with _isolate_lock: _retry_pending_teardown_locked() if _isolate is None: @@ -173,14 +193,17 @@ def _acquire_isolate(lib_path: str): try: _tear_down(lib, thread) except BaseException: - # Even teardown failed: retain the created isolate and arm a - # retry rather than leaking it silently. Leaving the bootstrap - # `thread` attached here is intentional -- its detach already - # failed above, so we do NOT detach it again; the retry path - # (_retry_pending_teardown_locked) attaches its own fresh - # worker to re-attempt teardown. + # Even teardown failed: the isolate was NOT destroyed and the + # bootstrap `thread` is still attached to it. Retain both the + # isolate AND that still-attached bootstrap thread so the retry + # can tear down using IT -- GraalVM teardown can never succeed + # while the bootstrap thread stays attached, so a fresh worker + # could never tear this isolate down. Its detach already failed + # above, so we do NOT detach it again; the retry path + # (_retry_pending_teardown_locked) reuses the retained thread. _lib, _lib_path, _isolate = lib, lib_path, isolate _teardown_needed = True + _pending_teardown_thread = thread print( "DataWeave: bootstrap-thread detach and isolate teardown " "both failed; isolate retained for retry.", diff --git a/native-lib/python/tests/unit/conftest.py b/native-lib/python/tests/unit/conftest.py index 0b4483d6..4b0974b4 100644 --- a/native-lib/python/tests/unit/conftest.py +++ b/native-lib/python/tests/unit/conftest.py @@ -21,4 +21,5 @@ def _clear(): native._isolate = None native._isolate_ref_count = 0 native._teardown_needed = False + native._pending_teardown_thread = None native._lib_path = None diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index c42dffd7..0eaa9f57 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -733,6 +733,58 @@ def test_bootstrap_detach_and_teardown_both_failing_arms_retry_instead_of_leakin assert len(library.tear_down_threads) == 1 +@pytest.mark.unit +def test_retry_after_double_failure_reuses_retained_bootstrap_thread(monkeypatch): + """Review #13 (finding D): when a bootstrap-thread detach AND the immediate + teardown BOTH fail in _acquire_isolate, the isolate was NOT destroyed and the + bootstrap thread is still attached. GraalVM teardown can never succeed while + that thread stays attached, so the retry must reuse the RETAINED bootstrap + thread -- attaching a fresh worker would leave the bootstrap attached and + teardown could never succeed.""" + monkeypatch.setattr(native, "_teardown_needed", False) + monkeypatch.setattr(native, "_pending_teardown_thread", None) + library = FakeLibrary() + + # graal_create_isolate hands out a distinct, non-null bootstrap thread so we + # can prove the retry tears down using THAT thread, not a fresh attach (a + # fresh attach via FakeLibrary would produce a different worker pointer). + def create_isolate(_params, _isolate, thread_ptr): + bootstrap = ctypes.cast(ctypes.c_void_p(0x1000), native.GraalIsolateThreadPointer) + ctypes.cast( + thread_ptr, ctypes.POINTER(native.GraalIsolateThreadPointer) + )[0] = bootstrap + return 0 + + def failing_detach(_thread): + return 1 # bootstrap detach always fails + + teardown_calls = {"threads": []} + + def teardown(thread): + teardown_calls["threads"].append(ctypes.cast(thread, ctypes.c_void_p).value) + return 1 if len(teardown_calls["threads"]) == 1 else 0 # fail first, succeed on retry + + library.graal_create_isolate = CallableFunction(create_isolate) + library.graal_detach_thread = CallableFunction(failing_detach) + library.graal_tear_down_isolate = CallableFunction(teardown) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + with pytest.raises(native.DataWeaveError): + native._acquire_isolate("/tmp/dwlib") + assert native._teardown_needed is True + assert native._pending_teardown_thread is not None + bootstrap_addr = ctypes.cast(native._pending_teardown_thread, ctypes.c_void_p).value + assert bootstrap_addr == 0x1000 + + # Retry: teardown must reuse the retained bootstrap thread and succeed. + with native._isolate_lock: + native._retry_pending_teardown_locked() + assert native._teardown_needed is False + assert native._pending_teardown_thread is None + # Two teardown attempts total, both on the SAME retained bootstrap thread. + assert teardown_calls["threads"] == [bootstrap_addr, bootstrap_addr] + + class RetryTeardownFake: """Fake native lib for the failed-teardown / cross-thread-retry scenario. From d1bc8349414ca04de6d6c8899aa6c4125fa53837 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 18:43:45 -0300 Subject: [PATCH 185/216] docs(native-lib): correct stale isolate-lifecycle and run_script ABI references (review #12 #4 #5) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/README.md | 19 +++++++++++++++++-- native-lib/python/README.md | 14 +++++++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/native-lib/README.md b/native-lib/README.md index 3c1cee57..cfbda6d8 100644 --- a/native-lib/README.md +++ b/native-lib/README.md @@ -22,14 +22,29 @@ The main purpose is to allow non-JVM consumers (most notably the Python package │ ┌────────────────────────────────────────┐ │ │ │ Native Shared Library (dwlib) │ │ │ │ ┌──────────────────────────────────┐ │ │ -│ │ │ GraalVM Isolate │ │ │ -│ │ │ - NativeLib.run_script() │ │ │ +│ │ │ GraalVM Isolate (process-wide) │ │ │ +│ │ │ - create_engine / │ │ │ +│ │ │ create_engine_with_resolver │ │ │ +│ │ │ - run_script_engine / │ │ │ +│ │ │ run_script_callback_engine / │ │ │ +│ │ │ run_script_input_output_ │ │ │ +│ │ │ callback_engine │ │ │ +│ │ │ - destroy_engine │ │ │ │ │ │ - DataWeave script execution │ │ │ │ │ └──────────────────────────────────┘ │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ ``` +Each engine is a handle-addressed object created with `create_engine` (or +`create_engine_with_resolver`, which additionally registers a module-resolve +callback) and run via `run_script_engine`, `run_script_callback_engine`, or +`run_script_input_output_callback_engine`, then released with +`destroy_engine`. The underlying GraalVM isolate is a single process-wide +isolate, created and attached via `graal_create_isolate` / `graal_attach_thread` +on first use and torn down via `graal_tear_down_isolate` once the last engine +across the process has been destroyed. + ## Building with Gradle ### Prerequisites diff --git a/native-lib/python/README.md b/native-lib/python/README.md index 0997545f..b3991679 100644 --- a/native-lib/python/README.md +++ b/native-lib/python/README.md @@ -204,11 +204,15 @@ source, credentials, and local paths are not exposed. Set `DATAWEAVE_RESOLVER_DEBUG=1` only in a trusted debugging environment to include the exception type, message, and traceback. -Each initialized explicit Python `DataWeave` instance owns a dedicated Graal -isolate. Its first resolver-backed run installs that instance's resolver; later -runs reuse it. The instance retains the resolver callback until successful -isolate teardown, then releases callback references during `cleanup()`. -Different live instances can therefore use different resolvers. +There is a single process-wide GraalVM isolate, reference-counted by the +number of live engines across all `DataWeave` instances; it is created on the +first engine and torn down when the last one is released (with a retryable +teardown fallback if that final teardown fails). Each `DataWeave` instance +owns its own handle-addressed engine within that shared isolate. Its first +resolver-backed run installs that instance's resolver; later runs reuse it. +The instance retains the resolver callback until its engine is destroyed, +then releases callback references during `cleanup()`. Different live +instances can therefore use different resolvers. ### Custom module resolution scope From 8c0d06dd64792983a819fdc8c7799f2e8eff699e Mon Sep 17 00:00:00 2001 From: mlischetti Date: Fri, 28 Aug 2026 19:12:55 -0300 Subject: [PATCH 186/216] test(python): update README lifecycle guard to the corrected shared-isolate wording (review #12 #4) test_ci_structure pinned the exact stale 'dedicated Graal isolate per instance' and 'until successful isolate teardown' sentences that finding E asked us to remove. Assert the corrected shared-isolate / per-instance-engine wording instead, with negative guards so the stale claims cannot return. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../python/tests/unit/test_ci_structure.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/native-lib/python/tests/unit/test_ci_structure.py b/native-lib/python/tests/unit/test_ci_structure.py index c2d32dec..25f467e9 100644 --- a/native-lib/python/tests/unit/test_ci_structure.py +++ b/native-lib/python/tests/unit/test_ci_structure.py @@ -92,8 +92,20 @@ def test_python_readme_documents_module_resolver_contract(): normalized = " ".join(readme.split()) assert "without a leading path separator" in normalized assert "without a leading slash or separator" not in normalized - assert "Each initialized explicit Python `DataWeave` instance owns a dedicated Graal isolate." in normalized - assert "retains the resolver callback until successful isolate teardown" in normalized + # Lifecycle: one process-wide, reference-counted isolate with per-instance + # handle-addressed engines (review #12 #4 / #13 finding E). The stale + # "dedicated Graal isolate per instance" claim must stay gone. + assert ( + "There is a single process-wide GraalVM isolate, reference-counted by the " + "number of live engines across all `DataWeave` instances" in normalized + ) + assert ( + "Each `DataWeave` instance owns its own handle-addressed engine within " + "that shared isolate." in normalized + ) + assert "owns a dedicated Graal isolate" not in normalized + assert "retains the resolver callback until its engine is destroyed" in normalized + assert "until successful isolate teardown" not in normalized @pytest.mark.unit From 8d1419f8aeb60afe910fbbf06b817fb8ad03002b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 10:19:32 -0300 Subject: [PATCH 187/216] =?UTF-8?q?fix(native-lib):=20remediate=20review?= =?UTF-8?q?=20#14=20=E2=80=94=20test=20hermeticity=20+=20lifecycle=20doc?= =?UTF-8?q?=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - node: make the poisoned-singleton test hermetic by setting/restoring DATAWEAVE_NATIVE_LIB (findLibrary() runs in the DataWeave ctor before the mocked ffi.initialize; without a staged dwlib it threw) (review #14 #2) - native-lib/README: distinguish Raw C ABI (caller-managed isolate; destroy_engine only unregisters) from Node/Python bindings (process-wide ref-counted isolate torn down after final engine release) (review #14 #1) - python/README: initialization binds the resolver via create_engine_with_resolver, not on first run; subsequent runs reuse the resolver and cache (review #14 #3) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/README.md | 24 +++++-- .../tests/unit/dataweave-initialize.test.ts | 72 +++++++++++-------- native-lib/python/README.md | 13 ++-- 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/native-lib/README.md b/native-lib/README.md index cfbda6d8..ccc090a9 100644 --- a/native-lib/README.md +++ b/native-lib/README.md @@ -39,11 +39,25 @@ The main purpose is to allow non-JVM consumers (most notably the Python package Each engine is a handle-addressed object created with `create_engine` (or `create_engine_with_resolver`, which additionally registers a module-resolve callback) and run via `run_script_engine`, `run_script_callback_engine`, or -`run_script_input_output_callback_engine`, then released with -`destroy_engine`. The underlying GraalVM isolate is a single process-wide -isolate, created and attached via `graal_create_isolate` / `graal_attach_thread` -on first use and torn down via `graal_tear_down_isolate` once the last engine -across the process has been destroyed. +`run_script_input_output_callback_engine`, then released with `destroy_engine`. + +**Raw C ABI (caller-managed isolate).** At the C level the isolate lifecycle is +the caller's responsibility. A direct consumer creates and attaches the GraalVM +isolate itself via `graal_create_isolate` / `graal_attach_thread`, creates and +destroys any number of engines within it (`create_engine` / +`create_engine_with_resolver` … `destroy_engine`), and tears the isolate down +with `graal_tear_down_isolate` when done. `destroy_engine` only unregisters that +engine from the runtime; it never tears down the isolate. There is no built-in +reference counting at the ABI — the C consumer decides when the isolate is no +longer needed. + +**Node / Python bindings (reference-counted isolate).** The bindings layer this +policy on top of the raw ABI: each maintains a single process-wide GraalVM +isolate, reference-counted by the number of live engines across all instances. +The isolate is created and attached on first use and torn down via +`graal_tear_down_isolate` only after the final engine in the process has been +released (with a retryable-teardown fallback if teardown fails). This +ref-counting lives in the binding code, not in the dwlib engine ABI. ## Building with Gradle diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 913ffcec..538c472b 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -262,34 +262,50 @@ describe("DataWeave.initialize() native ref-count safety", () => { }); it("does not publish a poisoned singleton when the first module-level init fails", async () => { - // Isolate module state: a fresh import gives a null globalInstance so this - // test controls the very first getGlobalInstance() call. - vi.resetModules(); - const ffiMod = await import("../../src/ffi"); - const dwMod = await import("../../src/dataweave"); - - // First module-level run(): ffi.initialize() throws (e.g. bad lib path). - vi.mocked(ffiMod.initialize).mockImplementationOnce(() => { - throw new Error("library not found"); - }); - expect(() => dwMod.run("%dw 2.0\noutput application/json\n---\n1")).toThrow(); - - // The fault is corrected; the NEXT module-level run() must build a fresh, - // working singleton -- not reuse a poisoned, uninitialized one that fails - // "not initialized" forever (review #6 #1). - vi.mocked(ffiMod.initialize).mockImplementation(() => {}); - vi.mocked(ffiMod.createEngine).mockReturnValue(1); - vi.mocked(ffiMod.runScriptEngine).mockReturnValue( - JSON.stringify({ - success: true, - result: Buffer.from("1").toString("base64"), - mimeType: "application/json", - charset: "utf-8", - binary: false, - }) - ); - const result = dwMod.run("%dw 2.0\noutput application/json\n---\n1"); - expect(result.success).toBe(true); + // This drives the module-level run() through getGlobalInstance(), which + // constructs a DataWeave directly and so hits the real findLibrary() lookup + // (findLibrary is in the DataWeave constructor and is NOT mocked by the + // vi.mock("../../src/ffi") at the top of this file). Point + // DATAWEAVE_NATIVE_LIB at this test file (guaranteed to exist) so that + // lookup succeeds without depending on a staged/built dwlib -- ffi is + // mocked, so the path's contents are never touched. Without this the test + // fails with "Could not find DataWeave native library" whenever no dwlib is + // present (review #14 #2). + const prevEnvLib = process.env.DATAWEAVE_NATIVE_LIB; + process.env.DATAWEAVE_NATIVE_LIB = __filename; + try { + // Isolate module state: a fresh import gives a null globalInstance so this + // test controls the very first getGlobalInstance() call. + vi.resetModules(); + const ffiMod = await import("../../src/ffi"); + const dwMod = await import("../../src/dataweave"); + + // First module-level run(): ffi.initialize() throws (e.g. bad lib path). + vi.mocked(ffiMod.initialize).mockImplementationOnce(() => { + throw new Error("library not found"); + }); + expect(() => dwMod.run("%dw 2.0\noutput application/json\n---\n1")).toThrow(); + + // The fault is corrected; the NEXT module-level run() must build a fresh, + // working singleton -- not reuse a poisoned, uninitialized one that fails + // "not initialized" forever (review #6 #1). + vi.mocked(ffiMod.initialize).mockImplementation(() => {}); + vi.mocked(ffiMod.createEngine).mockReturnValue(1); + vi.mocked(ffiMod.runScriptEngine).mockReturnValue( + JSON.stringify({ + success: true, + result: Buffer.from("1").toString("base64"), + mimeType: "application/json", + charset: "utf-8", + binary: false, + }) + ); + const result = dwMod.run("%dw 2.0\noutput application/json\n---\n1"); + expect(result.success).toBe(true); + } finally { + if (prevEnvLib === undefined) delete process.env.DATAWEAVE_NATIVE_LIB; + else process.env.DATAWEAVE_NATIVE_LIB = prevEnvLib; + } }); it("gates re-initialization on the in-flight rollback when engine creation fails", async () => { diff --git a/native-lib/python/README.md b/native-lib/python/README.md index b3991679..973eb866 100644 --- a/native-lib/python/README.md +++ b/native-lib/python/README.md @@ -208,11 +208,14 @@ There is a single process-wide GraalVM isolate, reference-counted by the number of live engines across all `DataWeave` instances; it is created on the first engine and torn down when the last one is released (with a retryable teardown fallback if that final teardown fails). Each `DataWeave` instance -owns its own handle-addressed engine within that shared isolate. Its first -resolver-backed run installs that instance's resolver; later runs reuse it. -The instance retains the resolver callback until its engine is destroyed, -then releases callback references during `cleanup()`. Different live -instances can therefore use different resolvers. +owns its own handle-addressed engine within that shared isolate. +Initialization binds the resolver to that engine — `DataWeave.initialize()` +creates the engine via `create_engine_with_resolver`, so the resolver is +installed once, up front, not on first use. Subsequent runs reuse the same +resolver and its compiled-module cache. The instance retains the resolver +callback until its engine is destroyed, then releases callback references +during `cleanup()`. Different live instances can therefore use different +resolvers. ### Custom module resolution scope From ff483d3d870ea9c4fecaaaf654e4214c9e24b03b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 11:53:14 -0300 Subject: [PATCH 188/216] fix(python): stop reusing the bootstrap IsolateThread across OS threads (review #15 #1) Option A: the _acquire_isolate bootstrap double-failure is now unrecoverable -- the OS-thread-affine bootstrap thread is never retained/reused; the wedged isolate leaks and a later initialize() builds a fresh one. The release-path retry (fresh worker on the current thread) is preserved and now covered by a distinct-OS-thread regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/native.py | 83 ++++++------- native-lib/python/tests/unit/conftest.py | 1 - native-lib/python/tests/unit/test_native.py | 126 +++++++++++--------- 3 files changed, 104 insertions(+), 106 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index 8ebc4298..cecc9f2d 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -41,13 +41,6 @@ class graal_isolatethread_t(ctypes.Structure): # Node's g_teardown_needed retryable-teardown model. Only read/written while # holding _isolate_lock. _teardown_needed = False -# When a bootstrap-thread detach AND the immediate teardown BOTH fail in -# _acquire_isolate, the isolate was NOT destroyed and the bootstrap thread is -# still attached. GraalVM teardown cannot succeed while it stays attached, so the -# retry must reuse THIS thread rather than attach a fresh worker (which could -# never tear down). None except across such a double-failure window. Guarded by -# _isolate_lock. -_pending_teardown_thread = None # Per-engine resolver dispatch. The ctx passed to create_engine_with_resolver is @@ -120,48 +113,43 @@ def _retry_pending_teardown_locked() -> None: On success, clears the flag and nulls the isolate globals so the caller may build fresh. On failure, leaves the isolate live and the flag armed, and propagates the failure so the caller does not proceed to build a second, - racing isolate.""" - global _lib, _lib_path, _isolate, _teardown_needed, _pending_teardown_thread + racing isolate. + + The retry ALWAYS attaches a FRESH worker on the CURRENT OS thread. GraalVM + IsolateThread handles are OS-thread-affine, so a thread attached on one OS + thread must never be reused to tear down from another (review #15 #1). This + is sound because the only path that arms a retry is the last-release path + (_release_isolate), which leaves NO thread persistently attached -- the + bootstrap was detached at create and every op detaches its own thread -- so a + fresh attach on the current thread is always a valid, sole attachment.""" + global _lib, _lib_path, _isolate, _teardown_needed if not _teardown_needed: return lib, isolate = _lib, _isolate - if _pending_teardown_thread is not None: - # Reuse the still-attached bootstrap thread from a prior double failure - # (bootstrap detach + immediate teardown both failed). Attaching a fresh - # worker would leave that thread attached and teardown could never - # succeed. Its detach already failed, so we must NOT detach it here. - worker = _pending_teardown_thread - attached_fresh = False - else: - worker = GraalIsolateThreadPointer() - if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: - raise DataWeaveError("Failed to attach thread to retry isolate teardown") - attached_fresh = True + worker = GraalIsolateThreadPointer() + if lib.graal_attach_thread(isolate, ctypes.byref(worker)) != 0: + raise DataWeaveError("Failed to attach thread to retry isolate teardown") try: _tear_down(lib, worker) # raises on failure -> flag stays armed except BaseException: - # Teardown failed again. If we attached a fresh worker, detach it (best- - # effort) so it does not stay attached and block the NEXT retry, which - # attaches its own fresh worker. If we reused the retained bootstrap - # thread, keep it retained -- its detach already failed and the isolate - # is still live. _teardown_needed stays armed; globals not nulled. - if attached_fresh: - try: - lib.graal_detach_thread(worker) - except Exception: - pass + # Teardown failed again. Detach the fresh worker (best-effort) so it does + # not stay attached and block the NEXT retry, then re-raise with the flag + # still armed and globals not nulled. + try: + lib.graal_detach_thread(worker) + except Exception: + pass raise # Success: the isolate (and every thread pointer into it) is now invalid, so # the worker must NOT be detached. _lib = _lib_path = _isolate = None _teardown_needed = False - _pending_teardown_thread = None def _acquire_isolate(lib_path: str): """Returns (lib, isolate), creating the shared isolate on the first reference. Increments the refcount only on success.""" - global _lib, _lib_path, _isolate, _isolate_ref_count, _teardown_needed, _pending_teardown_thread + global _lib, _lib_path, _isolate, _isolate_ref_count, _teardown_needed with _isolate_lock: _retry_pending_teardown_locked() if _isolate is None: @@ -189,24 +177,27 @@ def _acquire_isolate(lib_path: str): # created but not yet published (globals unset, refcount not # bumped), so tear it down here rather than leak an unreachable # live isolate. Reuse the same still-attached bootstrap thread to - # tear down (it is the only attached thread). + # tear down -- valid because this runs on the OS thread that + # created it (IsolateThread values are OS-thread-affine). try: _tear_down(lib, thread) except BaseException: - # Even teardown failed: the isolate was NOT destroyed and the - # bootstrap `thread` is still attached to it. Retain both the - # isolate AND that still-attached bootstrap thread so the retry - # can tear down using IT -- GraalVM teardown can never succeed - # while the bootstrap thread stays attached, so a fresh worker - # could never tear this isolate down. Its detach already failed - # above, so we do NOT detach it again; the retry path - # (_retry_pending_teardown_locked) reuses the retained thread. - _lib, _lib_path, _isolate = lib, lib_path, isolate - _teardown_needed = True - _pending_teardown_thread = thread + # Double failure: the bootstrap thread could be neither + # detached nor used to tear the isolate down. Only THIS OS + # thread could ever tear this isolate down (GraalVM teardown + # needs the sole attached thread, on its own OS thread), and + # we are about to return an error with no guarantee this + # thread re-enters. Retaining the bootstrap IsolateThread for + # a later retry would risk handing an OS-thread-affine pointer + # to graal_tear_down_isolate from a DIFFERENT thread (review + # #15 #1: wrong-thread fatal path). So treat the isolate as + # UNRECOVERABLE: leave the globals unset (this isolate leaks + # until process exit) so a later initialize() -- on any thread + # -- builds a fresh isolate. print( "DataWeave: bootstrap-thread detach and isolate teardown " - "both failed; isolate retained for retry.", + "both failed; the isolate is unrecoverable and is leaked " + "(a later initialize() will build a fresh one).", file=sys.stderr, ) raise DataWeaveError( diff --git a/native-lib/python/tests/unit/conftest.py b/native-lib/python/tests/unit/conftest.py index 4b0974b4..0b4483d6 100644 --- a/native-lib/python/tests/unit/conftest.py +++ b/native-lib/python/tests/unit/conftest.py @@ -21,5 +21,4 @@ def _clear(): native._isolate = None native._isolate_ref_count = 0 native._teardown_needed = False - native._pending_teardown_thread = None native._lib_path = None diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 0eaa9f57..7693c437 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -1,5 +1,6 @@ from pathlib import Path import ctypes +import threading from threading import Barrier, BrokenBarrierError, current_thread, get_ident, Thread import pytest @@ -712,10 +713,14 @@ def create_isolate(_params, _isolate, _thread): @pytest.mark.unit -def test_bootstrap_detach_and_teardown_both_failing_arms_retry_instead_of_leaking(monkeypatch): - """If the just-created isolate's teardown ALSO fails after a bootstrap - detach failure, the isolate must be retained (not silently leaked) and a - retry armed for the next acquire -- mirroring the release-path contract.""" +def test_bootstrap_detach_and_teardown_both_failing_leaks_isolate_without_retaining_thread(monkeypatch): + """Option A (review #15 #1): when a bootstrap-thread detach AND the immediate + teardown BOTH fail in _acquire_isolate, the still-attached bootstrap + IsolateThread is OS-thread-affine and could only ever tear this isolate down + from THIS OS thread. Retaining it for a cross-thread retry risks handing a + foreign thread to graal_tear_down_isolate (wrong-thread fatal path). So the + isolate is treated as UNRECOVERABLE: leaked (globals unset), no retry armed, + no thread retained -- and a later initialize() builds a fresh isolate.""" library = FakeLibrary() library.graal_detach_thread = CallableFunction(lambda _thread: 1) # bootstrap detach fails library.graal_tear_down_isolate = CallableFunction( @@ -726,63 +731,20 @@ def test_bootstrap_detach_and_teardown_both_failing_arms_retry_instead_of_leakin with pytest.raises(native.DataWeaveError): native._acquire_isolate("/tmp/dwlib") - assert native._isolate is not None - assert native._lib is library + # Leaked, not published; no retry armed; no thread retained. + assert native._isolate is None + assert native._lib is None assert native._isolate_ref_count == 0 - assert native._teardown_needed is True - assert len(library.tear_down_threads) == 1 - - -@pytest.mark.unit -def test_retry_after_double_failure_reuses_retained_bootstrap_thread(monkeypatch): - """Review #13 (finding D): when a bootstrap-thread detach AND the immediate - teardown BOTH fail in _acquire_isolate, the isolate was NOT destroyed and the - bootstrap thread is still attached. GraalVM teardown can never succeed while - that thread stays attached, so the retry must reuse the RETAINED bootstrap - thread -- attaching a fresh worker would leave the bootstrap attached and - teardown could never succeed.""" - monkeypatch.setattr(native, "_teardown_needed", False) - monkeypatch.setattr(native, "_pending_teardown_thread", None) - library = FakeLibrary() - - # graal_create_isolate hands out a distinct, non-null bootstrap thread so we - # can prove the retry tears down using THAT thread, not a fresh attach (a - # fresh attach via FakeLibrary would produce a different worker pointer). - def create_isolate(_params, _isolate, thread_ptr): - bootstrap = ctypes.cast(ctypes.c_void_p(0x1000), native.GraalIsolateThreadPointer) - ctypes.cast( - thread_ptr, ctypes.POINTER(native.GraalIsolateThreadPointer) - )[0] = bootstrap - return 0 - - def failing_detach(_thread): - return 1 # bootstrap detach always fails - - teardown_calls = {"threads": []} - - def teardown(thread): - teardown_calls["threads"].append(ctypes.cast(thread, ctypes.c_void_p).value) - return 1 if len(teardown_calls["threads"]) == 1 else 0 # fail first, succeed on retry - - library.graal_create_isolate = CallableFunction(create_isolate) - library.graal_detach_thread = CallableFunction(failing_detach) - library.graal_tear_down_isolate = CallableFunction(teardown) - monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) - - with pytest.raises(native.DataWeaveError): - native._acquire_isolate("/tmp/dwlib") - assert native._teardown_needed is True - assert native._pending_teardown_thread is not None - bootstrap_addr = ctypes.cast(native._pending_teardown_thread, ctypes.c_void_p).value - assert bootstrap_addr == 0x1000 - - # Retry: teardown must reuse the retained bootstrap thread and succeed. - with native._isolate_lock: - native._retry_pending_teardown_locked() assert native._teardown_needed is False - assert native._pending_teardown_thread is None - # Two teardown attempts total, both on the SAME retained bootstrap thread. - assert teardown_calls["threads"] == [bootstrap_addr, bootstrap_addr] + assert len(library.tear_down_threads) == 1 # tried once, on the bootstrap thread + + # The module is NOT wedged: a healthy library builds a fresh isolate. + healthy = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: healthy) + lib, isolate = native._acquire_isolate("/tmp/dwlib") + assert lib is healthy + assert native._isolate is not None + native._release_isolate() # clean up the fresh isolate class RetryTeardownFake: @@ -887,6 +849,52 @@ def run_retry(): assert fake.detached == [0x1000] # success path does not detach +@pytest.mark.unit +def test_release_teardown_retry_from_a_distinct_os_thread_uses_a_fresh_worker(monkeypatch): + """Review #15 #1: the retry must attach a FRESH worker on whatever OS thread + runs it -- never reuse a thread attached on another OS thread. Drive the + last-release teardown to fail (arming _teardown_needed with NO retained + thread), then run the retry from a distinct threading.Thread and prove it + attached a new worker on that thread and tore down successfully. + + RetryTeardownFake implements only the lifecycle ABI (attach/detach/teardown), + not the full export set _acquire_isolate/_bind_abi require, so publish the + shared isolate by driving the globals directly -- exactly the setup pattern + used by the sibling cross-thread retry test above.""" + fake = RetryTeardownFake() + monkeypatch.setattr(native, "_lib", fake) + monkeypatch.setattr(native, "_lib_path", "/tmp/dwlib") + monkeypatch.setattr(native, "_isolate", native.GraalIsolatePointer()) + monkeypatch.setattr(native, "_isolate_ref_count", 1) + monkeypatch.setattr(native, "_teardown_needed", False) + + with pytest.raises(native.DataWeaveError): + native._release_isolate() # last release: teardown fails once -> armed + assert native._teardown_needed is True + assert native._isolate is not None + workers_before = list(fake.attach_workers) + + errors = [] + def retry_on_other_thread(): + try: + with native._isolate_lock: + native._retry_pending_teardown_locked() # succeeds on the 2nd teardown + except BaseException as e: # pragma: no cover - surfaced via errors + errors.append(e) + + t = threading.Thread(target=retry_on_other_thread) + t.start() + t.join() + + assert errors == [] + assert native._teardown_needed is False + assert native._isolate is None + # The retry attached a NEW worker (distinct pointer) and tore down with IT. + assert len(fake.attach_workers) == len(workers_before) + 1 + fresh_worker = fake.attach_workers[-1] + assert fake.tear_down_workers[-1] == fresh_worker + + @pytest.mark.unit def test_two_engines_dispatch_to_their_own_resolver(monkeypatch): library = FakeLibrary() From 88e7b5cbe582c1828466056f8d83345e55d146d1 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 12:21:33 -0300 Subject: [PATCH 189/216] fix(native-lib): stop feeder-join busy-spin when the cleanup caller is interrupted (review #15 #2) Record interruption in a local flag and keep joining with the interrupt status clear so join() blocks instead of re-throwing immediately; restore the interrupt after the feeder terminates. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/org/mule/weave/lib/NativeLib.java | 23 +++-- .../mule/weave/lib/NativeLibFeederTest.java | 88 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 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 7abf0832..8fb33c25 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 @@ -314,8 +314,11 @@ private static String mergeInputEntry(String existingJson, String name, org.json * where the feeder has already reached EOF and exited. It also unregisters the handle. *
  • Join without a finite timeout — we wait for {@code run()} to complete rather * than abandoning the thread after a bound. An {@link InterruptedException} does not end - * the wait (returning early would reopen the use-after-free window); we re-assert the - * interrupt and keep waiting.
  • + * the wait (returning early would reopen the use-after-free window): we record the + * interruption locally and keep joining with the interrupt status cleared, so + * {@code join()} actually blocks instead of immediately re-throwing and busy-spinning. + * The caller's interrupt status is restored exactly once, after the feeder has + * terminated. * * *

    Null-safety: when the feeder never started — a setup failure threw @@ -340,16 +343,22 @@ static void cleanupFeeder(InputCallbackFeeder feederRunnable, Thread thread, lon if (thread == null) { return; } - boolean joined = false; - while (!joined) { + boolean interrupted = false; + while (thread.isAlive()) { try { thread.join(); - joined = true; } catch (InterruptedException e) { - // Never abandon a live feeder: re-assert the interrupt and keep waiting. - Thread.currentThread().interrupt(); + // Never abandon a live feeder (abandoning reopens the use-after-free + // window this method exists to close). Record the interruption and + // keep waiting with the interrupt status CLEARED, so join() actually + // blocks instead of re-throwing immediately and busy-spinning. + interrupted = true; } } + if (interrupted) { + // Restore the caller's interrupt status now that the feeder has exited. + Thread.currentThread().interrupt(); + } } /** diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java index 9a5dff0d..114fd2c9 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -9,8 +9,11 @@ import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadMXBean; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -380,4 +383,89 @@ int readChunk(byte[] dest, int max) { assertTrue(resultJson.contains("\"success\":false"), "selectTransformResult must observe the feeder's late failure, was: " + resultJson); } + + // ── Interrupted cleanup caller must block, not busy-spin (review #15 #2) ───── + + /** + * Regression guard: before the fix, {@code cleanupFeeder}'s join-retry loop re-asserted the + * interrupt inside its {@code catch (InterruptedException)} handler. If the caller's interrupt + * flag was already set when {@code cleanupFeeder} was invoked, every subsequent + * {@code thread.join()} re-threw immediately (an interrupted {@code join()} throws without + * blocking), so the loop busy-spun at full CPU instead of blocking, even while the feeder was + * still alive. + * + *

    This test pins a feeder alive (parked in an overridden {@code readChunk} behind a latch) + * and runs {@code cleanupFeeder} on a worker thread that arrives pre-interrupted. It then + * measures the worker thread's own CPU time (via {@link ThreadMXBean#getThreadCpuTime(long)}) + * over a fixed wall-clock window while the feeder is held alive: the fix must let {@code join()} + * actually block, consuming close to zero CPU, whereas the bug's tight + * re-throw/catch/re-interrupt loop consumes close to 100% of the window.

    + * + *

    Determinism note: sampling {@link Thread#getState()} was tried first and rejected — the + * JVM's {@code join()}/{@code wait()} interrupt check can transition the thread through a + * momentary {@code WAITING} state even while it is, in aggregate, busy-spinning (verified: on + * this JDK, 15/15 trial runs of a state-polling assertion falsely passed against the unfixed + * {@code cleanupFeeder}, i.e. it never actually caught the regression). Per-thread CPU time + * integrated over a window does not have that failure mode: it reliably reports ~0ns against + * the fix and ~100% of the window against the bug (verified over multiple runs against both). + * {@code cleaner} is a daemon thread specifically so that if this regression is ever + * reintroduced, the busy-spin (which never terminates on its own, since {@code release} is + * only counted down after this assertion) does not hang the test JVM — it only fails the + * assertion below.

    + */ + @Test + void interruptedCleanupCallerBlocksInJoinInsteadOfBusySpinning() throws Exception { + InputStreamSession inputSession = new InputStreamSession("application/json", "UTF-8"); + long inputHandle = inputSession.register(); + + CountDownLatch feederParked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + // Feeder blocks inside readChunk until released, so it stays alive across the join. + NativeLib.InputCallbackFeeder feeder = new NativeLib.InputCallbackFeeder(0L, 0L, inputSession) { + @Override + int readChunk(byte[] dest, int max) { + feederParked.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return 0; // clean EOF once released, so the loop exits promptly + } + }; + Thread feederThread = new Thread(feeder, "test-busy-spin-feeder"); + feederThread.setDaemon(true); + feederThread.start(); + assertTrue(feederParked.await(2, TimeUnit.SECONDS), "feeder never entered the read callback"); + + AtomicBoolean restored = new AtomicBoolean(false); + Thread cleaner = new Thread(() -> { + Thread.currentThread().interrupt(); // caller arrives already interrupted + NativeLib.cleanupFeeder(feeder, feederThread, inputHandle); + restored.set(Thread.currentThread().isInterrupted()); // must be restored at the end + }); + // Daemon: see the determinism note above — a reintroduced regression must not hang the JVM. + cleaner.setDaemon(true); + cleaner.start(); + + // The fix blocks in join(), consuming ~no CPU; the bug spins, consuming ~all of the window. + ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); + long cleanerId = cleaner.getId(); + Thread.sleep(50); // let the cleanup thread reach steady state (blocked, or spinning) + long cpuBefore = threadMXBean.getThreadCpuTime(cleanerId); + Thread.sleep(300); + long cpuAfter = threadMXBean.getThreadCpuTime(cleanerId); + assertTrue(cpuBefore >= 0 && cpuAfter >= 0, + "thread CPU time measurement unavailable on this JVM"); + long consumedNanos = cpuAfter - cpuBefore; + assertTrue(consumedNanos < TimeUnit.MILLISECONDS.toNanos(100), + "cleanup thread must block in join(), not busy-spin, under interruption (consumed " + + TimeUnit.NANOSECONDS.toMillis(consumedNanos) + "ms of CPU over a 300ms window)"); + + release.countDown(); // let the feeder finish + cleaner.join(5000); + feederThread.join(5000); + assertFalse(cleaner.isAlive()); + assertTrue(restored.get(), "interrupt status must be restored after cleanup"); + } } From 06ff5a64f15fedd06907c264d5ef38b70ecf14c0 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 12:25:44 -0300 Subject: [PATCH 190/216] =?UTF-8?q?docs(spec):=20align=20=C2=A710=20Python?= =?UTF-8?q?=20teardown-failure=20behavior=20with=20retained/retryable=20mo?= =?UTF-8?q?del=20(review=20#15=20#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-08-07-native-lib-multi-engine-design.md | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) 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 index 5141ea56..421dffa5 100644 --- 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 @@ -617,10 +617,23 @@ dwB.cleanup() → join workers; destroy_engine(handleB); ref 1→0 → attach f `_isolate` stays None; `create_engine` failure after isolate create → release the ref (tearing down if this call created it) and unregister any resolver token, then raise. `run`/stream after `cleanup()` → instance guard raises `DataWeaveError` (handle already cleared). -- **Teardown failure (Python `graal_tear_down_isolate` returns nonzero):** surface a warning and - re-raise `DataWeaveError`, and clear the isolate globals (`_lib`/`_lib_path`/`_isolate` → None, - count already 0) so the next `initialize()` builds a fresh isolate rather than reusing one whose - teardown just failed. +- **Teardown failure (Python `graal_tear_down_isolate` returns nonzero):** the + last-release teardown attaches a fresh worker on the releasing OS thread; if it + (or the attach immediately before it) fails, the isolate is **retained live** + and `_teardown_needed` is armed rather than nulling the globals — nulling would + let the next `initialize()` build a second, racing isolate. The next + `initialize()` (on any OS thread) retries by attaching a **fresh** worker on the + current thread and tearing down; on success it clears the flag and nulls the + globals. GraalVM `IsolateThread` handles are OS-thread-affine, so the retry + never reuses a thread attached on another OS thread — it always attaches its own. +- **Bootstrap-thread double failure (Python, `_acquire_isolate`):** if the just- + created isolate's bootstrap thread can be neither detached **nor** used to tear + the isolate down, only the creating OS thread could ever tear it down (teardown + needs the sole attached thread, on its own OS thread). Rather than retain that + OS-thread-affine bootstrap thread for a cross-thread retry (which would risk the + wrong-thread fatal path), the isolate is treated as **unrecoverable**: the + globals are left unset (the isolate leaks until process exit) and a later + `initialize()` on any thread builds a fresh isolate. - **Intended breaking changes (pre-GA, no shims):** the dwlib C ABI drops the exported `run_script` / `run_script_callback` / `run_script_input_output_callback` legacy singleton entrypoints **and** the `run_script[...]_with_resolver` entrypoints, keeping only the `*_engine` From 95ef4ba9f82f61d0297e0cfd881697da6382a4b2 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 14:01:28 -0300 Subject: [PATCH 191/216] test(native-lib): fix feeder late-failure tests racing their own cancel signal getErrorReflectsLateFailureOnlyAfterJoin and selectTransformResultObservesLateFailureOnlyAfterJoin started the feeder thread and then cancelled it (via cleanupFeeder / selectTransformResult) with no happens-before guaranteeing the feeder had entered readChunk. When the feeder thread was slow to schedule, the cancel() set cancelled=true before run() first evaluated while (!cancelled), so readChunk never ran, no feeder error was recorded, and the thread exited clean -- failing assertNotNull(feeder.getError()) at line 324. Review #15 misclassified this exact failure as a harness-only artifact. It is a real race that fails in the Gradle native-lib:test lane (~52% locally). Gate each test on an `entered` CountDownLatch the feeder counts down at readChunk entry (past run()'s while(!cancelled) guard), mirroring the existing cleanupFeederCancelsAndJoinsInFlightReadCallbackBeforeReturning test in the same file. Test-only; no production code change. Verified with a JUnit Platform launcher over the compiled classes: the committed version failed 417/800 runs; the fixed version passed 1000/1000. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mule/weave/lib/NativeLibFeederTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java index 114fd2c9..0280cc9b 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -290,6 +290,7 @@ private static final class NativeLibFeederConstants { @Test void getErrorReflectsLateFailureOnlyAfterJoin() throws Exception { CountDownLatch release = new CountDownLatch(1); + CountDownLatch entered = new CountDownLatch(1); InputStreamSession session = new InputStreamSession("application/json", null); long handle = session.register(); // A feeder whose read callback blocks until released, then reports an out-of-range @@ -300,6 +301,13 @@ void getErrorReflectsLateFailureOnlyAfterJoin() throws Exception { NativeLib.InputCallbackFeeder feeder = new NativeLib.InputCallbackFeeder(0L, 0L, session) { @Override int readChunk(byte[] dest, int max) { + // Signal that the feeder is inside readChunk -- i.e. run() is past its + // while(!cancelled) guard -- BEFORE blocking, so the test can guarantee the + // loop body is running before cleanupFeeder's cancel() fires. Without this + // barrier a slow-to-schedule feeder thread could observe cancelled==true first, + // skip readChunk entirely, and exit with no error recorded (the race that made + // this test flaky under real CI thread scheduling). + entered.countDown(); try { release.await(); } catch (InterruptedException e) { @@ -312,6 +320,11 @@ int readChunk(byte[] dest, int max) { t.setDaemon(true); t.start(); + // Wait until the feeder is actually inside readChunk (past run()'s while(!cancelled) + // guard) before doing anything that cancels it, so the out-of-range return is always + // processed into a feeder error rather than skipped by an early cancel. + assertTrue(entered.await(5, TimeUnit.SECONDS), "feeder never entered readChunk"); + // Pre-join: the callback is still blocked, so no terminal error is visible yet. assertNull(feeder.getError()); @@ -342,11 +355,16 @@ int readChunk(byte[] dest, int max) { @Test void selectTransformResultObservesLateFailureOnlyAfterJoin() throws Exception { CountDownLatch release = new CountDownLatch(1); + CountDownLatch entered = new CountDownLatch(1); InputStreamSession inputSession = new InputStreamSession("application/json", null); long inputHandle = inputSession.register(); NativeLib.InputCallbackFeeder feeder = new NativeLib.InputCallbackFeeder(0L, 0L, inputSession) { @Override int readChunk(byte[] dest, int max) { + // See getErrorReflectsLateFailureOnlyAfterJoin: signal readChunk entry (run() + // past its while(!cancelled) guard) before blocking, so selectTransformResult's + // internal cancel() cannot win a scheduling race and skip readChunk. + entered.countDown(); try { release.await(); } catch (InterruptedException e) { @@ -359,6 +377,10 @@ int readChunk(byte[] dest, int max) { t.setDaemon(true); t.start(); + // Guarantee the feeder is inside readChunk (past run()'s while(!cancelled) guard) before + // selectTransformResult below can cancel it, so the late failure is always recorded. + assertTrue(entered.await(5, TimeUnit.SECONDS), "feeder never entered readChunk"); + // Release the blocked callback ~100ms from now, on a separate thread, so the call under // test below begins while the feeder is still guaranteed to be blocked (no error // recorded yet). A correct implementation's join (inside cleanupFeeder) then waits for From 017508934027687d7220d246c66bf88b683f5fc3 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 14:25:58 -0300 Subject: [PATCH 192/216] fix(native-lib): treat a -1 read-callback status as a terminal feeder error (review #16 #1) run() broke on `n <= 0`, treating the documented input-error code -1 identically to a clean EOF (0). A read callback that supplied complete input then returned -1 left feederError unset, so the transform reported success:true -- silently converting an input failure into success. Split the branch: 0 stays clean EOF; -1 records a terminal feeder error (unless rejectOutOfRange already set a more specific one). Out-of-range validation and the n>0 write path are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/org/mule/weave/lib/NativeLib.java | 16 +++++++-- .../mule/weave/lib/NativeLibFeederTest.java | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 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 8fb33c25..0770d087 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 @@ -475,8 +475,20 @@ public void run() { if (n > CALLBACK_BUFFER_SIZE || n < -1) { n = rejectOutOfRange(n, CALLBACK_BUFFER_SIZE); } - if (n <= 0) { - break; // 0 = EOF, negative = error + if (n == 0) { + break; // clean EOF + } + if (n < 0) { + // n == -1: the read callback signalled an input error (the documented + // error code, produced e.g. when a Python read callback raises). Record a + // terminal feeder error so complete-but-then-failed input can never be + // reported as success:true. rejectOutOfRange already set a more specific + // message for out-of-range values funnelled to -1, so only fill in the + // generic error when none was recorded. + if (feederError == null) { + feederError = "Input read callback signalled an error (returned -1)"; + } + break; } // Check AFTER the callback returns and BEFORE re-invoking / writing: once // cancelled, a slow-but-returning in-flight callback must not be re-entered diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java index 0280cc9b..0c15de28 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -269,6 +269,40 @@ public void setFeeder(NativeLib.InputCallbackFeeder feeder) { assertNull(ref.get().getError(), "clean EOF must leave getError() == null"); } + /** + * A read callback that supplies a complete chunk and then returns {@code -1} (the documented + * input-error code) must be recorded as a terminal feeder error, NOT treated as a clean EOF. + * Otherwise DataWeave can parse the already-supplied input and the transform reports + * success:true, silently converting an input failure into success (review #16 #1). + */ + @Test + void readCallbackErrorMinusOneAfterCompleteInputIsRecordedAsFeederError() throws Exception { + AtomicReference ref = new AtomicReference<>(); + AtomicInteger calls = new AtomicInteger(0); + Throwable escaped = runFeederCapturingEscapedError(new ReadChunkStub() { + @Override + public int readChunk(byte[] dest, int max) { + // First read supplies a complete, syntactically valid input; the second signals -1. + if (calls.getAndIncrement() == 0) { + dest[0] = '{'; + dest[1] = '}'; + return 2; + } + return -1; // documented input-error code (in range, so rejectOutOfRange won't fire) + } + + @Override + public void setFeeder(NativeLib.InputCallbackFeeder feeder) { + ref.set(feeder); + } + }); + + assertNull(escaped, "no exception may escape run() on a -1 read: " + escaped); + assertEquals(2, calls.get(), "feeder must stop after the -1 read"); + assertNotNull(ref.get().getError(), + "a -1 read after complete input must be recorded as a feeder error, not a clean EOF"); + } + /** Mirrors the package-private {@code CALLBACK_BUFFER_SIZE} used as the read {@code max}. */ private static final class NativeLibFeederConstants { static final int BUFFER = 8 * 1024; From 07c48413b44d1c5dd7192965dd888d49cd105a93 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 14:32:53 -0300 Subject: [PATCH 193/216] fix(python): leak-and-continue when teardown recovery cannot detach its worker (review #16 #2) Both teardown-failure branches (_release_isolate and _retry_pending_teardown_locked) detached the just-attached worker with `try: graal_detach_thread(worker) except Exception: pass`, discarding the nonzero STATUS graal_detach_thread returns on failure. A failed detach left the worker attached while _teardown_needed stayed armed, so the next retry attached ANOTHER worker on top of it -- and graal_tear_down_isolate, needing the sole attached thread, was then permanently blocked. Inspect the detach status; on a teardown-plus-detach double failure, transition to the same explicit unrecoverable-leak state as the bootstrap double failure: null the globals, do NOT arm a retry, retain no thread. A later initialize() builds a fresh isolate. The detach-succeeds branch (retain live + arm retry) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/native.py | 65 ++++++++++++++----- native-lib/python/tests/unit/test_native.py | 69 +++++++++++++++++++++ 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index cecc9f2d..2ce0fbbc 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -132,13 +132,30 @@ def _retry_pending_teardown_locked() -> None: try: _tear_down(lib, worker) # raises on failure -> flag stays armed except BaseException: - # Teardown failed again. Detach the fresh worker (best-effort) so it does - # not stay attached and block the NEXT retry, then re-raise with the flag - # still armed and globals not nulled. + # Teardown failed again. Detach the fresh worker so it does not stay + # attached and block the NEXT retry. graal_detach_thread returns a nonzero + # STATUS on failure (it does not raise), so inspect it. + detach_failed = False try: - lib.graal_detach_thread(worker) + detach_failed = lib.graal_detach_thread(worker) != 0 except Exception: - pass + detach_failed = True + if detach_failed: + # Double failure: another retry would stack a second stuck worker and + # block teardown forever. Treat the isolate as UNRECOVERABLE (review + # #16 #2, leak-and-continue): null the globals, disarm the retry, retain + # no thread. A later initialize() builds a fresh isolate. + _lib = _lib_path = _isolate = None + _teardown_needed = False + print( + "DataWeave: GraalVM isolate teardown retry failed and the worker " + "could not be detached; the isolate is unrecoverable and is leaked " + "(a later initialize() will build a fresh one).", + file=sys.stderr, + ) + raise + # Detach succeeded: leave the flag armed and the globals intact, and + # propagate so the caller does not build a second racing isolate. raise # Success: the isolate (and every thread pointer into it) is now invalid, so # the worker must NOT be detached. @@ -240,18 +257,36 @@ def _release_isolate() -> None: try: _tear_down(lib, worker) except BaseException: - # Teardown failed: detach the worker we just attached FIRST (best- - # effort) so it does not stay attached and block a later retry, which - # attaches its own fresh worker. Then keep the isolate live, arm a - # retry, and do NOT null globals (nulling would let the next - # initialize() build a second live isolate). Mirrors Node's - # g_teardown_needed retryable model. On the SUCCESS path below the - # worker is intentionally left undetached -- after _tear_down returns - # the isolate is gone and the worker pointer is invalid. + # Teardown failed. Detach the worker we just attached so it does not + # stay attached and block a later retry. graal_detach_thread returns a + # nonzero STATUS on failure (it does not raise), so inspect it: a + # still-attached worker permanently blocks any future teardown, which + # needs the SOLE attached thread. + detach_failed = False try: - lib.graal_detach_thread(worker) + detach_failed = lib.graal_detach_thread(worker) != 0 except Exception: - pass + detach_failed = True + if detach_failed: + # Teardown AND detach both failed. Arming a retry would attach yet + # another worker on top of the stuck one and block teardown forever, + # so treat the isolate as UNRECOVERABLE (review #16 #2, leak-and- + # continue): null the globals, do NOT arm a retry, retain no thread. + # A later initialize() builds a fresh isolate. Mirrors the bootstrap + # double-failure policy in _acquire_isolate. + _lib = _lib_path = _isolate = None + _teardown_needed = False + print( + "DataWeave: GraalVM isolate teardown failed and the teardown " + "worker could not be detached; the isolate is unrecoverable and " + "is leaked (a later initialize() will build a fresh one).", + file=sys.stderr, + ) + raise + # Detach succeeded: keep the isolate live, arm a retry, do NOT null the + # globals (nulling would let the next initialize() build a second, racing + # isolate). On the SUCCESS path below the worker is intentionally left + # undetached -- after _tear_down returns the isolate and worker are gone. _teardown_needed = True print( "DataWeave: GraalVM isolate teardown failed; the isolate is " diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 7693c437..5587b686 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -1109,3 +1109,72 @@ def call_initialize(): runtime.cleanup() assert native._isolate_ref_count == 0 + + +@pytest.mark.unit +def test_release_teardown_and_detach_both_failing_leaks_isolate_without_arming_retry(monkeypatch): + """review #16 #2 (leak-and-continue): if the last-release teardown fails AND + detaching the just-attached worker also fails, re-arming a retry would attach + yet another worker on top of the stuck one and permanently block teardown + (which needs the SOLE attached thread). So the isolate is treated as + UNRECOVERABLE: globals nulled, NO retry armed, no thread retained -- a later + initialize() builds a fresh isolate. Mirrors the bootstrap double-failure policy.""" + monkeypatch.setattr(native, "_teardown_needed", False) # restored after the test regardless + library = FakeLibrary() # bootstrap detach succeeds (default) so acquire publishes cleanly + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + lib, isolate = native._acquire_isolate("/tmp/dwlib") + + # Now make the last-release teardown fail AND the subsequent detach fail. + library.graal_tear_down_isolate = CallableFunction(lambda _thread: 1) + library.graal_detach_thread = CallableFunction(lambda _thread: 1) # nonzero == failed detach + + with pytest.raises(native.DataWeaveError): + native._release_isolate() # last release -> teardown fails -> detach fails -> leak + + # Leaked: globals nulled, NO retry armed (the crux of #16 #2), refcount at 0. + assert native._isolate is None + assert native._lib is None + assert native._isolate_ref_count == 0 + assert native._teardown_needed is False + + # The module is NOT wedged: a healthy library builds a fresh isolate. + healthy = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: healthy) + lib2, isolate2 = native._acquire_isolate("/tmp/dwlib") + assert lib2 is healthy + assert native._isolate is not None + native._release_isolate() # clean up the fresh isolate + + +@pytest.mark.unit +def test_retry_teardown_and_detach_both_failing_leaks_isolate_without_arming_retry(monkeypatch): + """review #16 #2: the retry path (_retry_pending_teardown_locked) attaches a + fresh worker. If its teardown fails AND detaching that worker fails, arming + another retry would stack a second stuck worker -> permanent block. So it + leaks-and-continues: globals nulled, retry disarmed, no thread retained.""" + monkeypatch.setattr(native, "_teardown_needed", False) # restored after the test regardless + library = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + + # Acquire cleanly, then arm a pending teardown by hand and point the retry at + # a library whose teardown and detach both fail. + lib, isolate = native._acquire_isolate("/tmp/dwlib") + monkeypatch.setattr(native, "_teardown_needed", True) + monkeypatch.setattr(native, "_isolate_ref_count", 0) + library.graal_tear_down_isolate = CallableFunction(lambda _thread: 1) + library.graal_detach_thread = CallableFunction(lambda _thread: 1) + + # Next acquire runs _retry_pending_teardown_locked, which double-fails. + with pytest.raises(native.DataWeaveError): + native._acquire_isolate("/tmp/dwlib") + + assert native._isolate is None + assert native._lib is None + assert native._teardown_needed is False + + healthy = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: healthy) + lib2, isolate2 = native._acquire_isolate("/tmp/dwlib") + assert native._isolate is not None + native._release_isolate() From 357e0576c0dbb108265e008523cd1e87824ac351 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 14:39:04 -0300 Subject: [PATCH 194/216] docs(spec): redefine the refcount invariant; document the zero-count leak window (review #16 #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec claimed the isolate exists iff the refcount > 0, but both bindings retain a live isolate at zero refs after a failed teardown, and Python leaks one at zero refs on a bootstrap or (review #16 #2) release double failure. Redefine the count as outstanding ownership/init references: positive requires a live isolate; zero may temporarily retain one pending retry or leave one leaked after the unrecoverable path. Document the new release double-failure leak in §7.2 and §10. Verified the public READMEs and test_ci_structure.py never made the iff claim (no change needed). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-08-07-native-lib-multi-engine-design.md | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) 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 index 421dffa5..dddc443b 100644 --- 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 @@ -409,8 +409,12 @@ drain-before-teardown. The **public Python API is unchanged** by the unification Module-level state in `native.py`, all mutations under one module lock (`_isolate_lock`): `_lib`, `_lib_path`, `_isolate` (the single process-wide isolate, or None), `_isolate_ref_count`. -> **Invariant:** `_isolate_ref_count` == number of live engines across all `DataWeave` instances, -> and the isolate exists iff the count > 0. +> **Invariant:** `_isolate_ref_count` == the number of outstanding ownership/init references +> (one per live engine across all `DataWeave` instances). A **positive** count requires a live +> isolate. A **zero** count normally means no isolate, but may temporarily retain a live one +> pending a teardown retry (`_teardown_needed`), or leave one leaked for the process lifetime +> after an unrecoverable teardown path (see §7.2, §10). The count is thus proof of outstanding +> ownership, not proof of physical isolate existence. Each `DataWeave` instance owns exactly one engine handle and contributes exactly one to the refcount. The module lock guards only isolate refcount/create/teardown; it is **not** held during @@ -443,7 +447,11 @@ attachment**, mirroring the Node and Go bindings: teardown before deciding whether to create a new isolate; a repeated failure re-arms the flag and raises rather than proceeding. This mirrors Node's `g_teardown_needed` retryable-teardown model (§6.2) — the two bindings now share one failure-recovery contract instead of Python's previous - unconditional-null behavior. + unconditional-null behavior. If teardown fails **and** the just-attached worker cannot be detached + (`graal_detach_thread` returns nonzero), a retry would stack a second worker on the stuck one + and block teardown forever, so the isolate is instead treated as **unrecoverable**: the globals + are nulled, `_teardown_needed` is left unset, and the isolate leaks until process exit — the same + leak-and-continue policy as the bootstrap double failure (§10). Because nothing stays attached between calls, teardown never blocks on a phantom attachment regardless of which OS thread performs the last release. @@ -625,7 +633,11 @@ dwB.cleanup() → join workers; destroy_engine(handleB); ref 1→0 → attach f `initialize()` (on any OS thread) retries by attaching a **fresh** worker on the current thread and tearing down; on success it clears the flag and nulls the globals. GraalVM `IsolateThread` handles are OS-thread-affine, so the retry - never reuses a thread attached on another OS thread — it always attaches its own. + never reuses a thread attached on another OS thread — it always attaches its own. If that + retry's teardown fails **and** its worker cannot be detached (`graal_detach_thread` returns + nonzero), the same holds for the initial release: a further retry would stack a second stuck + worker, so the isolate is treated as **unrecoverable** — globals nulled, no retry armed, isolate + leaked until process exit (mirroring the bootstrap double-failure policy below). - **Bootstrap-thread double failure (Python, `_acquire_isolate`):** if the just- created isolate's bootstrap thread can be neither detached **nor** used to tear the isolate down, only the creating OS thread could ever tear it down (teardown @@ -685,8 +697,9 @@ These invariants are the shared artifact both `native-lib/node/src/addon.c` and uphold all six: 1. One process-wide isolate; engines are handle-addressed objects in the Java registry. -2. The isolate is reference-counted; the refcount equals the number of live engines; the isolate - exists iff the refcount > 0. +2. The isolate is reference-counted by outstanding ownership/init references (one per live engine). + A positive refcount requires a live isolate; a zero refcount may temporarily retain a live + isolate pending a teardown retry, or leave one leaked after an unrecoverable teardown path. 3. Create-on-first-ref, tear-down-on-last-release; the binding calls `graal_create_isolate` / `graal_tear_down_isolate` from *outside* the isolate, and holds no thread persistently attached across calls (so teardown never blocks on a phantom attachment). From 1b0162f1d53fd89f66c02b751c9690294b77f64a Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 14:46:26 -0300 Subject: [PATCH 195/216] docs(python): correct the stale refcount/isolate invariant in native.py's header (review #16 #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding #16 #3's remediation redefined the "isolate exists iff refcount > 0" invariant in the design spec, READMEs, and test_ci_structure but missed the source-of-truth file's own header comment, which still asserted "_isolate is not None iff the count > 0". That contradicts the retention/leak behavior implemented in the same file (a detach-succeeds teardown failure leaves _isolate non-None at refcount 0 with _teardown_needed armed; a double failure leaks it). Align the comment with §7.1: the count is outstanding ownership/init references -- positive requires a live isolate; zero may temporarily retain one pending retry or leak one after an unrecoverable teardown. Comment-only; unit suite unchanged (117 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/python/src/dataweave/native.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index 2ce0fbbc..afacd88d 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -28,8 +28,14 @@ class graal_isolatethread_t(ctypes.Structure): # ── Process-wide shared isolate (one per process, N handle-addressed engines) ── # All mutations happen under _isolate_lock. Invariant: _isolate_ref_count equals -# the number of live engines across all DataWeave instances, and _isolate is not -# None iff the count > 0. +# the number of outstanding ownership/init references (one per live engine across +# all DataWeave instances). A positive count requires a live _isolate; a zero +# count normally means _isolate is None, but may temporarily retain a live one +# pending a teardown retry (_teardown_needed, see _release_isolate / +# _retry_pending_teardown_locked), or leak one for the process lifetime after an +# unrecoverable teardown path (double detach+teardown failure, or the bootstrap +# double failure in _acquire_isolate). The count is proof of outstanding +# ownership, not proof of physical isolate existence. _isolate_lock = Lock() _lib = None _lib_path = None From 890db7097eeae87296b24741e49e7556df785b17 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 16:10:04 -0300 Subject: [PATCH 196/216] fix(node): leak-and-continue on the teardown-plus-detach double failure (review #17 #1) cleanup_thread_fn and teardown_waiter_thread_fn ignored fn_detach_thread's return after a failed graal_tear_down_isolate. On a double failure the exiting worker stayed attached while g_teardown_needed was armed, so future retries attached more workers and teardown became permanently impossible. Add a third teardown outcome (CLEANUP_UNRECOVERABLE) threaded through the shared helper, its four synchronous callers, and the async waiter. On the double failure, abandon_unrecoverable_isolate_locked() clears the published globals so a later initialize() builds a fresh isolate, does NOT arm the retry, emits a stderr diagnostic, and leaks the old isolate for the process lifetime -- the Node twin of the Python policy shipped in review #16 #2. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 212 +++++++++++++++++++++++------------- 1 file changed, 139 insertions(+), 73 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 12e950a3..45766d7c 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -197,6 +197,26 @@ typedef enum { TEARDOWN_PENDING_WAIT, TEARDOWN_TEARING_DOWN, } teardown_state_t; +// Outcome of an attempted reached-zero isolate teardown, reported by +// cleanup_thread_fn to its synchronous callers and computed inline by +// teardown_waiter_thread_fn. Three-way (review #17 #1) so the callers can +// distinguish the unrecoverable double failure from an ordinary retryable one: +// CLEANUP_TORN_DOWN -- isolate destroyed (or nothing to tear down): +// clear g_thread/g_isolate/g_initialized/g_ref_count. +// CLEANUP_RETAIN -- teardown could not run or failed but the worker +// detached cleanly (or the spawn never happened): the +// isolate is still live AND reachable -- retain the +// globals and arm the retry (g_teardown_needed). +// CLEANUP_UNRECOVERABLE -- graal_tear_down_isolate AND the follow-up detach +// BOTH failed: an exiting worker is stuck attached, so +// this isolate can never be torn down. Leak it -- see +// abandon_unrecoverable_isolate_locked(). Mirrors +// Python native.py's double-failure leak-and-continue. +typedef enum { + CLEANUP_TORN_DOWN = 0, + CLEANUP_RETAIN, + CLEANUP_UNRECOVERABLE, +} cleanup_result_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. @@ -213,6 +233,29 @@ static bool g_teardown_cancelled = false; static bool g_teardown_needed = false; static uv_cond_t g_teardown_cond; +// teardown+detach double failure (review #17 #1): an exiting worker is stuck +// attached to this isolate, so graal_tear_down_isolate can never again get the +// sole-attached, current-OS-thread IsolateThread it requires -- retrying is +// futile and would only attach MORE stuck workers. Abandon the isolate: clear +// the PUBLISHED globals so the next initialize() builds a FRESH isolate (GraalVM +// allows multiple isolates per process; the stuck worker is bound to the OLD, +// leaked isolate and never impedes the new one), do NOT arm g_teardown_needed, +// and leak the old isolate for the process lifetime. Emit a diagnostic so the +// leak is observable. Mirrors Python native.py's leak-and-continue +// (_release_isolate / _retry_pending_teardown_locked). Caller holds g_mutex. +static void abandon_unrecoverable_isolate_locked(void) { + g_thread = NULL; + g_isolate = NULL; + g_initialized = 0; + g_ref_count = 0; + g_teardown_needed = false; + fprintf(stderr, + "[DataWeave Node addon] GraalVM isolate teardown AND worker detach both " + "failed; the isolate can never be torn down and is being leaked for the " + "process lifetime. Binding state was reset so a later initialize() " + "builds a fresh isolate.\n"); +} + // 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 @@ -767,6 +810,7 @@ static bool env_init_acquire_and_hook(napi_env env) { // Forward declaration: tears down g_isolate on a dedicated attached thread. // Defined below; used here (napi_initialize's create-path acquire-failure // recovery) and further down by isolate_ref_release_n_locked. +// arg is a cleanup_result_t* out-param (review #17 #1); see the definition. static void cleanup_thread_fn(void* arg); // Forward declaration: retries a stranded teardown (round-14 #2/#3). Defined @@ -937,17 +981,24 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { uv_thread_options_t cleanup_opts; cleanup_opts.flags = UV_THREAD_HAS_STACK_SIZE; cleanup_opts.stack_size = 2 * 1024 * 1024; - int torn_down = 0; - int cleanup_spawn_rc = uv_thread_create_ex(&cleanup_tid, &cleanup_opts, cleanup_thread_fn, &torn_down); + cleanup_result_t result = CLEANUP_RETAIN; + int cleanup_spawn_rc = uv_thread_create_ex(&cleanup_tid, &cleanup_opts, cleanup_thread_fn, &result); if (cleanup_spawn_rc == 0) { uv_thread_join(&cleanup_tid); } - if (torn_down) { + if (result == CLEANUP_TORN_DOWN) { // Teardown ran (or there was nothing to tear down) -- clear the globals // so the next initialize() sees a clean slate. g_ref_count is already 0. g_thread = NULL; g_isolate = NULL; g_initialized = 0; + } else if (result == CLEANUP_UNRECOVERABLE) { + // teardown+detach double failure (review #17 #1): abandon the isolate and + // reset published state so this same initialize() failure path throws + // below and a LATER initialize() builds a fresh isolate. Does NOT arm the + // retry. g_ref_count is already 0, so the helper's g_ref_count = 0 is a + // no-op and the invariant g_ref_count == sum(init_refs) still holds. + abandon_unrecoverable_isolate_locked(); } else { // Spawn failed, or cleanup_thread_fn's attach/teardown to the isolate // failed. The isolate is genuinely still alive with g_initialized == 0. @@ -2599,15 +2650,16 @@ 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). +// `arg` is a cleanup_result_t* out-param: the caller must set it to +// CLEANUP_RETAIN before spawning this thread (so a spawn that never runs, or the +// attach-failure early return, leaves the live isolate retained) and read it +// after uv_thread_join returns. Mirrors teardown_waiter_thread_fn's outcome +// exactly, so the caller can distinguish "isolate torn down / nothing to tear +// down" (clear g_thread/g_isolate/g_initialized/g_ref_count) from "attach or +// teardown failed but the isolate is still reachable" (retain + arm retry) from +// "teardown AND detach both failed" (unrecoverable -- leak the isolate). static void cleanup_thread_fn(void* arg) { - int* out_torn_down = (int*)arg; + cleanup_result_t* out_result = (cleanup_result_t*)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 @@ -2616,29 +2668,32 @@ static void cleanup_thread_fn(void* arg) { // 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; + *out_result = CLEANUP_TORN_DOWN; 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. + // Attach failed -- the isolate is still alive. Leave *out_result at its + // caller-initialized CLEANUP_RETAIN so the caller does NOT clear g_isolate + // (or it becomes unreachable and can never be torn down) and arms the retry. return; } // Check the teardown return code (0 == success). On nonzero the isolate is - // still live: leave *out_torn_down at 0 so the caller retains - // g_isolate/g_initialized/g_ref_count and (per its own logic) arms the retry, - // rather than orphaning a live isolate (review #6 #3). On that failure the - // isolate was NOT destroyed, so this thread is still attached to it -- detach - // before the helper thread exits, or the live isolate keeps a phantom - // attached thread that can make a later retry teardown block or fail (review - // #7 #1). On success the isolate is gone: do NOT detach (would be a UAF). + // still live and this thread is still attached to it -- detach before exiting + // or the live isolate keeps a phantom attached thread that can block/fail a + // later retry teardown (review #7 #1). On success the isolate is gone: do NOT + // detach (would be a UAF). if (fn_tear_down_isolate(local_thread) == 0) { - *out_torn_down = 1; + *out_result = CLEANUP_TORN_DOWN; + } else if (fn_detach_thread(local_thread) == 0) { + // Teardown failed but the worker detached cleanly: the isolate is live and + // reachable -- retain it and (per the caller's own logic) arm the retry + // (review #6 #3). + *out_result = CLEANUP_RETAIN; } else { - fn_detach_thread(local_thread); - *out_torn_down = 0; + // Teardown AND detach both failed (review #17 #1): the worker is stuck + // attached, so this isolate can never be torn down. Signal leak-and-continue. + *out_result = CLEANUP_UNRECOVERABLE; } } @@ -2663,60 +2718,56 @@ static void teardown_waiter_thread_fn(void* arg) { } 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. Honor the return code (0 == success); a nonzero teardown leaves the - // isolate live (review #6 #3). Skipped entirely - // when an initialize() call adopted the live isolate instead (see - // napi_initialize's TEARDOWN_PENDING_WAIT branch). - bool torn_down = false; + // Perform teardown exactly as the synchronous cleanup_thread_fn path does. + // Honor the return codes (0 == success). Skipped entirely when an initialize() + // call adopted the live isolate instead (see napi_initialize's + // TEARDOWN_PENDING_WAIT branch). + cleanup_result_t result = CLEANUP_RETAIN; 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) { - // Check the teardown return code (0 == success). On nonzero the isolate is - // still live -- leave torn_down false so the post-teardown block below - // retains the isolate globals and arms the retry (review #6 #3). On that - // failure the isolate was NOT destroyed, so this thread is still attached - // to it -- detach before exiting or the live isolate keeps a phantom - // attached thread that can block/fail a later retry teardown (review #7 - // #1). On success the isolate is gone: do NOT detach (would be a UAF). if (fn_tear_down_isolate(local_thread) == 0) { - torn_down = true; + result = CLEANUP_TORN_DOWN; + } else if (fn_detach_thread(local_thread) == 0) { + // Teardown failed, worker detached cleanly: retain + arm below (review #6 #3). + result = CLEANUP_RETAIN; } else { - fn_detach_thread(local_thread); - torn_down = false; + // Teardown AND detach both failed (review #17 #1): leak-and-continue below. + result = CLEANUP_UNRECOVERABLE; } } - // else: attach failed -- the isolate is still alive. Do NOT clear g_isolate, - // or it becomes unreachable and can never be torn down. + // else: attach failed -- isolate still alive and reachable; leave + // result == CLEANUP_RETAIN so the post block retains + arms the retry. } else if (!cancelled) { // Nothing to tear down (no isolate / FFI unavailable) -- safe to clear. - torn_down = true; + result = CLEANUP_TORN_DOWN; } - // if (cancelled): leave torn_down = false -- the isolate stays live for the - // adopter; we tear nothing down. + // if (cancelled): leave result == CLEANUP_RETAIN -- the isolate stays live for + // the adopter; we tear nothing down and the post block's !cancelled guards skip + // every branch, leaving the adopter's state untouched. uv_mutex_lock(&g_mutex); - if (!cancelled && torn_down) { + if (!cancelled && result == CLEANUP_TORN_DOWN) { g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; + } else if (!cancelled && result == CLEANUP_UNRECOVERABLE) { + // teardown+detach double failure on the deferred path (review #17 #1): + // abandon + leak the isolate; do NOT arm the retry. The deferred cleanup() + // promise still RESOLVES below (deliberate, exactly as the retain branch + // does). The helper emits its own stderr diagnostic. Mirrors Python + // native.py leak-and-continue. + abandon_unrecoverable_isolate_locked(); } else if (!cancelled && g_isolate != NULL && g_ref_count == 0) { // Teardown did not happen (attach failed, or graal_tear_down_isolate - // returned nonzero -- review #6 #3) and this async-waiter path IS the last - // release: g_ref_count is already 0 with no owner and no pending waiter. - // Arm the retry signal so a later op-completion drain or a fresh - // initialize() retries teardown -- otherwise the live isolate is stranded - // with nothing to reclaim it (review #6 #4). Mirrors the twin arm in - // isolate_ref_release_n_locked's waiter-spawn-failure path. + // returned nonzero with a clean detach -- review #6 #3) and this async-waiter + // path IS the last release: arm the retry signal so a later drain or a fresh + // initialize() retries teardown (review #6 #4). g_teardown_needed = true; // Observable failure (review #10 #5): the deferred cleanup() promise is still - // RESOLVED below (via call_js_teardown_done -- deliberate, exactly as the - // synchronous Case 4 path resolves on failure), so emit a diagnostic or a - // failed async teardown would be silent. Parity with Python's _release_isolate - // stderr notice (native.py). + // RESOLVED below, so emit a diagnostic or a failed async teardown would be + // silent. Parity with Python's _release_isolate stderr notice (native.py). fprintf(stderr, "[DataWeave Node addon] GraalVM isolate teardown failed on deferred " "cleanup(); the isolate is retained and teardown will be retried on the " @@ -2843,18 +2894,22 @@ static void retry_stranded_teardown_locked(void) { uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - int torn_down = 0; - int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, &torn_down); + cleanup_result_t result = CLEANUP_RETAIN; + int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, &result); if (spawn_rc == 0) uv_thread_join(&tid); - if (torn_down) { + if (result == CLEANUP_TORN_DOWN) { g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; g_teardown_needed = false; + } else if (result == CLEANUP_UNRECOVERABLE) { + // teardown+detach double failure (review #17 #1): abandon + leak; the helper + // also clears g_teardown_needed so this stranded-teardown retry stops. + abandon_unrecoverable_isolate_locked(); } - // else: spawn/attach failed again -- leave g_teardown_needed set so the next - // drain (or a later initialize() adoption) retries. + // else (CLEANUP_RETAIN): spawn/attach failed again -- leave g_teardown_needed + // set so the next drain (or a later initialize() adoption) retries. } static void isolate_ref_release_n_locked(int n) { @@ -2868,16 +2923,20 @@ static void isolate_ref_release_n_locked(int n) { uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - int torn_down = 0; - int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, &torn_down); + cleanup_result_t result = CLEANUP_RETAIN; + int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, &result); if (spawn_rc == 0) { uv_thread_join(&tid); } - if (torn_down) { + if (result == CLEANUP_TORN_DOWN) { g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; + } else if (result == CLEANUP_UNRECOVERABLE) { + // teardown+detach double failure (review #17 #1): abandon + leak the + // isolate; do NOT arm the retry. Mirrors Python native.py leak-and-continue. + abandon_unrecoverable_isolate_locked(); } else if (g_isolate != NULL && g_ref_count == 0) { // Sync teardown failed (spawn or cleanup_thread_fn attach) with the isolate // still live and no owners: arm the retry signal (round-14 #3). g_active_ops @@ -3032,13 +3091,14 @@ static napi_value release_isolate_ref_locked(napi_env env) { uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - // 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. + // result is cleanup_thread_fn's out-param (mirrors teardown_waiter_thread_fn's + // outcome exactly): must be initialized to CLEANUP_RETAIN before the thread runs + // so a spawn that never happens, or the attach-failure early-return path (which + // never touches it), leaves the live isolate retained + the retry armed. // 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); + cleanup_result_t result = CLEANUP_RETAIN; + int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, &result); if (spawn_rc == 0) { uv_thread_join(&tid); } @@ -3053,11 +3113,17 @@ static napi_value release_isolate_ref_locked(napi_env env) { // 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) { + if (result == CLEANUP_TORN_DOWN) { g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; + } else if (result == CLEANUP_UNRECOVERABLE) { + // teardown+detach double failure (review #17 #1): abandon + leak the + // isolate; the promise below still RESOLVES (deliberate, per the note that + // follows). The helper emits its own stderr diagnostic. Mirrors Python + // native.py leak-and-continue. + abandon_unrecoverable_isolate_locked(); } else if (g_isolate != NULL && g_ref_count == 0) { // cleanup_thread_fn spawn/attach failed: the isolate is still live with // zero owners. Arm the retry signal so a later op-completion drain or the From dcff255fdf56a1f8195b0ea676613bd2415525f8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 16:15:24 -0300 Subject: [PATCH 197/216] docs(node): refresh a stale torn_down comment left after the CLEANUP_RETAIN rename (review #17 #1) The C4 comment still said "torn_down stays 0" after the local was renamed to `result` initialized to CLEANUP_RETAIN. Comment-only; no code change. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 45766d7c..7505aca6 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -3104,7 +3104,7 @@ static napi_value release_isolate_ref_locked(napi_env env) { } // 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 + // result stays CLEANUP_RETAIN -- 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 From b6ef969f36fa2fad759b9cef203a6bcdc234a040 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 16:19:10 -0300 Subject: [PATCH 198/216] docs(spec): correct the over-claim that Python needs none of Node's retry machinery (review #17 #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5 and §7 intro claimed Python needs "none of Node's retry machinery," contradicting §7.1/§7.2 and shipped native.py, which implement _teardown_needed + a synchronous teardown retry. Clarify: Python needs none of Node's ASYNCHRONOUS waiter/PENDING_WAIT/ adoption machinery, but does implement a simpler SYNCHRONOUS retry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-08-07-native-lib-multi-engine-design.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index dddc443b..13ed67c6 100644 --- 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 @@ -116,8 +116,8 @@ no change to isolate lifecycle management for the *feature*. Node requires the c reference-and-teardown coordination in §6 because the isolate is shared by independently created and destroyed engines across threads. **Python can adopt the same model trivially**: its ctypes calls are synchronous and it owns its stream-worker threads directly, so it needs none of Node's -`PENDING_WAIT`/adoption/retry machinery — just a reference count and a synchronous -drain-before-teardown (§7). +*asynchronous* `PENDING_WAIT`/waiter-thread/adoption machinery — a reference count, a +synchronous drain-before-teardown, and a simpler *synchronous* teardown retry suffice (§7). **Accepted trade-off (Python).** Python instances in one process now share one isolate's heap instead of having separate heaps. This is weaker memory isolation, relevant only if @@ -401,8 +401,10 @@ because a boolean cannot represent the window during which `cleanup()` has start Python drives the **same** shared Java engine layer and the **same** `*_engine` C ABI as Node, but its isolate/thread glue (`native-lib/python/src/dataweave/native.py`) is much simpler than §6: ctypes calls are synchronous and Python owns its stream-worker threads directly, so it needs none -of Node's `PENDING_WAIT`/adoption/retry machinery — just a reference count and a synchronous -drain-before-teardown. The **public Python API is unchanged** by the unification. +of Node's *asynchronous* `PENDING_WAIT`/waiter-thread/adoption machinery. It still needs a +reference count, a synchronous drain-before-teardown, and a simpler *synchronous* teardown retry +(`_teardown_needed`, retried on the next `initialize()` — see §7.2). The **public Python API is +unchanged** by the unification. ### 7.1 Shared state and the reference-count invariant From 696b8be6177893ddd7db376f50e4a1778a29c6a8 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 16:19:44 -0300 Subject: [PATCH 199/216] docs(native-lib): document the unrecoverable teardown-plus-detach leak contract (review #17 #3) Both READMEs described teardown failure as flatly retryable, omitting the intentional unrecoverable double-failure branch (teardown+detach, or the bootstrap double failure) that resets published state, leaks the isolate for the process lifetime, and lets a future initialization build a fresh one. Document both contracts. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/README.md | 10 ++++++++-- native-lib/python/README.md | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/native-lib/README.md b/native-lib/README.md index ccc090a9..f8049a7e 100644 --- a/native-lib/README.md +++ b/native-lib/README.md @@ -56,8 +56,14 @@ policy on top of the raw ABI: each maintains a single process-wide GraalVM isolate, reference-counted by the number of live engines across all instances. The isolate is created and attached on first use and torn down via `graal_tear_down_isolate` only after the final engine in the process has been -released (with a retryable-teardown fallback if teardown fails). This -ref-counting lives in the binding code, not in the dwlib engine ABI. +released. Teardown failure has two contracts: an **ordinary** failure (teardown +fails but the worker thread detaches cleanly) retains the live isolate and +retries teardown on the next initialization or engine release; a +**teardown-plus-detach double failure** (or a bootstrap detach-plus-teardown +double failure) is treated as **unrecoverable** — the binding resets its +published state, deliberately leaks the isolate for the process lifetime, and +lets a future initialization build a fresh isolate. This ref-counting and +teardown policy lives in the binding code, not in the dwlib engine ABI. ## Building with Gradle diff --git a/native-lib/python/README.md b/native-lib/python/README.md index 973eb866..3e776559 100644 --- a/native-lib/python/README.md +++ b/native-lib/python/README.md @@ -206,8 +206,13 @@ the exception type, message, and traceback. There is a single process-wide GraalVM isolate, reference-counted by the number of live engines across all `DataWeave` instances; it is created on the -first engine and torn down when the last one is released (with a retryable -teardown fallback if that final teardown fails). Each `DataWeave` instance +first engine and torn down when the last one is released. If that final +teardown fails but the worker thread detaches cleanly, the live isolate is +retained and teardown is retried on the next initialization; if teardown and +detachment both fail (or the bootstrap attach path hits a detach-plus-teardown +double failure), the isolate is unrecoverable — the binding clears its module +state, leaks the isolate for the process lifetime, and a later initialization +builds a fresh one. Each `DataWeave` instance owns its own handle-addressed engine within that shared isolate. Initialization binds the resolver to that engine — `DataWeave.initialize()` creates the engine via `create_engine_with_resolver`, so the resolver is From 26679e1c12e7c21c116dc075f62fabf37b25f18e Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 16:20:11 -0300 Subject: [PATCH 200/216] docs(resolver): mark the stale Python resolver design superseded (review #17 #4) The 2026-08-24 Python-only resolver design predates the handle-based shared-isolate model. Its run_script_with_resolver ABI, "no Java/Node changes," dedicated-isolate, and resolve-on-first-run claims are all stale. Add a superseded banner pointing at 2026-08-07-native-lib-multi-engine-design.md and listing the invalidated assertions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-08-24-python-module-resolver-design.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/superpowers/specs/2026-08-24-python-module-resolver-design.md b/docs/superpowers/specs/2026-08-24-python-module-resolver-design.md index b8622785..09ef6f0e 100644 --- a/docs/superpowers/specs/2026-08-24-python-module-resolver-design.md +++ b/docs/superpowers/specs/2026-08-24-python-module-resolver-design.md @@ -4,6 +4,21 @@ **Module:** `native-lib` Python binding **Related implementation:** Node external-module support in PR #154 +> **⚠️ Superseded (2026-08-31).** This document describes an early Python-only +> resolver design that PR #157 did not implement. It is retained for historical +> context only. The shipped design is +> [`2026-08-07-native-lib-multi-engine-design.md`](2026-08-07-native-lib-multi-engine-design.md). +> The following assertions below are stale and no longer accurate: +> - `dwlib` no longer exports `run_script_with_resolver`; resolver-backed +> execution goes through the handle-based `create_engine_with_resolver` + +> `run_script_engine` ABI. +> - The feature *did* require Java and native-image changes (the shared engine +> layer), not "no Java/Node changes." +> - There are no dedicated per-instance Python isolates; all `DataWeave` +> instances share one process-wide isolate, addressed by opaque handles. +> - The resolver is bound at `DataWeave.initialize()` (via +> `create_engine_with_resolver`), not installed on the first `run()`. + ## Problem The Python binding cannot resolve reusable DataWeave modules supplied by an From c1caecbcca0efbfcbc694c089faec88ebc3837b3 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 16:30:07 -0300 Subject: [PATCH 201/216] =?UTF-8?q?docs(spec):=20document=20the=20Node=20u?= =?UTF-8?q?nrecoverable=20teardown-leak=20branch=20in=20=C2=A76=20(review?= =?UTF-8?q?=20#17=20final)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6.1/§6.2 (Node) described only the two-outcome teardown model and claimed the retry signal is armed on any teardown failure, contradicting the new Node leak-and-continue on the teardown-plus-detach double failure shipped in 9efb0d1. Add the unrecoverable-leak branch to §6.2 and the zero-count leak clause to the §6.1 invariant, mirroring the Python §7.1/§7.2/§10 treatment already present. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-08-07-native-lib-multi-engine-design.md | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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 index 13ed67c6..791f7f58 100644 --- 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 @@ -143,7 +143,12 @@ The isolate lives while any env holds an init reference. The governing invariant > **`g_ref_count` == Σ `init_refs` over all live per-env records.** -`g_ref_count` is a derived total, not a bare global that any code path may drive to zero. +`g_ref_count` is a derived total, not a bare global that any code path may drive to zero. A +**positive** count requires a live isolate; a **zero** count normally means no isolate, but may +temporarily retain a live one pending a teardown retry (`g_teardown_needed`, §6.2) or leave one +leaked for the process lifetime after an unrecoverable teardown path (§6.2's teardown-plus-detach +double failure). The count is thus proof of outstanding ownership, not proof of physical isolate +existence — mirroring the Python invariant in §7.1. Reference accounting is **per `napi_env`**, tracked in a `g_mutex`-guarded linked list of `env_init_rec_t { napi_env env; int init_refs; next; }`: @@ -228,6 +233,20 @@ accepted residual: if teardown fails *and* no later `initialize()` or op ever oc lingers until process exit — benign (one process-lifetime isolate, no invariant violation), the deliberate tradeoff for not adding event-loop-affine async retry infrastructure to this code. +**Unrecoverable teardown-plus-detach double failure.** The retry signal above covers the *ordinary* +failure where `graal_tear_down_isolate` fails but the helper's follow-up `fn_detach_thread` +succeeds — the isolate is left live and reachable, so arming the retry is safe. If that **detach +itself also fails** (a teardown-plus-detach *double* failure), the exiting worker stays stuck-attached +and the isolate can never again obtain the sole-attached, current-OS-thread IsolateThread teardown +requires — retrying is futile and would only attach *more* stuck workers. So instead of arming the +retry the binding treats the isolate as **unrecoverable**: it clears the published globals +(`g_isolate`/`g_thread`/`g_initialized`/`g_ref_count`), does *not* arm `g_teardown_needed`, emits a +stderr diagnostic, and deliberately **leaks** the old isolate for the process lifetime, letting the +next `initialize()` build a fresh one (GraalVM allows multiple isolates per process; the stuck +worker is bound to the leaked isolate and never impedes the new one). `napi_initialize`'s own +build-then-teardown failure path applies the same leak-and-continue. This is the Node twin +(review #17 #1) of the Python policy in §7.2 / §10. + ### 6.3 Per-engine records, admission pinning, and deferred destroy Every engine — resolver-backed **and** resolver-less — gets a per-engine record From b06381102f85d1955b38721bc7ca0d13c5970680 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 16:56:50 -0300 Subject: [PATCH 202/216] docs(native-lib): make the ordinary-teardown-failure retry trigger binding-specific (review #18 #1) The shared root README said ordinary failures retry "on the next initialization or engine release" -- inaccurate for both bindings. Split by binding: Node retries at the next initialization or async op-completion drain; Python retries synchronously at the next initialization. Also attribute the bootstrap double-failure to Python only (Node has no bootstrap-detach retry path). Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/native-lib/README.md b/native-lib/README.md index f8049a7e..57d3b9f0 100644 --- a/native-lib/README.md +++ b/native-lib/README.md @@ -56,14 +56,17 @@ policy on top of the raw ABI: each maintains a single process-wide GraalVM isolate, reference-counted by the number of live engines across all instances. The isolate is created and attached on first use and torn down via `graal_tear_down_isolate` only after the final engine in the process has been -released. Teardown failure has two contracts: an **ordinary** failure (teardown +released. Teardown failure has two contracts. An **ordinary** failure (teardown fails but the worker thread detaches cleanly) retains the live isolate and -retries teardown on the next initialization or engine release; a -**teardown-plus-detach double failure** (or a bootstrap detach-plus-teardown -double failure) is treated as **unrecoverable** — the binding resets its -published state, deliberately leaks the isolate for the process lifetime, and -lets a future initialization build a fresh isolate. This ref-counting and -teardown policy lives in the binding code, not in the dwlib engine ABI. +retries teardown later, with a binding-specific trigger: **Node** retries at the +next initialization or when an in-flight operation finishes draining (async +op-completion); **Python** retries synchronously at the next initialization. A +**teardown-plus-detach double failure** (in Python, also a bootstrap +detach-plus-teardown double failure) is treated as **unrecoverable** — the +binding resets its published state, emits a diagnostic, deliberately leaks the +isolate for the process lifetime, and lets a future initialization build a fresh +isolate. This ref-counting and teardown policy lives in the binding code, not in +the dwlib engine ABI. ## Building with Gradle From c8dff29250643a371e26e17750733156db66973c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 16:56:50 -0300 Subject: [PATCH 203/216] docs(node): clarify cleanup() guarantees the teardown attempt, not physical reclamation (review #18 #2) The Node package README said a final-reference cleanup() resolves once teardown "has actually finished." Both failure paths still resolve the promise (ordinary retryable failure, or unrecoverable double-failure leak). Document that cleanup() guarantees logical release and completion of the teardown attempt (after draining in-flight ops), not necessarily physical reclamation; ordinary failures retry where safe, double failures intentionally leak with a diagnostic. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/native-lib/node/README.md b/native-lib/node/README.md index f2ba9035..75d0090f 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -223,7 +223,7 @@ for await (const chunk of generator) { #### `cleanup(): Promise` -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()` and uncaught exceptions — cases where `beforeExit` never fires — and cannot await the drain. Neither hook fires on `SIGTERM`, `SIGINT`, or `SIGKILL` (Node does not emit `exit` for signals), so install your own signal handler that calls `cleanup()` if you need a graceful drain on termination. Called manually, it releases this instance's reference to the native runtime; the shared native isolate is torn down only when the **last** initialized instance in the process is released. When this call releases that final reference, it resolves once native teardown has actually finished, waiting for any still-in-flight streaming/transform operation to drain first; otherwise (other instances remain initialized) it resolves as soon as this instance is released, without draining process-wide work. +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()` and uncaught exceptions — cases where `beforeExit` never fires — and cannot await the drain. Neither hook fires on `SIGTERM`, `SIGINT`, or `SIGKILL` (Node does not emit `exit` for signals), so install your own signal handler that calls `cleanup()` if you need a graceful drain on termination. Called manually, it releases this instance's reference to the native runtime; the shared native isolate is torn down only when the **last** initialized instance in the process is released. When this call releases that final reference, it first drains any still-in-flight streaming/transform operation, then attempts isolate teardown and resolves once that attempt completes. The promise thus guarantees **logical release** and that teardown was attempted — not necessarily physical reclamation of the isolate: an ordinary teardown failure retains the live isolate and is retried where safe (at a later initialization or async op-completion drain), and an unrecoverable teardown-plus-detach double failure intentionally leaks the isolate until process exit, with a diagnostic on stderr. Otherwise (other instances remain initialized) it resolves as soon as this instance is released, without draining process-wide work. ```javascript import { cleanup } from 'dataweave-native'; @@ -252,7 +252,7 @@ try { **Methods:** - `initialize()`: Initialize the native library -- `cleanup(): Promise`: Release this instance's native resources. When it releases the last initialized instance in the process, it resolves once the shared isolate has finished tearing down (draining any in-flight streaming/transform op first); otherwise it resolves as soon as this instance is released, leaving the isolate live for other instances. +- `cleanup(): Promise`: Release this instance's native resources. When it releases the last initialized instance in the process, it drains any in-flight streaming/transform op, then resolves once the teardown **attempt** completes — logical release is guaranteed, physical reclamation is not (an ordinary failure retains the isolate and retries where safe; an unrecoverable teardown-plus-detach double failure leaks it until process exit, with a diagnostic). Otherwise it resolves as soon as this instance is released, leaving the isolate live for other instances. - `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 2414471c34d2631063e092a0614b60a36ab084fc Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 17:29:06 -0300 Subject: [PATCH 204/216] docs(native-lib): correct cleanup() to state a teardown attempt, not physical reclamation (review #19 #1 #2) The root README code-example comment, the exported TypeScript cleanup() TSDoc, its coalescing comment, and the unit-test rationale all promised that a final-reference cleanup() resolves only once the isolate 'has actually finished tearing down'. The shipped contract resolves once the teardown ATTEMPT completes: it guarantees logical release, not physical reclamation. An ordinary failure retains the live isolate and retries where safe (later init or async op-completion drain); an unrecoverable teardown-plus-detach double failure leaks the isolate until process exit with a stderr diagnostic. Reworded all four spots to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/README.md | 7 +++-- native-lib/node/src/dataweave.ts | 26 ++++++++++++------- .../tests/unit/dataweave-initialize.test.ts | 4 +-- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/native-lib/README.md b/native-lib/README.md index 57d3b9f0..87cfdc98 100644 --- a/native-lib/README.md +++ b/native-lib/README.md @@ -505,8 +505,11 @@ try { } finally { // cleanup() returns a Promise; await it. When this releases the FINAL shared // native reference in the process, it drains any in-flight streaming/transform - // op and completes isolate teardown before resolving (so a subsequent - // initialize() does not race a still-tearing-down isolate). When other + // op, attempts isolate teardown, and resolves once that attempt completes -- + // guaranteeing logical release, not necessarily physical reclamation: an + // ordinary teardown failure retains the live isolate and retries where safe, + // and an unrecoverable teardown-plus-detach double failure intentionally + // leaks it until process exit (with a diagnostic on stderr). When other // initialized instances remain, it resolves as soon as this instance is // released, leaving the shared isolate live for them. await dw.cleanup(); diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index d6235dde..abff5c09 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -143,14 +143,19 @@ export class DataWeave { * cleanup the instance can be re-initialized via {@link DataWeave.initialize}. * * Resolution depends on whether this call releases the FINAL shared native - * reference in the process. When it does, it 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 at that point, - * 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. When other - * initialized instances remain, it resolves as soon as this instance's engine - * is released, leaving the shared isolate live for them. + * reference in the process. When it does, it first drains any in-flight + * streaming/transform operation on this or any other instance, then attempts + * isolate teardown and resolves once that attempt completes. The promise thus + * guarantees logical release and that teardown was attempted — not + * necessarily physical reclamation of the isolate: an ordinary teardown + * failure retains the live isolate and is retried where safe (at a later + * initialization or async op-completion drain), and an unrecoverable + * teardown-plus-detach double failure intentionally leaks the isolate until + * process exit (with a diagnostic on stderr). Awaiting this rather than + * firing-and-forgetting lets the drain complete before a subsequent + * {@link initialize}. When other initialized instances remain, it resolves as + * soon as this instance's engine is released, leaving the shared isolate live + * for them. */ async cleanup(): Promise { // Coalesce first: doCleanup() flips `state` to "cleaning-up" synchronously @@ -158,8 +163,9 @@ export class DataWeave { // `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 + // this method's contract of resolving only once the in-flight native + // teardown attempt has completed (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. diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 538c472b..8b852bc0 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -162,8 +162,8 @@ describe("DataWeave.initialize() native ref-count safety", () => { // `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 + // contradict cleanup()'s documented contract ("resolves once that + // [teardown] attempt completes") 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 From 15f5302df87b41b8e8d35d1ffcc65b2b8d923b1b Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 17:29:06 -0300 Subject: [PATCH 205/216] docs(spec): mark 2026-08-04 Node external-modules design superseded (review #19 #3) Twin of the Python resolver design doc that review #17 banner-superseded. Flips Status to Superseded and adds a banner linking the shipped 2026-08-07 multi-engine design, naming the three assertions PR #157 made stale: (a) resolver scope -- custom resolvers apply to run() only, not runStreaming/runTransform (built-ins resolve everywhere); (b) resolvers are per-engine and handle-addressed, not one-per-process, with multiple independent engines per process; (c) the old process-wide resolver ABI is replaced by handle-based create_engine_with_resolver + run_script_engine. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-08-04-nodejs-external-modules-design.md | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 index 33529b2a..445e7202 100644 --- a/docs/superpowers/specs/2026-08-04-nodejs-external-modules-design.md +++ b/docs/superpowers/specs/2026-08-04-nodejs-external-modules-design.md @@ -1,9 +1,29 @@ # Design: External DataWeave Module Support in Node.js Binding **Date:** 2026-08-04 -**Status:** Approved for implementation +**Status:** Superseded **Related Proposal:** [docs/proposals/nodejs-external-modules.md](../../proposals/nodejs-external-modules.md) +> **⚠️ Superseded (2026-08-31).** This document describes the original +> single-resolver Node external-module design. It is retained for historical +> context only. The shipped design is +> [`2026-08-07-native-lib-multi-engine-design.md`](2026-08-07-native-lib-multi-engine-design.md). +> The following assertions below are stale and no longer accurate: +> - **Resolver scope.** A custom resolver does *not* apply to all three +> execution APIs. It resolves custom modules only for `run()`; `runStreaming()` +> and `runTransform()` execute on a background worker that must not call back +> into the resolver, so custom-module imports there fail closed (report the +> module as not found) rather than routing to the callback. Built-in modules +> still resolve everywhere. +> - **One resolver per process.** Resolvers are no longer process-global. Each +> `DataWeave` instance owns its own handle-addressed engine with its own +> resolver; multiple independent resolver-backed engines can coexist in one +> process (each bound to the thread that created it). +> - **Resolver ABI and lifecycle.** The old process-wide resolver ABI is +> replaced by the handle-based `create_engine_with_resolver` + `run_script_engine` +> ABI; a resolver is bound at engine creation and released with that engine's +> `cleanup()`, not installed process-wide. + ## Goal Enable Node.js applications to use external DataWeave modules (reusable libraries) that are not compiled into the native image. Scripts can import modules from directories, JAR files, or in-memory maps, composed with fallback chains. From 9064176ee6e502a11be766fb0948639f8afe5aad Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 17:56:58 -0300 Subject: [PATCH 206/216] docs(node): fix stale lifecycle comments and dead report refs (review #20 low-pri) - dataweave.ts: module-level coalescing comment quoted the old 'resolves once native teardown has finished' contract; now 'resolves once the native teardown attempt has completed', matching the instance-level twin corrected in review #19. - dataweave-initialize.test.ts: drop the reference to the absent task-4-report.md planning artifact, and reword comments/test title that named a removed boolean 'initialized' field and the guard 'if (this.initialized) return;' -- the class now uses the string state machine ('uninitialized' | 'ready' | 'cleaning-up'). - engine-handle-contract.test.ts: drop the reference to the absent task-6-report.md planning artifact. Comment/test-name only; no behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/dataweave.ts | 3 ++- .../integration/engine-handle-contract.test.ts | 2 +- .../tests/unit/dataweave-initialize.test.ts | 17 +++++++++-------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index abff5c09..59f3c4da 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -332,7 +332,8 @@ let cleanupStarted = false; // instance-level DataWeave.cleanupPromise. Without it, the second of two // overlapping module cleanup() calls sees globalInstance already nulled and // resolves immediately -- before the first call's native teardown finishes, -// violating cleanup()'s "resolves once native teardown has finished" contract +// violating cleanup()'s "resolves once the native teardown attempt has +// completed" contract // for the last reference. (round 12 #5) let cleanupPromise: Promise | null = null; // The instance that `cleanupPromise` is currently draining. Needed because diff --git a/native-lib/node/tests/integration/engine-handle-contract.test.ts b/native-lib/node/tests/integration/engine-handle-contract.test.ts index e46ac9d9..38869745 100644 --- a/native-lib/node/tests/integration/engine-handle-contract.test.ts +++ b/native-lib/node/tests/integration/engine-handle-contract.test.ts @@ -14,7 +14,7 @@ import { findLibrary, buildInputsJson } from "../../src/utils"; // about -- against handles that were never registered and against handles // that were registered and then destroyed. // -// Confirmed empirically (see task-6-report.md) against the real addon: +// Confirmed empirically against the real addon: // - sync `runScriptEngine` RETURNS the JSON string // `{"success":false,"error":"Unknown engine handle"}` -- it does not throw. // - `runScriptStreamingEngine` / `runScriptTransformEngine` RESOLVE (never diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 8b852bc0..51d199f0 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // 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). +// practical way to assert on it. vi.mock("../../src/ffi", () => ({ initialize: vi.fn(), createEngine: vi.fn(), @@ -41,8 +41,9 @@ describe("DataWeave.initialize() native ref-count safety", () => { 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() -- + // ref count. Since the instance never reached the "ready" state, + // 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); }); @@ -80,8 +81,8 @@ describe("DataWeave.initialize() native ref-count safety", () => { // 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). + // instance permanently "half-initialized" (state stuck "ready" without an + // engine handle, or vice versa). vi.mocked(ffi.cleanup).mockClear(); dw.initialize(); expect(ffi.createEngine).toHaveBeenCalledTimes(2); @@ -105,7 +106,7 @@ describe("DataWeave.initialize() native ref-count safety", () => { expect(ffi.cleanup).toHaveBeenCalledTimes(1); }); - it("still clears `initialized` when ffi.cleanup() rejects, so the instance is re-initializable", async () => { + it("still resets state to \"uninitialized\" 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")); @@ -116,9 +117,9 @@ describe("DataWeave.initialize() native ref-count safety", () => { 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 + // destroyed and nulled -- state must not stay stuck "ready", or a // later initialize() call becomes a permanent no-op (the early-return - // guard `if (this.initialized) return;`) and the instance is stranded + // guard `if (this.state === "ready") return;`) and the instance is stranded // with a null engineHandle. vi.mocked(ffi.initialize).mockClear(); vi.mocked(ffi.createEngine).mockClear(); From ae2d908c82f5b4a1edc86afde54bab78a127b69f Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 18:03:29 -0300 Subject: [PATCH 207/216] fix(node): fail init if bootstrap detach fails instead of publishing a poisoned isolate (review #20 #1) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 7505aca6..0e249365 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -765,8 +765,32 @@ static void init_thread_fn(void* arg) { // never will). Subsequent calls (run/streaming/transform, and cleanup) attach // their own OS thread on demand and detach when done. Mirrors the Go binding, // which likewise detaches the bootstrap thread after graal_create_isolate. - if (fn_detach_thread) { - fn_detach_thread(boot_thread); + // + // If the detach FAILS, boot_thread stays attached while this init OS thread is + // about to be joined and exit -- a phantom attached thread that would wedge a + // later graal_tear_down_isolate() forever (review #20 #1). We must not publish + // such a poisoned isolate. boot_thread is still valid and current here, so use + // it to tear the isolate down immediately and fail initialization. If teardown + // ALSO fails, the isolate can never be reclaimed: leak it, emit the diagnostic, + // and still fail without publishing. Either way we leave g_isolate == NULL so + // the caller's `args->result != 0` path (addon.c ~955) sees the recoverable + // "no isolate" state, exactly like every other init failure path. + if (fn_detach_thread && fn_detach_thread(boot_thread) != 0) { + int td_rc = fn_tear_down_isolate ? fn_tear_down_isolate(boot_thread) : -1; + if (td_rc != 0) { + fprintf(stderr, + "[DataWeave Node addon] bootstrap thread detach AND isolate " + "teardown both failed during initialize(); the isolate can never " + "be torn down and is being leaked for the process lifetime. " + "Initialization was aborted.\n"); + } + g_isolate = NULL; // preserve the "nonzero result => g_isolate == NULL" contract + g_thread = NULL; + snprintf(args->error, sizeof(args->error), + "graal_detach_thread failed after isolate creation; " + "initialization aborted to avoid a poisoned isolate"); + args->result = -3; + return; } g_thread = NULL; From bfdbdd77c2fd7979a3af56754c4904caaf67dc26 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 18:09:49 -0300 Subject: [PATCH 208/216] fix(node): detach teardown-waiter threads so their OS handles are reclaimed (review #20 #2) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 0e249365..8f952337 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -4,6 +4,9 @@ #include #include #include +#ifndef _WIN32 +#include +#endif // GraalVM function pointer types typedef int (*graal_create_isolate_fn)(void*, void**, void**); @@ -2983,6 +2986,18 @@ static void isolate_ref_release_n_locked(int n) { waiter_opts.flags = UV_THREAD_HAS_STACK_SIZE; waiter_opts.stack_size = 2 * 1024 * 1024; int spawn_rc = uv_thread_create_ex(&waiter_tid, &waiter_opts, teardown_waiter_thread_fn, NULL); + if (spawn_rc == 0) { + // Reclaim the waiter's OS thread handle without joining it (joining on this + // JS thread would reintroduce the blocking-JS deadlock this deferral avoids). + // libuv has no uv_thread_detach, and uv_thread_t IS the underlying platform + // handle, so detach it directly: the OS reclaims the thread on exit, leaving + // zero unreaped handles across repeated init/stream/cleanup cycles (review #20 #2). +#ifdef _WIN32 + CloseHandle(waiter_tid); +#else + pthread_detach(waiter_tid); +#endif + } if (spawn_rc != 0) { // Best-effort degradation: the waiter thread never started, so nothing will // drain the isolate. Restore g_ref_count to the true remaining ownership @@ -3208,9 +3223,19 @@ static napi_value release_isolate_ref_locked(napi_env env) { waiter_opts.flags = UV_THREAD_HAS_STACK_SIZE; waiter_opts.stack_size = 2 * 1024 * 1024; 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 + // 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. + // the blocking-JS-thread problem this fix removes. But we must still reclaim + // its OS thread handle, so DETACH it: libuv has no uv_thread_detach and + // uv_thread_t IS the platform handle, so the OS reclaims the thread on exit + // with zero unreaped handles across cycles (review #20 #2). + if (spawn_rc == 0) { +#ifdef _WIN32 + CloseHandle(waiter_tid); +#else + pthread_detach(waiter_tid); +#endif + } if (spawn_rc != 0) { // Best-effort degradation: if the waiter thread never starts, nothing From 08faf6a38d3eb7ea9a7f26026b8aea691a6426a5 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Mon, 31 Aug 2026 18:18:27 -0300 Subject: [PATCH 209/216] fix(node): require graal detach/teardown symbols so the bootstrap-detach guard cannot short-circuit (review #20 final) Final whole-branch review noted that graal_detach_thread and graal_tear_down_isolate were dlsym'd but not in the required-symbol gate. The review #20 #1 bootstrap-detach failure path guards on those pointers (if (fn_detach_thread && ...) / fn_tear_down_isolate ? ...), so a NULL fn_detach_thread would short-circuit and fall through to a successful publish -- re-opening the exact phantom-attached-bootstrap- thread wedge #1 closes. Add both to the required-symbol check so init fails fast with a clear message and the guard's guarantee is unconditional. Practically unreachable (GraalVM co-exports these with graal_create_isolate) but makes the invariant explicit. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 8f952337..00570bbd 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -732,7 +732,13 @@ static void init_thread_fn(void* arg) { 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_free_cstring) { + // graal_detach_thread and graal_tear_down_isolate are required, not optional: + // the bootstrap-detach failure path below (review #20 #1) tears the isolate + // down with boot_thread on a failed detach, and its guard short-circuits when + // these pointers are NULL. Gating them here makes that guard's guarantee + // unconditional -- a dwlib missing them fails init fast with a clear message + // instead of publishing an isolate whose bootstrap thread was never detached. + if (!fn_create_isolate || !fn_free_cstring || !fn_detach_thread || !fn_tear_down_isolate) { snprintf(args->error, sizeof(args->error), "Missing required symbols in library"); args->result = -2; return; From cbd1c8648999e8876198ce52a7fd506f14f2c82e Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 1 Sep 2026 10:07:15 -0300 Subject: [PATCH 210/216] docs(spec): fix resolver arg order and teardown-completion overpromise (review #21 #3 #4) #3: create_engine_with_resolver ABI post-isolate order is (resolverCallback, ctx) -- the spec had (ctx, trampoline) reversed at three sites, which would lead a C/FFI consumer to pass the context where a function pointer is expected. Show the correct order plus the full (isolateThread, resolverCallback, ctx) signature. #4: module-level cleanup() resolves once the teardown ATTEMPT completes (logical release), not once physical teardown finishes -- align 6.4 with the 6.2 retry-on-ordinary-failure / leak-on-unrecoverable-failure model, matching the wording already corrected in README.md and dataweave.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-08-07-native-lib-multi-engine-design.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 index 791f7f58..00772917 100644 --- 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 @@ -377,7 +377,10 @@ because a boolean cannot represent the window during which `cleanup()` has start needing guaranteed graceful shutdown register and await their own signal handlers. - Module-level `cleanup()` coalesces overlapping calls via a module-scoped `cleanupPromise` (it nulls `globalInstance` synchronously so new work builds a fresh instance, but overlapping - `cleanup()`s await the same drain and resolve only when native teardown finishes). + `cleanup()`s await the same drain and resolve once the native teardown *attempt* completes -- + guaranteeing logical release, not necessarily physical reclamation: an ordinary teardown failure + retains the live isolate and retries where safe, and an unrecoverable teardown-plus-detach double + failure leaks it for the process lifetime with a stderr diagnostic (§6.2)). ### 6.5 Robustness of native allocation and streaming @@ -480,7 +483,9 @@ regardless of which OS thread performs the last release. ### 7.3 Instance lifecycle - **`initialize()`** — under the lock, `_acquire_isolate` (create-on-first-ref + bootstrap detach, - `_isolate_ref_count += 1`); then `create_engine()` or `create_engine_with_resolver(ctx, trampoline)`, + `_isolate_ref_count += 1`); then `create_engine()` or `create_engine_with_resolver(trampoline, ctx)` + (post-isolate ABI order is `(resolverCallback, ctx)`; the full C signature is + `create_engine_with_resolver(isolateThread, resolverCallback, ctx)`), storing the returned `handle` on the instance. If `create_engine` fails after the isolate ref was taken, the instance releases the ref (tearing down if it was the only one) and — when a resolver was installed before `initialize()` — unregisters its resolver token, so a failed init leaks @@ -614,11 +619,11 @@ dwB.run(...) → resolves via resolver B only; A's cache untouched; no cross-t ``` dwA = DataWeave(resolve_module=A); dwA.initialize() → lock: _isolate None → graal_create_isolate() + detach bootstrap thread; ref 0→1 - → create_engine_with_resolver(ctx=tokenA, trampoline); registry[tokenA]=dwA; dwA._handle = handleA + → create_engine_with_resolver(trampoline, ctx=tokenA); registry[tokenA]=dwA; dwA._handle = handleA dwB = DataWeave(resolve_module=B); dwB.initialize() → lock: _isolate exists → reuse; ref 1→2 - → create_engine_with_resolver(ctx=tokenB, trampoline); registry[tokenB]=dwB + → create_engine_with_resolver(trampoline, ctx=tokenB); registry[tokenB]=dwB dwA.run("... import custom/lib ...") → attach a fresh thread on demand → run_script_engine(handleA, script, inputs) → detach From 094677a33a76cc93c8ffb7345c2007ea60d365bd Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 1 Sep 2026 10:07:17 -0300 Subject: [PATCH 211/216] fix(node): require graal_attach_thread symbol so a lib missing it fails init instead of crashing createEngine (review #21 #2) fn_attach_thread is called unconditionally on the engine-creation, execution, and teardown paths (e.g. createEngine's fn_attach_thread(g_isolate, &thread) has no NULL guard), but the required-symbol gate checked only create_isolate/ free_cstring/detach/tear_down. A dwlib missing graal_attach_thread passed init and then invoked a NULL function pointer on the first createEngine(). Add it to the gate so init fails fast with a clear missing-symbol error. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 00570bbd..95e53cb0 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -732,13 +732,19 @@ static void init_thread_fn(void* arg) { 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); - // graal_detach_thread and graal_tear_down_isolate are required, not optional: - // the bootstrap-detach failure path below (review #20 #1) tears the isolate - // down with boot_thread on a failed detach, and its guard short-circuits when - // these pointers are NULL. Gating them here makes that guard's guarantee - // unconditional -- a dwlib missing them fails init fast with a clear message - // instead of publishing an isolate whose bootstrap thread was never detached. - if (!fn_create_isolate || !fn_free_cstring || !fn_detach_thread || !fn_tear_down_isolate) { + // graal_attach_thread, graal_detach_thread and graal_tear_down_isolate are + // required, not optional. graal_attach_thread is called UNCONDITIONALLY on + // every engine-creation, execution, and teardown path (e.g. createEngine at + // fn_attach_thread(g_isolate, &thread) with no NULL guard), so a dwlib missing + // it would pass init and then invoke a NULL function pointer on the first + // createEngine() (review #21 #2). graal_detach_thread/graal_tear_down_isolate + // back the bootstrap-detach failure path below (review #20 #1), whose guard + // short-circuits when those pointers are NULL. Gating all three here makes + // those guarantees unconditional -- a dwlib missing any of them fails init + // fast with a clear message instead of crashing or publishing an isolate whose + // bootstrap thread was never detached. + if (!fn_create_isolate || !fn_free_cstring || !fn_attach_thread || + !fn_detach_thread || !fn_tear_down_isolate) { snprintf(args->error, sizeof(args->error), "Missing required symbols in library"); args->result = -2; return; From b21efe1bc4cb1c6c0e49933bbf2109b94feae6fe Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 1 Sep 2026 14:13:45 -0300 Subject: [PATCH 212/216] fix(node): poison the isolate on an ordinary detach failure so teardown leaks instead of hanging (review #21 #1) Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 85 +++++++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index 95e53cb0..f6eec531 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -234,6 +234,17 @@ static bool g_teardown_cancelled = false; // While set with g_active_ops > 0, the op-completion drain point retries the // teardown once ops reach 0 (retry_stranded_teardown_locked). static bool g_teardown_needed = false; +// review #21 #1: set under g_mutex when an ORDINARY operation's +// graal_detach_thread() returns nonzero, leaving a phantom OS thread attached to +// the live isolate. Unlike g_teardown_needed (a retryable "teardown couldn't run +// yet" signal), this is TERMINAL for this isolate: graal_tear_down_isolate() +// would block forever waiting for the phantom to reach a safepoint, so any later +// teardown must SKIP the attempt and leak-and-continue (CLEANUP_UNRECOVERABLE) +// instead of hanging. The isolate stays fully usable for running more ops +// (GraalVM tolerates many attached threads); only its teardown is doomed. Reset +// to false when a FRESH isolate is created (init_thread_fn) and by +// abandon_unrecoverable_isolate_locked(). +static bool g_isolate_poisoned = false; static uv_cond_t g_teardown_cond; // teardown+detach double failure (review #17 #1): an exiting worker is stuck @@ -252,6 +263,7 @@ static void abandon_unrecoverable_isolate_locked(void) { g_initialized = 0; g_ref_count = 0; g_teardown_needed = false; + g_isolate_poisoned = false; fprintf(stderr, "[DataWeave Node addon] GraalVM isolate teardown AND worker detach both " "failed; the isolate can never be torn down and is being leaked for the " @@ -259,6 +271,32 @@ static void abandon_unrecoverable_isolate_locked(void) { "builds a fresh isolate.\n"); } +// review #21 #1: mark the shared isolate un-tear-down-able because an ordinary +// op's graal_detach_thread() failed (a phantom thread is now stuck attached). +// Emits a one-time stderr diagnostic on the false->true transition. Caller holds +// g_mutex. Teardown paths consult g_isolate_poisoned and leak-and-continue +// (CLEANUP_UNRECOVERABLE) instead of calling graal_tear_down_isolate(), which +// would hang. The triggering op still delivers its (valid) result -- only the +// isolate's eventual teardown is affected. +static void poison_isolate_detach_failure_locked(int detach_rc) { + if (!g_isolate_poisoned) { + fprintf(stderr, + "[DataWeave Node addon] graal_detach_thread failed (code %d) after an " + "operation; a thread is stuck attached to the isolate, so it can never " + "be torn down. Teardown will leak the isolate for the process lifetime " + "instead of hanging, and a later initialize() builds a fresh one.\n", + detach_rc); + } + g_isolate_poisoned = true; +} + +// Lock-taking wrapper for call sites that are NOT already holding g_mutex. +static void poison_isolate_detach_failure(int detach_rc) { + uv_mutex_lock(&g_mutex); + poison_isolate_detach_failure_locked(detach_rc); + uv_mutex_unlock(&g_mutex); +} + // 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 @@ -449,7 +487,8 @@ static bool bridge_finalize_registry(engine_bridge_t* b) { bool destroyed = false; if (fn_attach_thread(g_isolate, &thread) == 0 && thread != NULL) { fn_destroy_engine(thread, b->handle); - fn_detach_thread(thread); + int detach_rc = fn_detach_thread(thread); + if (detach_rc != 0) poison_isolate_detach_failure(detach_rc); destroyed = true; // registry entry removed -> resolver ctx is now dead } // else: attach failed while the isolate is STILL LIVE -- destroy was skipped, @@ -773,6 +812,12 @@ static void init_thread_fn(void* arg) { return; } + // review #21 #1: a brand-new isolate starts un-poisoned. Any poison flag left + // over from a previously abandoned/leaked isolate must not carry onto this + // fresh one. Runs under the init caller's g_mutex (see the g_mutex discipline + // note for init_thread_fn). + g_isolate_poisoned = false; + // Detach the bootstrap thread immediately. This init OS thread is joined and // exits right after, so leaving it attached would leave a phantom attached // thread on the isolate — and graal_tear_down_isolate() blocks forever waiting @@ -1204,6 +1249,7 @@ static void streaming_thread_fn(void* arg) { // 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; + int detach_rc = 0; if (rc != 0) { char err[256]; snprintf(err, sizeof(err), "{\"success\":false,\"error\":\"Failed to attach thread (code %d)\"}", rc); @@ -1221,7 +1267,7 @@ static void streaming_thread_fn(void* arg) { meta_result = strdup("{\"success\":false,\"error\":\"Empty response\"}"); if (meta_result == NULL) meta_result = (char*)OOM_JSON; } - fn_detach_thread(worker_thread); + detach_rc = fn_detach_thread(worker_thread); } // Decrement here, once this thread has fully detached from the isolate -- @@ -1232,6 +1278,7 @@ static void streaming_thread_fn(void* arg) { // 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); + if (detach_rc != 0) poison_isolate_detach_failure_locked(detach_rc); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); // Round-14 (#2/#3): if a prior last-release could not tear the isolate down @@ -1727,6 +1774,7 @@ static void transform_thread_fn(void* arg) { // so the sentinel below still delivers a terminal result. Mirrors // streaming_thread_fn. char* meta_result = NULL; + int detach_rc = 0; if (rc != 0) { char err[256]; snprintf(err, sizeof(err), "{\"success\":false,\"error\":\"Failed to attach thread (code %d)\"}", rc); @@ -1747,13 +1795,14 @@ static void transform_thread_fn(void* arg) { meta_result = strdup("{\"success\":false,\"error\":\"Empty response\"}"); if (meta_result == NULL) meta_result = (char*)OOM_JSON; } - fn_detach_thread(worker_thread); + detach_rc = 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); + if (detach_rc != 0) poison_isolate_detach_failure_locked(detach_rc); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); // Round-14 (#2/#3): retry a stranded teardown now that this op has drained. @@ -2215,7 +2264,8 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { napi_throw_error(env, NULL, "Failed to attach thread"); return NULL; } long long handle = fn_create_engine(thread); - fn_detach_thread(thread); + int detach_rc = fn_detach_thread(thread); + if (detach_rc != 0) poison_isolate_detach_failure(detach_rc); // 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 @@ -2346,7 +2396,8 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i 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); + int detach_rc = fn_detach_thread(thread); + if (detach_rc != 0) poison_isolate_detach_failure(detach_rc); // Same invalid-handle guard as napi_create_engine: a Java-side construction // failure surfaces here as handle == 0 (GraalVM @CEntryPoint default-value @@ -2534,12 +2585,14 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { g_active_ops++; // pins the live isolate against teardown for this attach uv_mutex_unlock(&g_mutex); void* thread = NULL; + int detach_rc = 0; if (fn_attach_thread(g_isolate, &thread) == 0 && thread != NULL) { fn_destroy_engine(thread, handle); - fn_detach_thread(thread); + detach_rc = fn_detach_thread(thread); } // Verbatim g_active_ops release pattern. uv_mutex_lock(&g_mutex); + if (detach_rc != 0) poison_isolate_detach_failure_locked(detach_rc); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); @@ -2634,7 +2687,8 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) char* result_copy = result ? strdup(result) : NULL; if (result != NULL) fn_free_cstring(thread, result); - fn_detach_thread(thread); + int detach_rc = fn_detach_thread(thread); + if (detach_rc != 0) poison_isolate_detach_failure(detach_rc); free(script); free(inputs); // Round-11 (#3): release the per-engine pin (may finalize a destroy that a @@ -2710,6 +2764,15 @@ static void cleanup_thread_fn(void* arg) { *out_result = CLEANUP_TORN_DOWN; return; } + if (g_isolate_poisoned) { + // review #21 #1: an earlier op's detach failed, leaving a phantom attached + // thread. graal_tear_down_isolate() would block forever waiting for it, so do + // NOT attempt teardown -- signal leak-and-continue (the caller runs + // abandon_unrecoverable_isolate_locked()). Reading g_isolate_poisoned unlocked + // is safe: the caller spawns+joins this thread while holding g_mutex. + *out_result = CLEANUP_UNRECOVERABLE; + 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_result at its @@ -2749,6 +2812,7 @@ static void teardown_waiter_thread_fn(void* arg) { uv_cond_wait(&g_teardown_cond, &g_mutex); } bool cancelled = g_teardown_cancelled; + bool poisoned = g_isolate_poisoned; 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 @@ -2762,7 +2826,12 @@ static void teardown_waiter_thread_fn(void* arg) { // call adopted the live isolate instead (see napi_initialize's // TEARDOWN_PENDING_WAIT branch). cleanup_result_t result = CLEANUP_RETAIN; - if (!cancelled && fn_tear_down_isolate && fn_attach_thread && g_isolate) { + if (!cancelled && poisoned) { + // review #21 #1: a prior op's failed detach left a phantom attached thread; + // graal_tear_down_isolate() would hang. Skip it and leak-and-continue -- the + // post block below runs abandon_unrecoverable_isolate_locked(). + result = CLEANUP_UNRECOVERABLE; + } else 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) { if (fn_tear_down_isolate(local_thread) == 0) { From 70090acbac62fe801430f20d233998a64df93c6d Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 1 Sep 2026 14:22:53 -0300 Subject: [PATCH 213/216] test(native-lib): release busy-spin test resources in finally and skip without CPU-time support (review #21 #5) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mule/weave/lib/NativeLibFeederTest.java | 117 +++++++++++------- 1 file changed, 72 insertions(+), 45 deletions(-) diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java index 0c15de28..0f9cdf7f 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import org.junit.jupiter.api.Test; @@ -471,57 +472,83 @@ int readChunk(byte[] dest, int max) { */ @Test void interruptedCleanupCallerBlocksInJoinInsteadOfBusySpinning() throws Exception { + ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); + // The block-vs-busy-spin distinction fundamentally needs per-thread CPU + // time (see the determinism note above -- thread-state sampling was tried + // and rejected). If the JVM cannot supply it, skip rather than fail. + assumeTrue(threadMXBean.isThreadCpuTimeSupported(), + "per-thread CPU time is not supported on this JVM; cannot distinguish block from busy-spin"); + boolean prevCpuEnabled = threadMXBean.isThreadCpuTimeEnabled(); + if (!prevCpuEnabled) { + threadMXBean.setThreadCpuTimeEnabled(true); + } + InputStreamSession inputSession = new InputStreamSession("application/json", "UTF-8"); long inputHandle = inputSession.register(); CountDownLatch feederParked = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); - // Feeder blocks inside readChunk until released, so it stays alive across the join. - NativeLib.InputCallbackFeeder feeder = new NativeLib.InputCallbackFeeder(0L, 0L, inputSession) { - @Override - int readChunk(byte[] dest, int max) { - feederParked.countDown(); - try { - release.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + Thread feederThread = null; + Thread cleaner = null; + try { + // Feeder blocks inside readChunk until released, so it stays alive across the join. + NativeLib.InputCallbackFeeder feeder = new NativeLib.InputCallbackFeeder(0L, 0L, inputSession) { + @Override + int readChunk(byte[] dest, int max) { + feederParked.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return 0; // clean EOF once released, so the loop exits promptly } - return 0; // clean EOF once released, so the loop exits promptly + }; + final Thread ft = new Thread(feeder, "test-busy-spin-feeder"); + feederThread = ft; + ft.setDaemon(true); + ft.start(); + assertTrue(feederParked.await(2, TimeUnit.SECONDS), "feeder never entered the read callback"); + + AtomicBoolean restored = new AtomicBoolean(false); + cleaner = new Thread(() -> { + Thread.currentThread().interrupt(); // caller arrives already interrupted + NativeLib.cleanupFeeder(feeder, ft, inputHandle); + restored.set(Thread.currentThread().isInterrupted()); // must be restored at the end + }); + // Daemon: see the determinism note above -- a reintroduced regression must not hang the JVM. + cleaner.setDaemon(true); + cleaner.start(); + + // The fix blocks in join(), consuming ~no CPU; the bug spins, consuming ~all of the window. + long cleanerId = cleaner.getId(); + Thread.sleep(50); // let the cleanup thread reach steady state (blocked, or spinning) + long cpuBefore = threadMXBean.getThreadCpuTime(cleanerId); + Thread.sleep(300); + long cpuAfter = threadMXBean.getThreadCpuTime(cleanerId); + assertTrue(cpuBefore >= 0 && cpuAfter >= 0, + "thread CPU time measurement unavailable on this JVM"); + long consumedNanos = cpuAfter - cpuBefore; + assertTrue(consumedNanos < TimeUnit.MILLISECONDS.toNanos(100), + "cleanup thread must block in join(), not busy-spin, under interruption (consumed " + + TimeUnit.NANOSECONDS.toMillis(consumedNanos) + "ms of CPU over a 300ms window)"); + + release.countDown(); // let the feeder finish + cleaner.join(5000); + feederThread.join(5000); + assertFalse(cleaner.isAlive()); + assertTrue(restored.get(), "interrupt status must be restored after cleanup"); + } finally { + // Always release the blocked feeder and reap threads so an assertion + // failure above cannot strand daemon threads or the registered input + // session for sibling tests sharing this JVM. + release.countDown(); // idempotent: no-op if already counted down + if (cleaner != null) cleaner.join(5000); + if (feederThread != null) feederThread.join(5000); + InputStreamSession.close(inputHandle); + if (!prevCpuEnabled) { + threadMXBean.setThreadCpuTimeEnabled(false); // restore the former setting } - }; - Thread feederThread = new Thread(feeder, "test-busy-spin-feeder"); - feederThread.setDaemon(true); - feederThread.start(); - assertTrue(feederParked.await(2, TimeUnit.SECONDS), "feeder never entered the read callback"); - - AtomicBoolean restored = new AtomicBoolean(false); - Thread cleaner = new Thread(() -> { - Thread.currentThread().interrupt(); // caller arrives already interrupted - NativeLib.cleanupFeeder(feeder, feederThread, inputHandle); - restored.set(Thread.currentThread().isInterrupted()); // must be restored at the end - }); - // Daemon: see the determinism note above — a reintroduced regression must not hang the JVM. - cleaner.setDaemon(true); - cleaner.start(); - - // The fix blocks in join(), consuming ~no CPU; the bug spins, consuming ~all of the window. - ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); - long cleanerId = cleaner.getId(); - Thread.sleep(50); // let the cleanup thread reach steady state (blocked, or spinning) - long cpuBefore = threadMXBean.getThreadCpuTime(cleanerId); - Thread.sleep(300); - long cpuAfter = threadMXBean.getThreadCpuTime(cleanerId); - assertTrue(cpuBefore >= 0 && cpuAfter >= 0, - "thread CPU time measurement unavailable on this JVM"); - long consumedNanos = cpuAfter - cpuBefore; - assertTrue(consumedNanos < TimeUnit.MILLISECONDS.toNanos(100), - "cleanup thread must block in join(), not busy-spin, under interruption (consumed " - + TimeUnit.NANOSECONDS.toMillis(consumedNanos) + "ms of CPU over a 300ms window)"); - - release.countDown(); // let the feeder finish - cleaner.join(5000); - feederThread.join(5000); - assertFalse(cleaner.isAlive()); - assertTrue(restored.get(), "interrupt status must be restored after cleanup"); + } } } From 141fad793e317deb2c486ca2cd928bf6fb141a4c Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 1 Sep 2026 14:29:15 -0300 Subject: [PATCH 214/216] test(native-lib): comment why ft/feederThread are two variables (review #21 #5 follow-up) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/java/org/mule/weave/lib/NativeLibFeederTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java index 0f9cdf7f..63f3e8b7 100644 --- a/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibFeederTest.java @@ -504,6 +504,11 @@ int readChunk(byte[] dest, int max) { return 0; // clean EOF once released, so the loop exits promptly } }; + // Two references to the SAME thread on purpose: `ft` is final so the + // cleaner lambda below can capture it, while `feederThread` is the + // method-scope (reassignable, initially null) alias the finally uses to + // join it. Do not collapse these into one variable -- a reassignable + // local cannot be captured by the lambda. final Thread ft = new Thread(feeder, "test-busy-spin-feeder"); feederThread = ft; ft.setDaemon(true); From 566c7011697bdf61a7ff8e2e58fd5fed938f3b87 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 1 Sep 2026 14:41:11 -0300 Subject: [PATCH 215/216] fix(node): poison on the OOM-rollback detach in napi_create_engine too (review #21 #1 completeness) Final whole-branch review found one ordinary detach still unchecked: the engine_bridge_t calloc-failure rollback in napi_create_engine. A detach failure there strands a phantom thread that would wedge a later teardown -- the exact hang #1 eliminates everywhere else. Fold the capture+poison into the existing g_active_ops-- critical section, matching the other sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- native-lib/node/src/addon.c | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index f6eec531..aa1f0f2d 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -2294,11 +2294,18 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { 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. + int detach_rc = 0; if (fn_destroy_engine) { void* t2 = NULL; - if (fn_attach_thread(g_isolate, &t2) == 0) { fn_destroy_engine(t2, handle); fn_detach_thread(t2); } + if (fn_attach_thread(g_isolate, &t2) == 0) { fn_destroy_engine(t2, handle); detach_rc = fn_detach_thread(t2); } } - uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + // review #21 #1 (final-review completeness): this OOM-rollback detach is an + // ordinary detach too -- a failure here strands a phantom thread and would + // wedge a later teardown, so poison in the same critical section as the + // g_active_ops-- (before the decrement/broadcast), matching the other sites. + uv_mutex_lock(&g_mutex); + if (detach_rc != 0) poison_isolate_detach_failure_locked(detach_rc); + g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); napi_throw_error(env, NULL, "Failed to allocate engine record"); return NULL; } From 6661b965944976c62dd7f2fc7305f181081c5753 Mon Sep 17 00:00:00 2001 From: mlischetti Date: Tue, 1 Sep 2026 15:20:39 -0300 Subject: [PATCH 216/216] test(node): align ca09d69 addon-path test ffi mock with the multi-engine surface The dataweave-addon-path.test.ts added by master's ca09d69 mocks the legacy singleton ffi surface (runScript/runWithResolver), which this branch removed. The multi-engine DataWeave.initialize() now calls ffi.createEngine()/ createEngineWithResolver(), so the mock threw "No createEngine export" and failed nodeTest after the rebase. Mock the current engine-handle surface instead (matching dataweave-initialize.test.ts). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../node/tests/unit/dataweave-addon-path.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/native-lib/node/tests/unit/dataweave-addon-path.test.ts b/native-lib/node/tests/unit/dataweave-addon-path.test.ts index e9036328..078ab2c1 100644 --- a/native-lib/node/tests/unit/dataweave-addon-path.test.ts +++ b/native-lib/node/tests/unit/dataweave-addon-path.test.ts @@ -9,11 +9,13 @@ vi.mock("node:fs", () => ({ existsSync: vi.fn() })); vi.mock("../../src/addon-path", () => ({ resolveAddonPath: vi.fn() })); 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(), - runScript: vi.fn(), - runScriptStreaming: vi.fn(), - runScriptTransform: vi.fn(), - runWithResolver: vi.fn(), })); const mockedExistsSync = vi.mocked(existsSync);