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 445e7202..77289a6d 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,7 +1,7 @@ # Design: External DataWeave Module Support in Node.js Binding -**Date:** 2026-08-04 -**Status:** Superseded +**Date:** 2026-08-04 +**Status:** Superseded **Related Proposal:** [docs/proposals/nodejs-external-modules.md](../../proposals/nodejs-external-modules.md) > **⚠️ Superseded (2026-08-31).** This document describes the original @@ -366,20 +366,20 @@ public interface ResolveModuleCallback extends CFunctionPointer { ```java public class CallbackWeaveResourceResolver implements WeaveResourceResolver { private final ResolveModuleCallback callback; - + public CallbackWeaveResourceResolver(ResolveModuleCallback callback) { this.callback = callback; } - + @Override public Option resolve(ResourceDescriptor descriptor) { CCharPointer pathPtr = CTypeConversion.toCString(descriptor.path()).get(); CCharPointer resultPtr = callback.invoke(CurrentIsolate.getCurrentThread(), pathPtr); - + if (resultPtr.isNull()) { return Option.empty(); // Resolver returned null } - + String source = CTypeConversion.toJavaString(resultPtr); // Note: host must free resultPtr after this returns return Option.apply(new StringWeaveResource(descriptor.path(), source)); @@ -462,7 +462,7 @@ static char* resolve_module_callback(void* thread, const char* module_path) { // Return result_source (or NULL) } -static void resolver_js_callback(napi_env env, napi_value js_callback, +static void resolver_js_callback(napi_env env, napi_value js_callback, void* context, void* data) { // Call JS: result = resolveModule(modulePath) // Extract result string or null 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 00772917..5c1aea7d 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,18 +1,20 @@ # Design: Multiple Isolated DataWeave Engines per Process (native-lib — Node & Python) -**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) +**Date:** 2026-08-07 (consolidated 2026-08-25; unified Node + Python 2026-08-26; review-22 hardening folded in 2026-09-02) +**Status:** Approved and implemented on `w-23692110-multi-engine-design` (PR #157), with review-22 hardening finalized on `w-23692110-review-22-fixes` **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 > `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 +> **final state** of PR #157 and its review-22 hardening. 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. +> singleton and moved Python off its former isolate-per-instance model onto the shared model. The +> subsequent review-22 hardening is folded into the shared Java lifecycle, binding admission, Node +> output-flow, and detach-recovery contracts below rather than retained as a second specification. > 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 @@ -116,8 +118,9 @@ 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 -*asynchronous* `PENDING_WAIT`/waiter-thread/adoption machinery — a reference count, a -synchronous drain-before-teardown, and a simpler *synchronous* teardown retry suffice (§7). +*asynchronous* `PENDING_WAIT`/waiter-thread/adoption machinery — a reference count, synchronous +refusal while an active registered worker exists, normal teardown once no worker is registered, and +a simpler *synchronous* teardown retry (§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 @@ -351,13 +354,12 @@ because a boolean cannot represent the window during which `cleanup()` has start 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. +- **`run()` / `runStreaming()` / `runTransform()`** — `captureOperationToken()` initially checks + readiness and captures `{handle, generation}`. `runTransform` calls + `assertCurrentOperation(token)` both after `await createChunkReader(input)` and immediately at + native admission. Async input pre-buffering can span arbitrary time; cleanup or reinitialization + during it therefore raises the public stale-generation `DataWeaveError` instead of admitting work + to a replacement engine or returning an `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), @@ -424,9 +426,10 @@ Python drives the **same** shared Java engine layer and the **same** `*_engine` 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 *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. +reference count, synchronous refusal while an active registered worker exists, normal teardown +after that worker unregisters, and a simpler *synchronous* teardown retry (`_teardown_needed`, +retried on the next `initialize()` — see §7.2). Unlike Node (§6), Python cleanup does not cancel or +wait for an active worker. The **public Python API is unchanged** by the unification. ### 7.1 Shared state and the reference-count invariant @@ -493,17 +496,17 @@ regardless of which OS thread performs the last release. - **`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. +- **`cleanup()`** — refuses while this instance has an active registered stream worker; it does not + use Node's cancel-and-wait cleanup protocol. Once no worker is active it destroys the engine, + removes the resolver-map entry, clears the operation token, and releases the isolate ref. Worker + registration validates its captured `{handle, generation}` token while holding the registry lock, + so cleanup/reinitialize cannot admit stale work. Cleanup on an uninitialized/already-cleaned + instance is a no-op; double cleanup releases the ref only once. If `destroy_engine` throws, the + isolate ref is still released and the error is re-raised. + +**Why this stays simple:** teardown happens only on the *last* release after each instance has no +registered active worker. A still-running daemon worker prevents its instance's cleanup, rather than +allowing teardown to race an attached worker thread. ### 7.4 Resolver dispatch and the streaming/resolver hazard @@ -532,12 +535,12 @@ instance has already joined its own workers, so the isolate has no attached work ### 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. - `getInstance()` / `defaultInstance` are **removed** — `ScriptRuntime` is purely handle-addressed. +- **`ScriptRuntime.java`** — a handle-keyed registry of lifecycle records, each retaining its + `ScriptRuntime` plus `LIVE`, `CLOSING`, or `DESTROYED` state and a count of admitted operation + leases. `acquire(handle)` admits only `LIVE` records and returns a lease; `destroy(handle)` closes + admission, waits for admitted leases to drain, then removes the record. The resolver is bound at + construction for the runtime lifetime; the former write-once static resolver and `getInstance()` + / `defaultInstance` are removed. - **`CallbackWeaveResourceResolver.java`** — stores a `PointerBase ctx` alongside the callback, forwarded on every `callback.invoke(...)`; constructor `(ResolveModuleCallback, PointerBase ctx)`. - **`NativeCallbacks.java`** — `ResolveModuleCallback` is the 3-arg ctx form @@ -546,8 +549,10 @@ instance has already joined its own workers, so the isolate has no attached work 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)`. The three legacy singleton entrypoints (`run_script`, + `run_script_callback_engine`, `run_script_input_output_callback_engine`). Java entrypoint + exceptions return ABI sentinels: create returns `0`, and run entrypoints return `NULL`. + Normal script failures remain allocated non-`NULL` JSON envelopes with `success:false`. + 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). @@ -583,12 +588,14 @@ instance has already joined its own workers, so the isolate has no attached work ### 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. + attach-on-demand thread handling, the 3-arg ctx resolver trampoline + `_resolver_registry`, + generation-bound `{handle, generation}` operation tokens, same-thread callback reentrancy guard, + 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. + stores its generation-bound operation token; run methods route through the `*_engine` entrypoints. + Cleanup refuses while an active stream worker is registered; registration validates the token so + stale work cannot be admitted. It then destroys the engine and releases the ref. The public API + surface is unchanged. - **`models.py`** — `RESOLVE_MODULE_CALLBACK` ctypes signature carries the `ctx` argument. ## 9. Data Flow @@ -606,7 +613,7 @@ new DataWeave({ resolveModule: A }).initialize() 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(...) + → Java: ScriptRuntime.acquire(handle_A) → admitted lease → runtime.run(...) 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-- @@ -630,8 +637,12 @@ dwA.run("... import custom/lib ...") → 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 +dwA.cleanup() → verify no active registered worker; destroy_engine(handleA); ref 2→1 (isolate stays) +dwB.cleanup() → verify no active registered worker; destroy_engine(handleB); ref 1→0 + → attach fresh thread + graal_tear_down_isolate(); _isolate=None + +active worker at cleanup → reject without joining or cancelling the worker; its generation-safe + registration prevents stale work from being admitted after lifecycle changes ``` ## 10. Error Handling & Backward Compatibility @@ -641,9 +652,9 @@ dwB.cleanup() → join workers; destroy_engine(handleB); ref 1→0 → attach f 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/crash. +- **Invalid/unknown/destroyed handle:** `ScriptRuntime.acquire(handle)` returns no lease → the + entrypoint returns `{"success":false,"error":"Unknown engine handle"}` (resolved for async ops, + returned as 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`). @@ -707,12 +718,13 @@ dwB.cleanup() → join workers; destroy_engine(handleB); ref 1→0 → attach f 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. This is a standing, documented - decision. +- **Test-only addon hook posture (Node).** With non-empty `DATAWEAVE_TEST_HOOKS`, internal, + non-public `__test_*` exports deterministically cover selected detach failures, engine-record + allocation failure, engine generation and handle boundaries, output settlement/status failures, + output-credit accounting, and scheduling/output-flow delivery paths. The hooks are intentionally + limited: unhooked native and N-API failure branches and arbitrary cross-thread interleavings retain + static or probabilistic Worker-test coverage. Detach injection runs only after a real detach has + succeeded, so it cannot safely reproduce a physically stuck attached Graal thread. - **Native image build** (`native-lib:nativeCompile`) stays green with the legacy entrypoints removed (confirms no SPI/reflection config referenced them). @@ -736,6 +748,43 @@ uphold all six: 6. A failed engine-create rolls back the isolate ref; a throwing `destroy_engine` still releases the ref. +## 12.1 Final hardening contract + +This section records review-22 hardening folded into this canonical final-state design. The +implementation details here are intentionally narrower than public API guarantees. Java registry +records use `LIVE`, `CLOSING`, and `DESTROYED` lifecycle states plus admitted-operation leases: +`acquire(handle)` admits only a live record, and `destroy(handle)` closes admission and waits for +existing leases before removal. Every exported Java C entrypoint has an explicit, allocation-free +exception sentinel: engine creation returns `0`, run entrypoints return `NULL`, and void entrypoints +return normally. Those sentinels are distinct from normal script or unknown-handle failures, which +remain allocated, non-`NULL` `success:false` JSON envelopes. + +Both bindings use callback thread-local state to reject same-thread resolver/read/write callback +reentry into lifecycle or execution APIs with their public `DataWeaveError`. Both bind operations +to immutable `{handle, generation}` tokens, validate that token immediately at native admission, +and reject stale work after cleanup or reinitialization rather than allowing it to execute on a +replacement engine. Java leases independently preserve raw-ABI engine and callback-context lifetime +after binding admission succeeds. + +Node's native output bridge bounds its own outstanding bytes and chunks and uses a finite TSFN +queue. Each output flow tracks private sequence-bound credits from native enqueue until the consumer +dequeues the chunk; cancellation releases outstanding credits and unblocks a producer. Buffers +retained by user code after yield are outside that bound. Controller and sequence credit values are +internal correctness machinery, not a public BigInt sequence contract. Node cleanup cancels and +waits for abandoned active streams/transforms before engine destruction. + +Python deliberately differs: it cannot force-cancel an active native call, so cleanup refuses while +an active streaming worker is attached. Its registration validation is generation-safe; it does not +adopt Node's cancellation behavior. + +**Test-only detach hooks.** The addon enables fault injection only when `DATAWEAVE_TEST_HOOKS` is +non-empty. Ordinary operation detaches pass through centralized status handling. A detach hook +always performs a real detach first and can synthesize a failure only after that detach succeeds; it +therefore does not model a physically stuck Graal thread. A nonzero ordinary detach status poisons +the published isolate generation and makes new admission fail closed. Cleanup abandons that +generation safely, and a later initialization can recover with a new isolate. These hooks are +internal, non-public test details, not stable APIs. + ## 13. Follow-Up Work - **Streaming/transform + custom-module resolution** across the background-thread boundary remains @@ -761,10 +810,11 @@ uphold all six: ## Appendix: Hardening provenance -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. +The Node concurrency & lifecycle model (§6) converged over a series of code-review rounds, the +Python unification (§7) was implemented and reviewed task-by-task, and review-22 hardening was +subsequently folded into the same final-state contracts. This map exists only for git archaeology; +the per-round, Python-unification, and review-22 remediation designs were consolidated into this +file. | Round(s) | Area folded into | Decision | |----------|------------------|----------| @@ -784,3 +834,4 @@ unification design documents were consolidated into this file. | 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. | +| PR157 review 22 (09-02) | §8, §10, §11, §12.1 | Java lifecycle records and leases; explicit C-entrypoint exception sentinels distinct from normal JSON errors; Node/Python same-thread callback TLS rejection and generation-safe immediate admission; Node finite-queue, sequence-credit output bounds with cancellation cleanup; Python active-worker cleanup refusal; centralized ordinary-detach poison handling, fail-closed admission, generation-safe abandonment/recovery, and internal real-detach-first fault injection. | diff --git a/native-lib/README.md b/native-lib/README.md index 87cfdc98..93a7699c 100644 --- a/native-lib/README.md +++ b/native-lib/README.md @@ -68,6 +68,24 @@ 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. +## Raw engine ABI contract + +The exported engine entrypoints are `create_engine`, +`create_engine_with_resolver`, `destroy_engine`, `run_script_engine`, +`run_script_callback_engine`, and `run_script_input_output_callback_engine`. +`create_engine` and `create_engine_with_resolver` return `0` when their Java C +entrypoint fails. The three `run_*_engine` entrypoints return `NULL` when their +Java C entrypoint fails; callers must not pass `NULL` to `free_cstring`. Those +sentinels are distinct from normal DataWeave failures: a script error is a +non-`NULL` JSON envelope with `success:false`, and that allocated result must be +freed normally. + +`destroy_engine` closes admission for the handle and blocks until operations +already admitted to that engine drain. Resolver, read, and write callback +contexts must remain valid until `destroy_engine` returns. A callback must not +call `destroy_engine` synchronously for the engine invoking it: that operation +holds an admitted lease and would wait for itself to finish. + ## Building with Gradle ### Prerequisites diff --git a/native-lib/node/README.md b/native-lib/node/README.md index 75d0090f..25ea51bf 100644 --- a/native-lib/node/README.md +++ b/native-lib/node/README.md @@ -232,6 +232,26 @@ import { cleanup } from 'dataweave-native'; await cleanup(); ``` +### Callback and stream lifecycle + +Resolver, read, and write callbacks must not call DataWeave lifecycle or +execution APIs on the same thread. The binding rejects that reentry with a +public `DataWeaveError` instead of recursively entering the native runtime. +Native addon callers receive the message `DataWeave native methods cannot be +called from a native callback` and code `ERR_DATAWEAVE_CALLBACK_REENTRANCY`. + +Streaming and transform work captures the initialized engine generation. If +cleanup or reinitialization happens before it is consumed or admitted, it fails +with `DataWeaveError: DataWeave operation belongs to a stale engine generation.` +and never runs against the replacement engine. Cleanup cancels and waits for +abandoned active streams and transforms before destroying their engine. + +The addon uses bounded native output buffering. Its byte and chunk watermarks, +finite thread-safe-function queue, and controller/sequence credit bookkeeping +are implementation details, not public configuration or a BigInt sequence API. +The bound excludes `Buffer` objects retained by application code after a chunk +is yielded. + ### Class-Based API For more control, use the `DataWeave` class directly: @@ -411,8 +431,9 @@ console.log(result.getString()); // "300" ### Streaming Large Files ```javascript -import { runTransform } from 'dataweave-native'; -import { readFileSync, createWriteStream } from 'fs'; +import { once } from "node:events"; +import { readFileSync, createWriteStream } from "node:fs"; +import { runTransform } from "dataweave-native"; const script = ` %dw 2.0 @@ -439,7 +460,9 @@ const generator = runTransform( const output = createWriteStream('filtered.json'); for await (const chunk of generator) { - output.write(chunk); + if (!output.write(chunk)) { + await once(output, "drain"); + } } output.end(); diff --git a/native-lib/node/src/addon.c b/native-lib/node/src/addon.c index aa1f0f2d..5432c635 100644 --- a/native-lib/node/src/addon.c +++ b/native-lib/node/src/addon.c @@ -4,6 +4,7 @@ #include #include #include +#include #ifndef _WIN32 #include #endif @@ -36,6 +37,9 @@ static void* g_thread = NULL; static int g_initialized = 0; static int g_ref_count = 0; static uv_mutex_t g_mutex; +static uv_mutex_t g_test_output_mutex; +static uv_key_t g_native_callback_depth; +static int g_native_callback_depth_status; // Guards initialization of the process-global g_mutex. Init() runs once per // Worker environment that loads this addon, but g_mutex is process-global — // re-running uv_mutex_init() on an already-initialized mutex from a second @@ -44,6 +48,49 @@ static uv_mutex_t g_mutex; // body runs exactly once per process regardless of how many Workers load us. static uv_once_t g_mutex_once = UV_ONCE_INIT; +#define CALLBACK_REENTRANCY_CODE "ERR_DATAWEAVE_CALLBACK_REENTRANCY" +#define ISOLATE_POISONED_MESSAGE \ + "DataWeave isolate is unavailable after a thread detach failure; clean up and initialize again." +#define MAX_SAFE_ENGINE_HANDLE 9007199254740991LL + +static unsigned native_callback_depth(void) { + return (unsigned)(uintptr_t)uv_key_get(&g_native_callback_depth); +} + +static void native_callback_enter(void) { + uv_key_set(&g_native_callback_depth, + (void*)(uintptr_t)(native_callback_depth() + 1)); +} + +static void native_callback_exit(void) { + unsigned depth = native_callback_depth(); + uv_key_set(&g_native_callback_depth, + depth > 1 ? (void*)(uintptr_t)(depth - 1) : NULL); +} + +static bool native_callback_active(void) { + return native_callback_depth() != 0; +} + +static napi_value throw_callback_reentrancy(napi_env env) { + napi_value message; + napi_value error; + napi_value code; + if (napi_create_string_utf8( + env, + "DataWeave native methods cannot be called from a native callback", + NAPI_AUTO_LENGTH, + &message) != napi_ok || + napi_create_error(env, NULL, message, &error) != napi_ok || + napi_create_string_utf8(env, CALLBACK_REENTRANCY_CODE, NAPI_AUTO_LENGTH, &code) != napi_ok || + napi_set_named_property(env, error, "code", code) != napi_ok || + napi_throw(env, error) != napi_ok) { + napi_throw_error(env, CALLBACK_REENTRANCY_CODE, + "DataWeave native methods cannot be called from a native callback"); + } + return NULL; +} + 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; @@ -86,7 +133,11 @@ typedef struct resolver_result_node { // streamed/transform custom-module lookup arriving on the background uv_thread // — and fail closed (return "not found") instead of crashing. typedef struct engine_bridge { + // Public handles stay unique for the process lifetime. native_handle is + // isolate-local and may be reused after an abandoned isolate is replaced. long long handle; + long long native_handle; + uint64_t isolate_generation; 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 @@ -115,9 +166,23 @@ typedef struct engine_bridge { // owner thread (creation, destroyEngine, bridge_env_cleanup) under the usual // owner-thread-serialization contract. bool hook_registered; + // Resolver refs and bridge memory have two independent owners once native + // registry removal strands: the Java callback ctx and the owner Node env. + // The env hook drops owner_alive/resolver_js; the native drain drops + // native_alive. The bridge is freed only after both have released it. + bool native_alive; + bool owner_alive; + // Every resolver bridge owns an unreferenced TSFN. A native-side drain can + // queue its callback onto the owner env to delete resolver_js legally; the + // TSFN finalizer drops owner ownership when that env dies instead. + napi_threadsafe_function owner_cleanup_tsfn; + bool owner_cleanup_queued; + bool owner_cleanup_released; struct engine_bridge* next; } engine_bridge_t; static engine_bridge_t* g_bridges = NULL; // linked list, guarded by g_mutex +static uint64_t g_isolate_generation = 0; // incremented for each fresh isolate +static long long g_next_engine_handle = 1; // process-unique public handle // 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 @@ -148,10 +213,73 @@ static engine_bridge_t* g_stranded_bridges = NULL; // linked list, guarded by g // 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. +// and the remaining test state 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; +static bool g_test_hold_next_async_op = false; +static bool g_test_async_op_held = false; +static bool g_test_release_async_op = false; +static uint64_t g_test_engine_record_allocation_failure_generation = 0; +static uint64_t g_detach_in_progress_generation = 0; +static unsigned g_detach_in_progress = 0; +static bool g_test_hold_next_detach_publication = false; +static bool g_test_detach_publication_held = false; +static bool g_test_release_detach_publication = false; +static unsigned g_detach_publication_waiters = 0; +static uint64_t g_test_live_resolver_refs = 0; +static uint64_t g_test_bridge_frees = 0; +static uint64_t g_test_post_reclamation_actions = 0; + +static void bridge_env_cleanup(void* arg); +static void bridge_finalize_free(engine_bridge_t* b, bool env_still_alive); + +typedef enum { + OUTPUT_SETTLEMENT_FAULT_NONE = 0, + OUTPUT_SETTLEMENT_FAULT_INITIAL_CREATE_GENERIC, + OUTPUT_SETTLEMENT_FAULT_INITIAL_PENDING_EXCEPTION, + OUTPUT_SETTLEMENT_FAULT_INITIAL_CALL_GENERIC_AFTER_CALL, + OUTPUT_SETTLEMENT_FAULT_INITIAL_CALL_PENDING_AFTER_CALL, + OUTPUT_SETTLEMENT_FAULT_FALLBACK_CALL_GENERIC, + OUTPUT_SETTLEMENT_FAULT_FALLBACK_PENDING_EXCEPTION, + OUTPUT_SETTLEMENT_FAULT_FALLBACK_CALL_GENERIC_AFTER_CALL, +} output_settlement_fault_t; + +typedef enum { + OUTPUT_EXCEPTION_CLEAR_FAULT_NONE = 0, + OUTPUT_EXCEPTION_CLEAR_FAULT_IS_PENDING, + OUTPUT_EXCEPTION_CLEAR_FAULT_GET_AND_CLEAR, +} output_exception_clear_fault_t; + +static output_settlement_fault_t g_test_next_output_settlement_fault = + OUTPUT_SETTLEMENT_FAULT_NONE; +static output_exception_clear_fault_t g_test_next_output_exception_clear_fault = + OUTPUT_EXCEPTION_CLEAR_FAULT_NONE; +static bool g_test_hold_next_output_delivery = false; +static bool g_test_output_delivery_held = false; +static bool g_test_release_output_delivery = false; +static uint64_t g_test_held_output_sequence = 0; +static size_t g_test_held_output_bytes = 0; + +typedef enum { + DETACH_SITE_NONE = 0, + DETACH_SITE_BRIDGE_FINALIZE, + DETACH_SITE_STREAM_WORKER, + DETACH_SITE_TRANSFORM_WORKER, + DETACH_SITE_CREATE_ENGINE, + DETACH_SITE_CREATE_ROLLBACK, + DETACH_SITE_RESOLVER_CREATE, + DETACH_SITE_UNKNOWN_DESTROY, + DETACH_SITE_SYNCHRONOUS_RUN, +} detach_site_t; + +// Process-lifetime test statistics and the one-shot detach fault arm. Every +// read/write is under g_mutex; only the N-API accessors are test-only exports. +static detach_site_t g_test_detach_failure_site = DETACH_SITE_NONE; +static uint64_t g_test_forced_detach_failures = 0; +static uint64_t g_test_isolate_creations = 0; +static uint64_t g_test_teardown_calls = 0; +static uint64_t g_test_abandoned_isolates = 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() @@ -220,6 +348,10 @@ typedef enum { CLEANUP_RETAIN, CLEANUP_UNRECOVERABLE, } cleanup_result_t; +typedef struct cleanup_thread_result { + cleanup_result_t outcome; + bool teardown_callable; +} cleanup_thread_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. @@ -247,6 +379,28 @@ static bool g_teardown_needed = false; static bool g_isolate_poisoned = false; static uv_cond_t g_teardown_cond; +// Test-only one-shot gate for a real streaming/transform worker. Admission has +// already reserved g_active_ops before the worker reaches this point, so holding +// it here lets tests drive cleanup() into TEARDOWN_PENDING_WAIT without entering +// through a native JS callback. Inert unless DATAWEAVE_TEST_HOOKS is enabled and +// __test_holdNextAsyncOp() armed the gate. +static void test_hold_async_op_if_armed(void) { + if (!g_test_hooks) return; + uv_mutex_lock(&g_mutex); + if (g_test_hold_next_async_op) { + g_test_hold_next_async_op = false; + g_test_async_op_held = true; + uv_cond_broadcast(&g_teardown_cond); + while (!g_test_release_async_op) { + uv_cond_wait(&g_teardown_cond, &g_mutex); + } + g_test_release_async_op = false; + g_test_async_op_held = false; + uv_cond_broadcast(&g_teardown_cond); + } + uv_mutex_unlock(&g_mutex); +} + // 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 @@ -258,12 +412,18 @@ static uv_cond_t g_teardown_cond; // 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) { + if (g_test_hooks && g_isolate != NULL) g_test_abandoned_isolates++; + if (g_test_engine_record_allocation_failure_generation == g_isolate_generation) { + g_test_engine_record_allocation_failure_generation = 0; + } g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; g_teardown_needed = false; g_isolate_poisoned = false; + g_detach_in_progress = 0; + g_detach_in_progress_generation = 0; 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 " @@ -290,11 +450,70 @@ static void poison_isolate_detach_failure_locked(int 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) { +// Wait while this generation has an ordinary detach whose result has not yet +// been published. Callers hold g_mutex. Each detach is covered by an active-op +// reservation, so waiting cannot let teardown overtake the detaching thread. +static void wait_for_detach_publication_locked(void) { + bool counted = false; + while (g_detach_in_progress > 0 && + g_detach_in_progress_generation == g_isolate_generation) { + if (g_test_hooks && !counted) { + g_detach_publication_waiters++; + counted = true; + uv_cond_broadcast(&g_teardown_cond); + } + uv_cond_wait(&g_teardown_cond, &g_mutex); + } + if (counted) { + g_detach_publication_waiters--; + uv_cond_broadcast(&g_teardown_cond); + } +} + +// Centralized policy for every ordinary operation detach. The real Graal +// detach always runs first. Test injection may turn only a successful detach +// at the selected site into one synthetic failure; real failures never consume +// the arm. Any nonzero result poisons this published isolate before later work +// can be admitted. +static int detach_thread_checked(detach_site_t site, void* thread) { + uv_mutex_lock(&g_mutex); + uint64_t generation = g_isolate_generation; + if (g_detach_in_progress == 0) { + g_detach_in_progress_generation = generation; + } + g_detach_in_progress++; + uv_mutex_unlock(&g_mutex); + + int detach_rc = fn_detach_thread(thread); uv_mutex_lock(&g_mutex); - poison_isolate_detach_failure_locked(detach_rc); + bool current_generation = generation == g_isolate_generation; + if (detach_rc == 0 && current_generation && g_test_hooks && + g_test_detach_failure_site == site) { + g_test_detach_failure_site = DETACH_SITE_NONE; + g_test_forced_detach_failures++; + detach_rc = -1; + } + if (current_generation && g_test_hooks && + g_test_hold_next_detach_publication) { + g_test_hold_next_detach_publication = false; + g_test_detach_publication_held = true; + uv_cond_broadcast(&g_teardown_cond); + while (!g_test_release_detach_publication) { + uv_cond_wait(&g_teardown_cond, &g_mutex); + } + g_test_release_detach_publication = false; + g_test_detach_publication_held = false; + } + if (detach_rc != 0 && current_generation) { + poison_isolate_detach_failure_locked(detach_rc); + } + if (generation == g_detach_in_progress_generation && g_detach_in_progress > 0) { + g_detach_in_progress--; + if (g_detach_in_progress == 0) g_detach_in_progress_generation = 0; + } + uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + return detach_rc; } // One node per cleanup() call that arrived while a teardown was already @@ -336,14 +555,149 @@ static void resolver_results_free_all(engine_bridge_t* b) { b->results = NULL; } -// Call under g_mutex. -static engine_bridge_t* bridge_find(long long handle) { +typedef struct bridge_finalize_action { + napi_threadsafe_function owner_cleanup_tsfn; + bool bridge_reclaimed; +} bridge_finalize_action_t; + +// Caller holds g_mutex. A call that can satisfy all three free predicates must +// be the caller's final operation through b. +static void bridge_release_if_unowned_locked( + engine_bridge_t* b, bridge_finalize_action_t* action) { + if (b == NULL || b->native_alive || b->owner_alive || + b->owner_cleanup_tsfn != NULL) return; + resolver_results_free_all(b); + if (g_test_hooks) { + g_test_bridge_frees++; + if (action != NULL) action->bridge_reclaimed = true; + } + free(b); +} + +// Executes the final owner-side action from stack state captured before any +// bridge reclamation. The probe is independent of b, so a reordered caller can +// report an action attempted after logical reclamation without touching freed +// memory. When bridge is non-NULL and no TSFN release is pending, reclamation is +// performed here as this function's final bridge operation. +static void bridge_finalize_execute_action( + bridge_finalize_action_t* action, engine_bridge_t* bridge) { + if (action == NULL) return; + if (action->bridge_reclaimed) { + uv_mutex_lock(&g_mutex); + g_test_post_reclamation_actions++; + uv_mutex_unlock(&g_mutex); + } + if (action->owner_cleanup_tsfn != NULL) { + napi_release_threadsafe_function( + action->owner_cleanup_tsfn, napi_tsfn_release); + return; + } + if (bridge != NULL) { + uv_mutex_lock(&g_mutex); + bridge_release_if_unowned_locked(bridge, action); + uv_mutex_unlock(&g_mutex); + } +} + +static void bridge_owner_cleanup_finalize( + napi_env env, void* finalize_data, void* finalize_hint) { + (void)env; + (void)finalize_hint; + engine_bridge_t* b = (engine_bridge_t*)finalize_data; + if (b == NULL) return; + uv_mutex_lock(&g_mutex); + b->owner_cleanup_tsfn = NULL; + if (b->owner_alive && b->resolver_js != NULL && g_test_hooks && + g_test_live_resolver_refs > 0) { + g_test_live_resolver_refs--; + } + b->owner_alive = false; + b->resolver_js = NULL; + bridge_release_if_unowned_locked(b, NULL); + uv_mutex_unlock(&g_mutex); +} + +static void call_js_bridge_owner_cleanup( + napi_env env, napi_value js_callback, void* context, void* data) { + (void)js_callback; + (void)data; + engine_bridge_t* b = (engine_bridge_t*)context; + if (b == NULL || env == NULL) return; + if (b->hook_registered) { + napi_remove_env_cleanup_hook(env, bridge_env_cleanup, b); + b->hook_registered = false; + } + bridge_finalize_free(b, /*env_still_alive=*/true); +} + +// Called on the bridge owner thread after its resolver reference is created. +// The unreferenced TSFN is an owner-env cleanup handoff: native drains may queue +// its JS callback from any thread, while env teardown invokes its finalizer. +static bool bridge_register_owner_cleanup(engine_bridge_t* b) { + if (b == NULL || b->resolver_js == NULL || b->env == NULL) return true; + napi_value resource_name; + if (napi_create_string_utf8( + b->env, "dwResolverOwnerCleanup", NAPI_AUTO_LENGTH, &resource_name) != napi_ok || + napi_create_threadsafe_function( + b->env, NULL, NULL, resource_name, 0, 1, b, + bridge_owner_cleanup_finalize, b, call_js_bridge_owner_cleanup, + &b->owner_cleanup_tsfn) != napi_ok) { + return false; + } + if (napi_unref_threadsafe_function(b->env, b->owner_cleanup_tsfn) != napi_ok) { + napi_release_threadsafe_function( + b->owner_cleanup_tsfn, napi_tsfn_abort); + b->owner_cleanup_released = true; + return false; + } + return true; +} + +static void bridge_release_native_and_handoff(engine_bridge_t* b) { + if (b == NULL) return; + uv_mutex_lock(&g_mutex); + if (b->owner_alive && b->resolver_js != NULL && + b->owner_cleanup_tsfn != NULL && !b->owner_cleanup_queued) { + b->owner_cleanup_queued = true; + // Queueing is thread-safe and never invokes the JS callback inline. Keep + // native ownership until the enqueue returns, so an env finalizer cannot + // free b between the ownership transition and this handoff. + napi_status status = napi_call_threadsafe_function( + b->owner_cleanup_tsfn, b, napi_tsfn_nonblocking); + if (status != napi_ok) b->owner_cleanup_queued = false; + } + b->native_alive = false; + bridge_release_if_unowned_locked(b, NULL); + uv_mutex_unlock(&g_mutex); +} + +// Call under g_mutex. Stale bridges stay owned by their env cleanup hooks, but +// only records from the currently published isolate may admit operations. +static engine_bridge_t* bridge_find_current(long long handle) { + for (engine_bridge_t* b = g_bridges; b != NULL; b = b->next) { + if (b->handle == handle && b->isolate_generation == g_isolate_generation) return b; + } + return NULL; +} + +// Destruction may reclaim a stale bridge, provided finalization does not touch +// the replacement isolate. Call under g_mutex. +static engine_bridge_t* bridge_find_any(long long handle) { for (engine_bridge_t* b = g_bridges; b != NULL; b = b->next) { if (b->handle == handle) return b; } return NULL; } +// Allocate the public handle while g_mutex is held. A zero result means the JS +// safe-integer handle space is exhausted. +static long long next_engine_handle_locked(void) { + if (g_next_engine_handle <= 0 || g_next_engine_handle > MAX_SAFE_ENGINE_HANDLE) return 0; + long long handle = g_next_engine_handle; + g_next_engine_handle = handle == MAX_SAFE_ENGINE_HANDLE ? 0 : handle + 1; + return handle; +} + // 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 @@ -358,7 +712,7 @@ static engine_bridge_t* bridge_find(long long handle) { // 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 +// bridge_find_current() 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 @@ -444,6 +798,10 @@ static int env_init_refs_total_locked(void) { // section: no teardown path can interleave between "isolate is live" and // "reservation taken". Callable from any thread NOT holding g_mutex. // +// `detach_site` identifies the operation whose successful attach/destroy is +// being detached; normal finalization uses bridge-finalize, while creation +// rollback keeps the distinct create-rollback fault-injection contract. +// // 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 @@ -452,8 +810,17 @@ static int env_init_refs_total_locked(void) { // 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) { +static bool bridge_finalize_registry_at_site(engine_bridge_t* b, detach_site_t detach_site) { if (b == NULL || fn_destroy_engine == NULL) return true; + uv_mutex_lock(&g_mutex); + bool stale_generation = g_isolate == NULL || + b->isolate_generation != g_isolate_generation; + uv_mutex_unlock(&g_mutex); + if (stale_generation) { + // The bridge's Java registry died with its old isolate. Never attach to + // a replacement isolate, where native_handle may identify a new engine. + 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 @@ -468,6 +835,7 @@ static bool bridge_finalize_registry(engine_bridge_t* b) { uv_mutex_unlock(&g_mutex); } uv_mutex_lock(&g_mutex); + wait_for_detach_publication_locked(); // 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, but report @@ -476,7 +844,8 @@ static bool bridge_finalize_registry(engine_bridge_t* b) { // 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) { + if (g_teardown_state == TEARDOWN_TEARING_DOWN || g_isolate == NULL || + b->isolate_generation != g_isolate_generation) { uv_mutex_unlock(&g_mutex); return true; } @@ -486,9 +855,8 @@ static bool bridge_finalize_registry(engine_bridge_t* b) { void* thread = NULL; bool destroyed = false; if (fn_attach_thread(g_isolate, &thread) == 0 && thread != NULL) { - fn_destroy_engine(thread, b->handle); - int detach_rc = fn_detach_thread(thread); - if (detach_rc != 0) poison_isolate_detach_failure(detach_rc); + fn_destroy_engine(thread, b->native_handle); + detach_thread_checked(detach_site, thread); destroyed = true; // registry entry removed -> resolver ctx is now dead } // else: attach failed while the isolate is STILL LIVE -- destroy was skipped, @@ -504,10 +872,9 @@ 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); +static bool bridge_finalize_registry(engine_bridge_t* b) { + return bridge_finalize_registry_at_site(b, DETACH_SITE_BRIDGE_FINALIZE); +} // 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 @@ -523,11 +890,29 @@ static void bridge_finalize_free(engine_bridge_t* b, bool env_still_alive) { if (g_test_hooks) { uv_mutex_lock(&g_mutex); g_test_resolver_ref_deletes++; + if (g_test_live_resolver_refs > 0) g_test_live_resolver_refs--; uv_mutex_unlock(&g_mutex); } } - resolver_results_free_all(b); - free(b); + bridge_finalize_action_t action = {0}; + uv_mutex_lock(&g_mutex); + if (env_still_alive || b->env == NULL || !b->owner_alive) { + b->resolver_js = NULL; + b->owner_alive = false; + } + if (env_still_alive && b->owner_cleanup_tsfn != NULL && + !b->owner_cleanup_released) { + b->owner_cleanup_released = true; + action.owner_cleanup_tsfn = b->owner_cleanup_tsfn; + } + b->native_alive = false; + uv_mutex_unlock(&g_mutex); + + // Releasing the TSFN lets its finalizer clear owner_cleanup_tsfn and perform + // the final bridge free. If no TSFN exists (resolver-less), the ownership + // drop below frees directly. In both cases the finalization action is the + // final bridge operation: never dereference b afterward. + bridge_finalize_execute_action(&action, b); } // Thin wrapper preserving the original signature and every call site. Registry @@ -603,10 +988,10 @@ static void drain_stranded_bridges(void) { 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); + // Registry entry removed (or isolate gone): release native ownership. + // The owner env hook remains responsible for deleting resolver_js and + // releasing owner ownership on its Node thread. + bridge_release_native_and_handoff(b); } else { // Still could not attach (isolate live, transient failure): keep the // ctx valid and retry at the next drain. @@ -639,6 +1024,15 @@ static void bridge_env_cleanup(void* arg) { if (*pp == b) { *pp = b->next; break; } pp = &(*pp)->next; } + engine_bridge_t** stranded_pp = &g_stranded_bridges; + while (*stranded_pp != NULL) { + if (*stranded_pp == b) { + *stranded_pp = b->next; + b->next = NULL; + break; + } + stranded_pp = &(*stranded_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 @@ -679,7 +1073,31 @@ static void bridge_env_cleanup(void* arg) { // 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); + if (b->resolver_js != NULL && b->env != NULL) { + napi_delete_reference(b->env, b->resolver_js); + b->resolver_js = NULL; + if (g_test_hooks) { + uv_mutex_lock(&g_mutex); + g_test_resolver_ref_deletes++; + if (g_test_live_resolver_refs > 0) g_test_live_resolver_refs--; + uv_mutex_unlock(&g_mutex); + } + } + b->owner_alive = false; + if (b->native_alive) { + if (bridge_finalize_registry(b)) { + uv_mutex_lock(&g_mutex); + b->native_alive = false; + bridge_release_if_unowned_locked(b, NULL); + uv_mutex_unlock(&g_mutex); + } else { + bridge_retain_stranded(b); + } + } else { + uv_mutex_lock(&g_mutex); + bridge_release_if_unowned_locked(b, NULL); + uv_mutex_unlock(&g_mutex); + } } // Increment this engine's in_flight while g_mutex is ALREADY held. Used by the @@ -690,7 +1108,7 @@ static void bridge_env_cleanup(void* arg) { // 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); + engine_bridge_t* b = bridge_find_current(handle); if (b != NULL) b->in_flight++; return b; } @@ -812,6 +1230,28 @@ static void init_thread_fn(void* arg) { return; } + if (g_isolate_generation == UINT64_MAX) { + if (g_test_hooks) { + g_test_isolate_creations++; + g_test_teardown_calls++; + } + int td_rc = fn_tear_down_isolate(boot_thread); + g_isolate = NULL; + g_thread = NULL; + if (td_rc != 0) { + fprintf(stderr, + "[DataWeave Node addon] isolate generation space was exhausted and " + "the unpublishable isolate could not be torn down; it is being leaked " + "for the process lifetime.\n"); + } + snprintf(args->error, sizeof(args->error), + "DataWeave isolate generation space exhausted"); + args->result = -4; + return; + } + g_isolate_generation++; + if (g_test_hooks) g_test_isolate_creations++; + // 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 @@ -836,7 +1276,11 @@ static void init_thread_fn(void* arg) { // 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; + int td_rc = -1; + if (fn_tear_down_isolate) { + if (g_test_hooks) g_test_teardown_calls++; + td_rc = fn_tear_down_isolate(boot_thread); + } if (td_rc != 0) { fprintf(stderr, "[DataWeave Node addon] bootstrap thread detach AND isolate " @@ -903,6 +1347,7 @@ static void cleanup_thread_fn(void* arg); static void retry_stranded_teardown_locked(void); static napi_value napi_initialize(napi_env env, napi_callback_info info) { + if (native_callback_active()) return throw_callback_reentrancy(env); size_t argc = 1; napi_value argv[1]; // Review #10 #5 (svacas P2): check napi_get_cb_info's status too, not just @@ -930,6 +1375,15 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { return NULL; } + uv_mutex_lock(&g_mutex); + wait_for_detach_publication_locked(); + bool poisoned_before_drain = g_isolate_poisoned; + uv_mutex_unlock(&g_mutex); + if (poisoned_before_drain) { + napi_throw_error(env, NULL, ISOLATE_POISONED_MESSAGE); + 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 // (drain_stranded_bridges locks internally). If a live isolate survives from a @@ -939,6 +1393,15 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { drain_stranded_bridges(); uv_mutex_lock(&g_mutex); + wait_for_detach_publication_locked(); + + // A detach failure is terminal for the currently published isolate. It may + // not be adopted or reused; explicit cleanup must abandon it first. + if (g_isolate_poisoned) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, ISOLATE_POISONED_MESSAGE); + return NULL; + } // 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 @@ -987,6 +1450,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 (g_isolate_poisoned) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, ISOLATE_POISONED_MESSAGE); + 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"); @@ -1008,6 +1476,11 @@ static napi_value napi_initialize(napi_env env, napi_callback_info info) { } if (g_initialized) { + if (g_isolate_poisoned) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, ISOLATE_POISONED_MESSAGE); + 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"); @@ -1065,18 +1538,22 @@ 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; - cleanup_result_t result = CLEANUP_RETAIN; + cleanup_thread_result_t result = {CLEANUP_RETAIN, false}; 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 (result == CLEANUP_TORN_DOWN) { + if (g_test_hooks && result.teardown_callable) g_test_teardown_calls++; + if (result.outcome == 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. + if (g_test_engine_record_allocation_failure_generation == g_isolate_generation) { + g_test_engine_record_allocation_failure_generation = 0; + } g_thread = NULL; g_isolate = NULL; g_initialized = 0; - } else if (result == CLEANUP_UNRECOVERABLE) { + } else if (result.outcome == 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 @@ -1123,116 +1600,1050 @@ static napi_value napi_initialize(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\"}"; +#define OUTPUT_HIGH_BYTES (1024 * 1024) +#define OUTPUT_LOW_BYTES (512 * 1024) +#define OUTPUT_HIGH_CHUNKS 128 +#define OUTPUT_LOW_CHUNKS 64 +#define OUTPUT_TSFN_QUEUE_SIZE 129 + +typedef struct output_credit { + size_t bytes; + uint64_t sequence; + bool delivered; + bool acknowledged; + struct output_credit* previous; + struct output_credit* next; +} output_credit_t; + +typedef struct output_flow { + uv_mutex_t mutex; + uv_cond_t cond; + size_t outstanding_bytes; + size_t outstanding_chunks; + size_t peak_buffered_bytes; + size_t peak_buffered_chunks; + size_t largest_chunk_bytes; + bool paused; + bool cancelled; + bool done; + unsigned int refs; + uint64_t operation_id; + uint64_t next_sequence; + output_credit_t* credit_head; + output_credit_t* credit_tail; + napi_ref thenable_ref; + napi_ref settlement_fallback_ref; + bool settlement_started; +} output_flow_t; + +typedef struct output_flow_stats { + uint64_t operation_id; + size_t outstanding_bytes; + size_t outstanding_chunks; + size_t peak_buffered_bytes; + size_t peak_buffered_chunks; + size_t largest_chunk_bytes; + bool paused; + bool cancelled; + bool done; + long long live_flows; +} output_flow_stats_t; + +// Test hooks store a value snapshot, never a flow pointer, so close/finalize +// cannot leave introspection pointing at freed operation state. +static output_flow_stats_t g_test_last_output_stats; +static uint64_t g_test_next_output_operation_id = 1; +static long long g_test_live_output_flows = 0; + +static void output_flow_record_stats_locked(output_flow_t* flow) { + if (!g_test_hooks || flow == NULL) return; + uv_mutex_lock(&g_test_output_mutex); + g_test_last_output_stats.operation_id = flow->operation_id; + g_test_last_output_stats.outstanding_bytes = flow->outstanding_bytes; + g_test_last_output_stats.outstanding_chunks = flow->outstanding_chunks; + g_test_last_output_stats.peak_buffered_bytes = flow->peak_buffered_bytes; + g_test_last_output_stats.peak_buffered_chunks = flow->peak_buffered_chunks; + g_test_last_output_stats.largest_chunk_bytes = flow->largest_chunk_bytes; + g_test_last_output_stats.paused = flow->paused; + g_test_last_output_stats.cancelled = flow->cancelled; + g_test_last_output_stats.done = flow->done; + g_test_last_output_stats.live_flows = g_test_live_output_flows; + uv_mutex_unlock(&g_test_output_mutex); +} -// chunk_data with len == -1 is a sentinel indicating completion (buf holds meta JSON) -struct chunk_data { - char* buf; - int len; -}; +static output_flow_t* output_flow_create(void) { + output_flow_t* flow = (output_flow_t*)calloc(1, sizeof(output_flow_t)); + if (flow == NULL) return NULL; + if (uv_mutex_init(&flow->mutex) != 0) { + free(flow); + return NULL; + } + if (uv_cond_init(&flow->cond) != 0) { + uv_mutex_destroy(&flow->mutex); + free(flow); + return NULL; + } + flow->refs = 1; + if (g_test_hooks) { + uv_mutex_lock(&g_test_output_mutex); + flow->operation_id = g_test_next_output_operation_id++; + g_test_live_output_flows++; + memset(&g_test_last_output_stats, 0, sizeof(g_test_last_output_stats)); + g_test_last_output_stats.operation_id = flow->operation_id; + g_test_last_output_stats.live_flows = g_test_live_output_flows; + uv_mutex_unlock(&g_test_output_mutex); + } + return flow; +} -struct streaming_work { - uv_thread_t tid; - napi_threadsafe_function tsfn; - napi_deferred deferred; - long long handle; - char* script; - char* inputs_json; - // 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; - // 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 output_flow_retain(output_flow_t* flow) { + if (flow == NULL) return; + uv_mutex_lock(&flow->mutex); + flow->refs++; + uv_mutex_unlock(&flow->mutex); +} -static void call_js_write(napi_env env, napi_value js_callback, void* context, void* data) { - // 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; +static void output_flow_release(output_flow_t* flow, napi_env env) { + if (flow == NULL) return; + bool destroy = false; + uv_mutex_lock(&flow->mutex); + if (flow->refs > 0) { + flow->refs--; + destroy = flow->refs == 0; + } + uv_mutex_unlock(&flow->mutex); + if (!destroy) return; + // A dead env auto-reclaims N-API references. Live-env terminal paths delete + // them before releasing the final native owner. + flow->thenable_ref = NULL; + if (env != NULL && flow->settlement_fallback_ref != NULL) { + napi_delete_reference(env, flow->settlement_fallback_ref); + } + flow->settlement_fallback_ref = NULL; - if (chunk->len == -1) { - // 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); + if (g_test_hooks) { + uv_mutex_lock(&g_test_output_mutex); + g_test_live_output_flows--; + if (g_test_last_output_stats.operation_id == flow->operation_id) { + g_test_last_output_stats.live_flows = g_test_live_output_flows; } + uv_mutex_unlock(&g_test_output_mutex); + } + output_credit_t* credit = flow->credit_head; + while (credit != NULL) { + output_credit_t* next = credit->next; + free(credit); + credit = next; + } + uv_cond_destroy(&flow->cond); + uv_mutex_destroy(&flow->mutex); + free(flow); +} - if (chunk->buf != OOM_JSON) free(chunk->buf); - free(chunk); - free(w->script); - free(w->inputs_json); +// Only the native producer waits here. No caller holds g_mutex while waiting. +static bool output_flow_reserve( + output_flow_t* flow, size_t bytes, uint64_t* sequence_out) { + if (flow == NULL) return false; + output_credit_t* credit = (output_credit_t*)calloc(1, sizeof(output_credit_t)); + if (credit == NULL) return false; + credit->bytes = bytes; + + uv_mutex_lock(&flow->mutex); + if (flow->cancelled || flow->done) { + flow->paused = false; + output_flow_record_stats_locked(flow); + uv_mutex_unlock(&flow->mutex); + free(credit); + return false; + } + bool empty = flow->outstanding_bytes == 0 && flow->outstanding_chunks == 0; + bool oversized = bytes > OUTPUT_HIGH_BYTES; + bool oversized_empty = empty && oversized; + bool over_high = + flow->outstanding_chunks + 1 > OUTPUT_HIGH_CHUNKS || + bytes > SIZE_MAX - flow->outstanding_bytes || + flow->outstanding_bytes + bytes > OUTPUT_HIGH_BYTES; + if (over_high && !oversized_empty) { + flow->paused = true; + output_flow_record_stats_locked(flow); + while (!flow->cancelled && !flow->done && + ((oversized && + (flow->outstanding_bytes > 0 || flow->outstanding_chunks > 0)) || + flow->outstanding_bytes > OUTPUT_LOW_BYTES || + flow->outstanding_chunks > OUTPUT_LOW_CHUNKS)) { + uv_cond_wait(&flow->cond, &flow->mutex); + } + } - 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. 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; + if (flow->cancelled || flow->done) { + flow->paused = false; + output_flow_record_stats_locked(flow); + uv_mutex_unlock(&flow->mutex); + free(credit); + return false; } - // 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; + flow->paused = false; + // The overflow check above routes a huge reservation through the oversized + // wait, which drains prior credit; ordinary reservations are already bounded. + flow->outstanding_bytes += bytes; + flow->outstanding_chunks++; + credit->sequence = ++flow->next_sequence; + credit->previous = flow->credit_tail; + if (flow->credit_tail != NULL) flow->credit_tail->next = credit; + else flow->credit_head = credit; + flow->credit_tail = credit; + if (flow->outstanding_bytes > flow->peak_buffered_bytes) { + flow->peak_buffered_bytes = flow->outstanding_bytes; + } + if (flow->outstanding_chunks > flow->peak_buffered_chunks) { + flow->peak_buffered_chunks = flow->outstanding_chunks; } + if (bytes > flow->largest_chunk_bytes) flow->largest_chunk_bytes = bytes; + output_flow_record_stats_locked(flow); + *sequence_out = credit->sequence; + uv_mutex_unlock(&flow->mutex); + return true; +} - napi_value buffer; - void* buf_data; - napi_create_buffer_copy(env, chunk->len, chunk->buf, &buf_data, &buffer); +typedef enum { + OUTPUT_ACK_IGNORED = 0, + OUTPUT_ACK_ACCEPTED, + OUTPUT_ACK_INVALID_SEQUENCE, + OUTPUT_ACK_NOT_DELIVERED, + OUTPUT_ACK_OUT_OF_ORDER, + OUTPUT_ACK_BYTES_MISMATCH, + OUTPUT_ACK_DUPLICATE, +} output_ack_result_t; + +static output_ack_result_t output_flow_acknowledge( + output_flow_t* flow, uint64_t sequence, size_t bytes) { + if (flow == NULL) return OUTPUT_ACK_IGNORED; + uv_mutex_lock(&flow->mutex); + if (flow->cancelled) { + output_flow_record_stats_locked(flow); + uv_mutex_unlock(&flow->mutex); + return OUTPUT_ACK_IGNORED; + } - napi_value global; - napi_get_global(env, &global); - napi_call_function(env, global, js_callback, 1, &buffer, NULL); + output_credit_t* credit = flow->credit_head; + output_credit_t* requested = credit; + while (requested != NULL && requested->sequence != sequence) { + requested = requested->next; + } + output_ack_result_t result; + if (requested == NULL) { + result = flow->done + ? OUTPUT_ACK_IGNORED + : sequence <= flow->next_sequence + ? OUTPUT_ACK_DUPLICATE + : OUTPUT_ACK_INVALID_SEQUENCE; + } else if (!requested->delivered) { + result = OUTPUT_ACK_NOT_DELIVERED; + } else if (requested != credit) { + result = OUTPUT_ACK_OUT_OF_ORDER; + } else if (requested->bytes != bytes) { + result = OUTPUT_ACK_BYTES_MISMATCH; + } else if (requested->acknowledged) { + result = OUTPUT_ACK_DUPLICATE; + } else if (bytes <= flow->outstanding_bytes && flow->outstanding_chunks > 0) { + requested->acknowledged = true; + flow->outstanding_bytes -= bytes; + flow->outstanding_chunks--; + flow->credit_head = requested->next; + if (flow->credit_head != NULL) flow->credit_head->previous = NULL; + else flow->credit_tail = NULL; + free(requested); + if (flow->paused && + flow->outstanding_bytes <= OUTPUT_LOW_BYTES && + flow->outstanding_chunks <= OUTPUT_LOW_CHUNKS) { + uv_cond_broadcast(&flow->cond); + } + result = OUTPUT_ACK_ACCEPTED; + } else { + result = OUTPUT_ACK_INVALID_SEQUENCE; + } + output_flow_record_stats_locked(flow); + uv_mutex_unlock(&flow->mutex); + return result; +} - free(chunk->buf); - free(chunk); +static bool output_flow_mark_delivered(output_flow_t* flow, uint64_t sequence) { + if (flow == NULL) return false; + uv_mutex_lock(&flow->mutex); + output_credit_t* credit = flow->credit_head; + while (credit != NULL && credit->sequence != sequence) credit = credit->next; + bool delivered = !flow->cancelled && !flow->done && credit != NULL; + if (delivered) credit->delivered = true; + output_flow_record_stats_locked(flow); + uv_mutex_unlock(&flow->mutex); + return delivered; } -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 +static bool output_flow_is_cancelled(output_flow_t* flow); + +static bool test_hold_output_delivery_if_armed( + output_flow_t* flow, uint64_t sequence, size_t bytes) { + if (!g_test_hooks) return true; + bool cancelled = false; + uv_mutex_lock(&g_test_output_mutex); + if (g_test_hold_next_output_delivery) { + g_test_hold_next_output_delivery = false; + g_test_output_delivery_held = true; + g_test_held_output_sequence = sequence; + g_test_held_output_bytes = bytes; + while (!g_test_release_output_delivery) { + uv_mutex_unlock(&g_test_output_mutex); + cancelled = output_flow_is_cancelled(flow); + if (!cancelled) uv_sleep(1); + uv_mutex_lock(&g_test_output_mutex); + if (cancelled) break; + } + g_test_release_output_delivery = false; + g_test_output_delivery_held = false; + g_test_held_output_sequence = 0; + g_test_held_output_bytes = 0; + } + uv_mutex_unlock(&g_test_output_mutex); + return !cancelled && !output_flow_is_cancelled(flow); +} + +// Enqueue/allocation rollback always targets the newest reservation because +// callbacks reserve and enqueue serially on the sole producer worker. +static void output_flow_rollback( + output_flow_t* flow, uint64_t sequence, size_t bytes) { + if (flow == NULL) return; + uv_mutex_lock(&flow->mutex); + output_credit_t* credit = flow->credit_tail; + if (credit != NULL && credit->sequence == sequence && credit->bytes == bytes && + bytes <= flow->outstanding_bytes && flow->outstanding_chunks > 0) { + flow->outstanding_bytes -= bytes; + flow->outstanding_chunks--; + flow->credit_tail = credit->previous; + if (flow->credit_tail != NULL) flow->credit_tail->next = NULL; + else flow->credit_head = NULL; + free(credit); + } + uv_cond_broadcast(&flow->cond); + output_flow_record_stats_locked(flow); + uv_mutex_unlock(&flow->mutex); +} + +static void output_flow_cancel_locked(output_flow_t* flow) { + if (!flow->cancelled) { + flow->cancelled = true; + flow->paused = false; + flow->outstanding_bytes = 0; + flow->outstanding_chunks = 0; + output_credit_t* credit = flow->credit_head; + while (credit != NULL) { + output_credit_t* next = credit->next; + free(credit); + credit = next; + } + flow->credit_head = NULL; + flow->credit_tail = NULL; + } + uv_cond_broadcast(&flow->cond); +} + +static void output_flow_cancel(output_flow_t* flow) { + if (flow == NULL) return; + uv_mutex_lock(&flow->mutex); + output_flow_cancel_locked(flow); + output_flow_record_stats_locked(flow); + uv_mutex_unlock(&flow->mutex); +} + +static void output_flow_cancel_if_running(output_flow_t* flow) { + if (flow == NULL) return; + uv_mutex_lock(&flow->mutex); + if (!flow->done) output_flow_cancel_locked(flow); + output_flow_record_stats_locked(flow); + uv_mutex_unlock(&flow->mutex); +} + +static bool output_flow_is_cancelled(output_flow_t* flow) { + if (flow == NULL) return true; + uv_mutex_lock(&flow->mutex); + bool cancelled = flow->cancelled; + uv_mutex_unlock(&flow->mutex); + return cancelled; +} + +static void output_flow_retain_thenable( + output_flow_t* flow, napi_env env, napi_value controller) { + if (flow == NULL || env == NULL) return; + uv_mutex_lock(&flow->mutex); + if (!flow->done && flow->thenable_ref == NULL) { + napi_ref thenable_ref = NULL; + if (napi_create_reference(env, controller, 1, &thenable_ref) == napi_ok) { + flow->thenable_ref = thenable_ref; + } + } + uv_mutex_unlock(&flow->mutex); +} + +static bool output_flow_begin_settlement(output_flow_t* flow) { + if (flow == NULL) return false; + uv_mutex_lock(&flow->mutex); + bool begin = !flow->settlement_started; + if (begin) flow->settlement_started = true; + uv_mutex_unlock(&flow->mutex); + return begin; +} + +static void output_flow_release_settlement_ref(output_flow_t* flow, napi_env env) { + if (flow == NULL || env == NULL) return; + uv_mutex_lock(&flow->mutex); + napi_ref fallback_ref = flow->settlement_fallback_ref; + flow->settlement_fallback_ref = NULL; + uv_mutex_unlock(&flow->mutex); + if (fallback_ref != NULL) napi_delete_reference(env, fallback_ref); +} + +static void output_flow_mark_done(output_flow_t* flow, napi_env env) { + if (flow == NULL) return; + napi_ref thenable_ref = NULL; + uv_mutex_lock(&flow->mutex); + flow->done = true; + flow->paused = false; + thenable_ref = flow->thenable_ref; + flow->thenable_ref = NULL; + uv_cond_broadcast(&flow->cond); + output_flow_record_stats_locked(flow); + uv_mutex_unlock(&flow->mutex); + // The reference is only created on this env's JS thread. env == NULL means + // teardown owns automatic N-API reference reclamation. + if (env != NULL && thenable_ref != NULL) napi_delete_reference(env, thenable_ref); +} + +typedef struct output_controller { + uv_mutex_t mutex; + output_flow_t* flow; + bool closed; + uint64_t operation_id; +} output_controller_t; + +static const napi_type_tag OUTPUT_CONTROLLER_TAG = { + 0x9d29436c7a6848e1ULL, + 0xa8e6af21e52d879bULL, +}; + +static void output_controller_cancel(output_controller_t* holder) { + if (holder == NULL) return; + uv_mutex_lock(&holder->mutex); + output_flow_t* flow = holder->flow; + if (flow != NULL) output_flow_retain(flow); + uv_mutex_unlock(&holder->mutex); + if (flow != NULL) { + output_flow_cancel(flow); + output_flow_release(flow, NULL); + } +} + +static void output_controller_close(output_controller_t* holder, napi_env env) { + if (holder == NULL) return; + uv_mutex_lock(&holder->mutex); + output_flow_t* flow = NULL; + if (!holder->closed) { + holder->closed = true; + flow = holder->flow; + holder->flow = NULL; + } + uv_mutex_unlock(&holder->mutex); + if (flow != NULL) { + output_flow_cancel_if_running(flow); + output_flow_release(flow, env); + } +} + +static void output_controller_finalize(napi_env env, void* data, void* hint) { + (void)hint; + output_controller_t* holder = (output_controller_t*)data; + if (holder == NULL) return; + output_controller_cancel(holder); + output_controller_close(holder, env); + uv_mutex_destroy(&holder->mutex); + free(holder); +} + +static output_controller_t* output_controller_unwrap( + napi_env env, napi_callback_info info, size_t expected_argc, napi_value* argv) { + napi_value this_arg; + size_t actual = expected_argc; + if (napi_get_cb_info(env, info, &actual, argv, &this_arg, NULL) != napi_ok) { + napi_throw_type_error(env, NULL, "Invalid output controller invocation"); + return NULL; + } + if (actual < expected_argc) { + napi_throw_type_error(env, NULL, "Missing output controller argument"); + return NULL; + } + bool tagged = false; + if (napi_check_object_type_tag(env, this_arg, &OUTPUT_CONTROLLER_TAG, &tagged) != napi_ok || + !tagged) { + napi_throw_type_error(env, NULL, "Invalid output controller receiver"); + return NULL; + } + output_controller_t* holder = NULL; + if (napi_unwrap(env, this_arg, (void**)&holder) != napi_ok || holder == NULL) { + napi_throw_type_error(env, NULL, "Invalid output controller receiver"); + return NULL; + } + return holder; +} + +static napi_value napi_output_acknowledge(napi_env env, napi_callback_info info) { + napi_value argv[2]; + output_controller_t* holder = output_controller_unwrap(env, info, 2, argv); + if (holder == NULL) return NULL; + napi_valuetype type; + uint64_t sequence; + double value; + bool lossless = false; + if (napi_typeof(env, argv[0], &type) != napi_ok || type != napi_bigint || + napi_get_value_bigint_uint64(env, argv[0], &sequence, &lossless) != napi_ok || + !lossless || sequence == 0) { + napi_throw_range_error(env, NULL, + "acknowledge(sequence, bytes) requires a positive uint64 BigInt sequence"); + return NULL; + } + if (napi_typeof(env, argv[1], &type) != napi_ok || type != napi_number || + napi_get_value_double(env, argv[1], &value) != napi_ok || + value != value || value < 0 || value > 9007199254740991.0 || + value > (double)SIZE_MAX || value != (double)(size_t)value) { + napi_throw_range_error(env, NULL, + "acknowledge(sequence, bytes) requires finite non-negative safe integer bytes"); + return NULL; + } + uv_mutex_lock(&holder->mutex); + output_flow_t* flow = holder->flow; + if (flow != NULL) output_flow_retain(flow); + uv_mutex_unlock(&holder->mutex); + if (flow != NULL) { + output_ack_result_t result = output_flow_acknowledge(flow, sequence, (size_t)value); + output_flow_release(flow, NULL); + switch (result) { + case OUTPUT_ACK_IGNORED: + case OUTPUT_ACK_ACCEPTED: + return NULL; + case OUTPUT_ACK_INVALID_SEQUENCE: + napi_throw_range_error(env, NULL, "Unknown output sequence"); + return NULL; + case OUTPUT_ACK_NOT_DELIVERED: + napi_throw_error(env, NULL, "Output sequence has not been delivered"); + return NULL; + case OUTPUT_ACK_OUT_OF_ORDER: + napi_throw_error(env, NULL, "Output acknowledgement is out of order"); + return NULL; + case OUTPUT_ACK_BYTES_MISMATCH: + napi_throw_range_error(env, NULL, "Output acknowledgement byte count does not match"); + return NULL; + case OUTPUT_ACK_DUPLICATE: + napi_throw_error(env, NULL, "Output sequence was already acknowledged"); + return NULL; + } + } + return NULL; +} + +static napi_value napi_output_cancel(napi_env env, napi_callback_info info) { + output_controller_t* holder = output_controller_unwrap(env, info, 0, NULL); + if (holder == NULL) return NULL; + output_controller_cancel(holder); + return NULL; +} + +static napi_value napi_output_close(napi_env env, napi_callback_info info) { + output_controller_t* holder = output_controller_unwrap(env, info, 0, NULL); + if (holder == NULL) return NULL; + output_controller_close(holder, env); + return NULL; +} + +static napi_value napi_output_promise_method(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + napi_value controller; + void* data; + if (napi_get_cb_info(env, info, &argc, argv, &controller, &data) != napi_ok) { + napi_throw_type_error(env, NULL, "Invalid output controller promise method"); + return NULL; + } + bool tagged = false; + if (napi_check_object_type_tag(env, controller, &OUTPUT_CONTROLLER_TAG, &tagged) != napi_ok || + !tagged) { + napi_throw_type_error(env, NULL, "Invalid output controller receiver"); + return NULL; + } + const char* name = (const char*)data; + napi_value completion; + napi_value method; + napi_value result; + if (napi_get_named_property(env, controller, "completion", &completion) != napi_ok || + napi_get_named_property(env, completion, name, &method) != napi_ok || + napi_call_function(env, completion, method, argc, argv, &result) != napi_ok) { + return NULL; + } + + output_controller_t* holder = NULL; + if (napi_unwrap(env, controller, (void**)&holder) != napi_ok || holder == NULL) { + napi_throw_type_error(env, NULL, "Invalid output controller receiver"); + return NULL; + } + uv_mutex_lock(&holder->mutex); + output_flow_t* flow = holder->flow; + if (flow != NULL) output_flow_retain(flow); + uv_mutex_unlock(&holder->mutex); + if (flow != NULL) { + // Retain only after the Promise method call succeeds. Failed assimilation + // must not root a controller that no caller can use to close the flow. + output_flow_retain_thenable(flow, env, controller); + output_flow_release(flow, NULL); + } + return result; +} + +static napi_value output_controller_create( + napi_env env, napi_value completion, output_flow_t* flow) { + output_controller_t* holder = (output_controller_t*)calloc(1, sizeof(output_controller_t)); + if (holder == NULL || uv_mutex_init(&holder->mutex) != 0) { + free(holder); + napi_throw_error(env, NULL, "OOM"); + return NULL; + } + holder->flow = flow; + holder->operation_id = flow->operation_id; + output_flow_retain(flow); // JS ownership, released once by close/finalizer. + + napi_value controller; + napi_value method; + if (napi_create_object(env, &controller) != napi_ok || + napi_set_named_property(env, controller, "completion", completion) != napi_ok || + napi_create_function(env, "acknowledge", NAPI_AUTO_LENGTH, + napi_output_acknowledge, NULL, &method) != napi_ok || + napi_set_named_property(env, controller, "acknowledge", method) != napi_ok || + napi_create_function(env, "cancel", NAPI_AUTO_LENGTH, + napi_output_cancel, NULL, &method) != napi_ok || + napi_set_named_property(env, controller, "cancel", method) != napi_ok || + napi_create_function(env, "close", NAPI_AUTO_LENGTH, + napi_output_close, NULL, &method) != napi_ok || + napi_set_named_property(env, controller, "close", method) != napi_ok || + napi_create_function(env, "then", NAPI_AUTO_LENGTH, + napi_output_promise_method, (void*)"then", &method) != napi_ok || + napi_set_named_property(env, controller, "then", method) != napi_ok || + napi_create_function(env, "catch", NAPI_AUTO_LENGTH, + napi_output_promise_method, (void*)"catch", &method) != napi_ok || + napi_set_named_property(env, controller, "catch", method) != napi_ok || + napi_create_function(env, "finally", NAPI_AUTO_LENGTH, + napi_output_promise_method, (void*)"finally", &method) != napi_ok || + napi_set_named_property(env, controller, "finally", method) != napi_ok) { + output_controller_cancel(holder); + output_controller_close(holder, env); + uv_mutex_destroy(&holder->mutex); + free(holder); + napi_throw_error(env, NULL, "Failed to create output controller"); + return NULL; + } + if (napi_wrap(env, controller, holder, output_controller_finalize, NULL, NULL) != napi_ok) { + output_controller_cancel(holder); + output_controller_close(holder, env); + uv_mutex_destroy(&holder->mutex); + free(holder); + napi_throw_error(env, NULL, "Failed to wrap output controller"); + return NULL; + } + if (napi_type_tag_object(env, controller, &OUTPUT_CONTROLLER_TAG) != napi_ok) { + void* removed = NULL; + napi_remove_wrap(env, controller, &removed); + output_controller_cancel(holder); + output_controller_close(holder, env); + uv_mutex_destroy(&holder->mutex); + free(holder); + napi_throw_error(env, NULL, "Failed to tag output controller"); + return NULL; + } + return controller; +} + +// 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\"}"; +static const char SETTLEMENT_ERROR_JSON[] = + "{\"success\":false,\"error\":\"Failed to settle native output completion\"}"; + +static output_settlement_fault_t test_consume_output_settlement_fault(void) { + if (!g_test_hooks) return OUTPUT_SETTLEMENT_FAULT_NONE; + uv_mutex_lock(&g_test_output_mutex); + output_settlement_fault_t fault = g_test_next_output_settlement_fault; + g_test_next_output_settlement_fault = OUTPUT_SETTLEMENT_FAULT_NONE; + uv_mutex_unlock(&g_test_output_mutex); + return fault; +} + +typedef enum { + OUTPUT_EXCEPTION_CLEARED, + OUTPUT_EXCEPTION_NOT_PENDING, + OUTPUT_EXCEPTION_CLEAR_FAILED, +} output_exception_clear_result_t; + +static output_exception_clear_result_t clear_pending_exception(napi_env env) { + output_exception_clear_fault_t fault = OUTPUT_EXCEPTION_CLEAR_FAULT_NONE; + if (g_test_hooks) { + uv_mutex_lock(&g_test_output_mutex); + fault = g_test_next_output_exception_clear_fault; + g_test_next_output_exception_clear_fault = OUTPUT_EXCEPTION_CLEAR_FAULT_NONE; + uv_mutex_unlock(&g_test_output_mutex); + } + bool pending = false; + if (fault == OUTPUT_EXCEPTION_CLEAR_FAULT_IS_PENDING || + napi_is_exception_pending(env, &pending) != napi_ok) { + return OUTPUT_EXCEPTION_CLEAR_FAILED; + } + if (!pending) { + return OUTPUT_EXCEPTION_NOT_PENDING; + } + napi_value exception; + if (fault == OUTPUT_EXCEPTION_CLEAR_FAULT_GET_AND_CLEAR || + napi_get_and_clear_last_exception(env, &exception) != napi_ok) { + fprintf(stderr, + "[DataWeave Node addon] Failed to clear an output settlement exception.\n"); + return OUTPUT_EXCEPTION_CLEAR_FAILED; + } + return OUTPUT_EXCEPTION_CLEARED; +} + +static napi_status settle_output_fallback( + napi_env env, napi_deferred deferred, napi_ref fallback_ref, + bool* conclude_called) { + *conclude_called = false; + napi_value holder; + napi_status status = napi_get_reference_value(env, fallback_ref, &holder); + if (status != napi_ok) return status; + napi_value fallback; + status = napi_get_named_property(env, holder, "value", &fallback); + if (status != napi_ok) return status; + *conclude_called = true; + return napi_resolve_deferred(env, deferred, fallback); +} + +static void output_settlement_fail_closed(napi_status status) { + // Node frees a deferred whenever napi_resolve_deferred/reject_deferred is + // invoked, including failure returns. Continuing would either reuse freed + // memory or leave callers waiting forever, so terminate deterministically. + char message[128]; + int length = snprintf( + message, sizeof(message), + "Output completion settlement failed after deferred consumption (napi status %d)", + (int)status + ); + size_t message_length = length > 0 + ? ((size_t)length < sizeof(message) ? (size_t)length : sizeof(message) - 1) + : 0; + static const char location[] = "DataWeave Node addon"; + napi_fatal_error( + location, sizeof(location) - 1, message, message_length + ); +} + +static napi_status settle_output_deferred( + napi_env env, napi_deferred deferred, output_flow_t* flow, + const char* result_json) { + if (env == NULL || flow == NULL || !output_flow_begin_settlement(flow)) { + return napi_ok; + } + + output_settlement_fault_t fault = test_consume_output_settlement_fault(); + napi_value result; + bool fail_initial = + fault == OUTPUT_SETTLEMENT_FAULT_INITIAL_CREATE_GENERIC || + fault == OUTPUT_SETTLEMENT_FAULT_FALLBACK_CALL_GENERIC || + fault == OUTPUT_SETTLEMENT_FAULT_FALLBACK_PENDING_EXCEPTION || + fault == OUTPUT_SETTLEMENT_FAULT_FALLBACK_CALL_GENERIC_AFTER_CALL; + napi_status status = fail_initial + ? napi_generic_failure + : napi_create_string_utf8(env, result_json, strlen(result_json), &result); + if (status == napi_ok) { + if (fault == OUTPUT_SETTLEMENT_FAULT_INITIAL_PENDING_EXCEPTION) { + napi_throw_error(env, NULL, "Injected initial output settlement exception"); + status = napi_pending_exception; + } else { + status = napi_resolve_deferred(env, deferred, result); + if (status == napi_ok && + fault == OUTPUT_SETTLEMENT_FAULT_INITIAL_CALL_GENERIC_AFTER_CALL) { + status = napi_generic_failure; + } else if (status == napi_ok && + fault == OUTPUT_SETTLEMENT_FAULT_INITIAL_CALL_PENDING_AFTER_CALL) { + napi_throw_error(env, NULL, "Injected consumed output settlement exception"); + status = napi_pending_exception; + } + if (status != napi_ok) { + if (status == napi_pending_exception) { + output_exception_clear_result_t clear_result = + clear_pending_exception(env); + if (clear_result == OUTPUT_EXCEPTION_CLEAR_FAILED) { + output_settlement_fail_closed(status); + } + if (clear_result == OUTPUT_EXCEPTION_NOT_PENDING) { + output_flow_release_settlement_ref(flow, env); + return status; + } + } + output_settlement_fail_closed(status); + } + output_flow_release_settlement_ref(flow, env); + return napi_ok; + } + } + if (status == napi_pending_exception) { + output_exception_clear_result_t clear_result = clear_pending_exception(env); + if (clear_result == OUTPUT_EXCEPTION_CLEAR_FAILED) { + output_settlement_fail_closed(status); + } + if (clear_result == OUTPUT_EXCEPTION_NOT_PENDING) { + output_flow_release_settlement_ref(flow, env); + return status; + } + } + + bool conclude_called = false; + if (fault == OUTPUT_SETTLEMENT_FAULT_FALLBACK_PENDING_EXCEPTION) { + napi_throw_error(env, NULL, "Injected fallback output settlement exception"); + status = napi_pending_exception; + } else if (fault == OUTPUT_SETTLEMENT_FAULT_FALLBACK_CALL_GENERIC) { + status = napi_generic_failure; + } else { + status = settle_output_fallback( + env, deferred, flow->settlement_fallback_ref, &conclude_called + ); + } + if (status == napi_ok && + fault == OUTPUT_SETTLEMENT_FAULT_FALLBACK_CALL_GENERIC_AFTER_CALL) { + status = napi_generic_failure; + } + if (status == napi_ok) { + output_flow_release_settlement_ref(flow, env); + return napi_ok; + } + if (status == napi_pending_exception) { + output_exception_clear_result_t clear_result = clear_pending_exception(env); + if (clear_result == OUTPUT_EXCEPTION_CLEAR_FAILED) { + output_settlement_fail_closed(status); + } + if (clear_result == OUTPUT_EXCEPTION_NOT_PENDING) { + output_flow_release_settlement_ref(flow, env); + return status; + } + } + if (conclude_called) output_settlement_fail_closed(status); + + status = settle_output_fallback( + env, deferred, flow->settlement_fallback_ref, &conclude_called + ); + if (status != napi_ok) { + if (status == napi_pending_exception) { + output_exception_clear_result_t clear_result = clear_pending_exception(env); + if (clear_result == OUTPUT_EXCEPTION_CLEAR_FAILED) { + output_settlement_fail_closed(status); + } + if (clear_result == OUTPUT_EXCEPTION_NOT_PENDING) { + output_flow_release_settlement_ref(flow, env); + return status; + } + } + output_settlement_fail_closed(status); + } + output_flow_release_settlement_ref(flow, env); + return napi_ok; +} + +static bool prepare_output_settlement(napi_env env, output_flow_t* flow) { + napi_value fallback; + if (napi_create_string_utf8( + env, SETTLEMENT_ERROR_JSON, NAPI_AUTO_LENGTH, &fallback) != napi_ok) { + return false; + } + // N-API v8 cannot retain a primitive string directly, so keep it reachable + // through a referenced object before the asynchronous operation starts. + napi_value holder; + if (napi_create_object(env, &holder) != napi_ok || + napi_set_named_property(env, holder, "value", fallback) != napi_ok) { + return false; + } + return napi_create_reference(env, holder, 1, &flow->settlement_fallback_ref) == napi_ok; +} + +// chunk_data with len == -1 is a sentinel indicating completion (buf holds meta JSON) +struct chunk_data { + char* buf; + int len; + output_flow_t* flow; + size_t accounted_bytes; + uint64_t sequence; +}; + +struct streaming_work { + uv_thread_t tid; + napi_threadsafe_function tsfn; + napi_deferred deferred; + long long handle; + char* script; + char* inputs_json; + // 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; + // 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; + output_flow_t* flow; +}; + +static void output_chunk_release(struct chunk_data* chunk, bool rollback) { + if (chunk == NULL) return; + if (chunk->flow != NULL) { + if (rollback) { + output_flow_rollback(chunk->flow, chunk->sequence, chunk->accounted_bytes); + } + output_flow_release(chunk->flow, NULL); + } + free(chunk->buf); + free(chunk); +} + +static void call_js_write(napi_env env, napi_value js_callback, void* context, void* data) { + // 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) { + // 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) { + settle_output_deferred(env, w->deferred, w->flow, chunk->buf); + } + + output_flow_mark_done(w->flow, env); + if (chunk->buf != OOM_JSON) free(chunk->buf); + free(chunk); + free(w->script); + free(w->inputs_json); + + 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. 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); + output_flow_release(w->flow, NULL); + free(w); + 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) { + output_flow_cancel(chunk->flow); + output_chunk_release(chunk, /*rollback=*/true); + return; + } + // cancel() already released this payload's credit. Drop any TSFN payload + // that was queued before cancellation instead of calling JavaScript again. + if (output_flow_is_cancelled(chunk->flow)) { + output_chunk_release(chunk, /*rollback=*/false); + return; + } + + napi_value buffer; + napi_value sequence; + void* buf_data; + napi_status status = napi_create_buffer_copy( + env, chunk->len, chunk->buf, &buf_data, &buffer + ); + if (status == napi_ok) { + status = napi_create_bigint_uint64(env, chunk->sequence, &sequence); + } + if (status == napi_ok) { + napi_value global; + status = napi_get_global(env, &global); + if (status == napi_ok) { + if (!output_flow_mark_delivered(chunk->flow, chunk->sequence)) { + output_chunk_release(chunk, /*rollback=*/false); + return; + } + native_callback_enter(); + napi_value argv[2] = {buffer, sequence}; + status = napi_call_function(env, global, js_callback, 2, argv, NULL); + native_callback_exit(); + } + } + if (status != napi_ok) { + output_flow_cancel(chunk->flow); + if (status == napi_pending_exception) { + napi_value exception; + napi_get_and_clear_last_exception(env, &exception); + } + } + output_chunk_release(chunk, /*rollback=*/status != napi_ok); +} + +static int streaming_write_cb(void* ctx, const char* buf, int len) { + struct streaming_work* w = (struct streaming_work*)ctx; + if (len < 0 || output_flow_is_cancelled(w->flow)) return -1; + uint64_t sequence; + if (!output_flow_reserve(w->flow, (size_t)len, &sequence)) return -1; + if (!test_hold_output_delivery_if_armed(w->flow, sequence, (size_t)len)) { + return -1; + } + // 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); + if (chunk == NULL) { + output_flow_rollback(w->flow, sequence, (size_t)len); + output_flow_cancel(w->flow); + return -1; + } + chunk->buf = len == 0 ? NULL : malloc((size_t)len); + if (len > 0 && chunk->buf == NULL) { + free(chunk); + output_flow_rollback(w->flow, sequence, (size_t)len); + output_flow_cancel(w->flow); + return -1; + } + if (len > 0) memcpy(chunk->buf, buf, (size_t)len); chunk->len = len; - - napi_status status = napi_call_threadsafe_function(tsfn, chunk, napi_tsfn_blocking); + chunk->flow = w->flow; + chunk->accounted_bytes = (size_t)len; + chunk->sequence = sequence; + output_flow_retain(w->flow); + + napi_status status = napi_call_threadsafe_function( + w->tsfn, chunk, napi_tsfn_nonblocking + ); if (status != napi_ok) { - free(chunk->buf); - free(chunk); + output_chunk_release(chunk, /*rollback=*/true); + output_flow_cancel(w->flow); return -1; } return 0; @@ -1240,6 +2651,7 @@ static int streaming_write_cb(void* ctx, const char* buf, int len) { static void streaming_thread_fn(void* arg) { struct streaming_work* w = (struct streaming_work*)arg; + test_hold_async_op_if_armed(); void* worker_thread = NULL; int rc = fn_attach_thread(g_isolate, &worker_thread); @@ -1249,7 +2661,6 @@ 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); @@ -1257,7 +2668,7 @@ static void streaming_thread_fn(void* arg) { 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 + worker_thread, w->handle, w->script, w->inputs_json, streaming_write_cb, (void*)w ); if (result_ptr) { meta_result = strdup((const char*)result_ptr); @@ -1267,7 +2678,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; } - detach_rc = fn_detach_thread(worker_thread); + detach_thread_checked(DETACH_SITE_STREAM_WORKER, worker_thread); } // Decrement here, once this thread has fully detached from the isolate -- @@ -1278,7 +2689,6 @@ 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 @@ -1304,6 +2714,9 @@ static void streaming_thread_fn(void* arg) { struct chunk_data* sentinel = w->sentinel; sentinel->buf = meta_result; sentinel->len = -1; + sentinel->flow = NULL; + sentinel->accounted_bytes = 0; + sentinel->sequence = 0; 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 @@ -1336,11 +2749,14 @@ static void streaming_thread_fn(void* arg) { free(w->script); free(w->inputs_json); bridge_end_op(w->bridge, /*env_still_alive=*/false); + output_flow_mark_done(w->flow, NULL); + output_flow_release(w->flow, NULL); free(w); } } static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_info info) { + if (native_callback_active()) return throw_callback_reentrancy(env); if (!g_initialized) { napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; @@ -1383,6 +2799,12 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i // genuine (non-cancelled) PENDING_WAIT or a committed TEARING_DOWN still // rejects. uv_mutex_lock(&g_mutex); + wait_for_detach_publication_locked(); + if (g_isolate_poisoned) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, ISOLATE_POISONED_MESSAGE); + return NULL; + } 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."); @@ -1425,7 +2847,7 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i napi_throw_error(env, NULL, "OOM"); return NULL; } - w->handle = (long long)handle64; + w->handle = pinned != NULL ? pinned->native_handle : 0; w->script = malloc(script_len + 1); w->inputs_json = malloc(inputs_len + 1); if (w->script == NULL || w->inputs_json == NULL) { @@ -1443,6 +2865,22 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i napi_throw_error(env, NULL, "runScriptStreamingEngine: failed to read script/inputsJson"); return NULL; } + w->flow = output_flow_create(); + if (w->flow == 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; + } + if (!prepare_output_settlement(env, w->flow)) { + output_flow_release(w->flow, 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, "runScriptStreamingEngine: failed to create settlement fallback"); + return NULL; + } // 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 @@ -1454,13 +2892,17 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i // (teardown wedge). napi_value resource_name; if (napi_create_string_utf8(env, "dwStreaming", NAPI_AUTO_LENGTH, &resource_name) != napi_ok) { + output_flow_release(w->flow, env); 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) { + if (napi_create_threadsafe_function(env, argv[3], NULL, resource_name, + OUTPUT_TSFN_QUEUE_SIZE, 1, NULL, NULL, + w, call_js_write, &w->tsfn) != napi_ok) { + output_flow_release(w->flow, env); 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); @@ -1478,6 +2920,7 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i w->sentinel = malloc(sizeof(struct chunk_data)); if (w->sentinel == NULL) { napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); + output_flow_release(w->flow, env); 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); @@ -1490,6 +2933,7 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i // 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); + output_flow_release(w->flow, env); 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); @@ -1497,6 +2941,16 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i return NULL; } + napi_value controller = output_controller_create(env, promise, w->flow); + if (controller == NULL) { + napi_release_threadsafe_function(w->tsfn, napi_tsfn_release); + output_flow_release(w->flow, env); + 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); + return NULL; + } + // 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; @@ -1523,17 +2977,20 @@ static napi_value napi_run_script_streaming_engine(napi_env env, napi_callback_i bridge_end_op(w->bridge, /*env_still_alive=*/true); 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); + settle_output_deferred( + env, w->deferred, w->flow, + "{\"success\":false,\"error\":\"Failed to spawn streaming worker thread\"}" + ); free(w->sentinel); free(w->script); free(w->inputs_json); + output_flow_mark_done(w->flow, env); + output_flow_release(w->flow, env); free(w); } - return promise; + return controller; } // --- Bidirectional streaming --- @@ -1559,6 +3016,7 @@ struct transform_work { // 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; + output_flow_t* flow; }; struct read_request { @@ -1592,7 +3050,9 @@ static void call_js_read(napi_env env, napi_value js_callback, void* context, vo napi_get_global(env, &global); napi_value result; + native_callback_enter(); napi_status status = napi_call_function(env, global, js_callback, 1, &buf_size_val, &result); + native_callback_exit(); if (status == napi_ok && result != NULL) { bool is_buffer; @@ -1683,19 +3143,40 @@ 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; + if (len < 0 || output_flow_is_cancelled(w->flow)) return -1; + uint64_t sequence; + if (!output_flow_reserve(w->flow, (size_t)len, &sequence)) return -1; + if (!test_hold_output_delivery_if_armed(w->flow, sequence, (size_t)len)) { + return -1; + } // 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); + if (chunk == NULL) { + output_flow_rollback(w->flow, sequence, (size_t)len); + output_flow_cancel(w->flow); + return -1; + } + chunk->buf = len == 0 ? NULL : malloc((size_t)len); + if (len > 0 && chunk->buf == NULL) { + free(chunk); + output_flow_rollback(w->flow, sequence, (size_t)len); + output_flow_cancel(w->flow); + return -1; + } + if (len > 0) memcpy(chunk->buf, buf, (size_t)len); chunk->len = len; - - napi_status status = napi_call_threadsafe_function(w->write_tsfn, chunk, napi_tsfn_blocking); + chunk->flow = w->flow; + chunk->accounted_bytes = (size_t)len; + chunk->sequence = sequence; + output_flow_retain(w->flow); + + napi_status status = napi_call_threadsafe_function( + w->write_tsfn, chunk, napi_tsfn_nonblocking + ); if (status != napi_ok) { - free(chunk->buf); - free(chunk); + output_chunk_release(chunk, /*rollback=*/true); + output_flow_cancel(w->flow); return -1; } return 0; @@ -1717,11 +3198,10 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* // 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); + settle_output_deferred(env, w->deferred, w->flow, chunk->buf); } + output_flow_mark_done(w->flow, env); if (chunk->buf != OOM_JSON) free(chunk->buf); free(chunk); free(w->script); @@ -1739,6 +3219,7 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* // 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); + output_flow_release(w->flow, NULL); free(w); return; } @@ -1747,25 +3228,53 @@ static void call_js_transform_write(napi_env env, napi_value js_callback, void* // 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); + output_flow_cancel(chunk->flow); + output_chunk_release(chunk, /*rollback=*/true); + return; + } + // cancel() already released this payload's credit. Drop any TSFN payload + // that was queued before cancellation instead of calling JavaScript again. + if (output_flow_is_cancelled(chunk->flow)) { + output_chunk_release(chunk, /*rollback=*/false); return; } napi_value buffer; + napi_value sequence; void* buf_data; - napi_create_buffer_copy(env, chunk->len, chunk->buf, &buf_data, &buffer); - - napi_value global; - napi_get_global(env, &global); - napi_call_function(env, global, js_callback, 1, &buffer, NULL); - - free(chunk->buf); - free(chunk); + napi_status status = napi_create_buffer_copy( + env, chunk->len, chunk->buf, &buf_data, &buffer + ); + if (status == napi_ok) { + status = napi_create_bigint_uint64(env, chunk->sequence, &sequence); + } + if (status == napi_ok) { + napi_value global; + status = napi_get_global(env, &global); + if (status == napi_ok) { + if (!output_flow_mark_delivered(chunk->flow, chunk->sequence)) { + output_chunk_release(chunk, /*rollback=*/false); + return; + } + native_callback_enter(); + napi_value argv[2] = {buffer, sequence}; + status = napi_call_function(env, global, js_callback, 2, argv, NULL); + native_callback_exit(); + } + } + if (status != napi_ok) { + output_flow_cancel(chunk->flow); + if (status == napi_pending_exception) { + napi_value exception; + napi_get_and_clear_last_exception(env, &exception); + } + } + output_chunk_release(chunk, /*rollback=*/status != napi_ok); } static void transform_thread_fn(void* arg) { struct transform_work* w = (struct transform_work*)arg; + test_hold_async_op_if_armed(); void* worker_thread = NULL; int rc = fn_attach_thread(g_isolate, &worker_thread); @@ -1774,7 +3283,6 @@ 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); @@ -1795,14 +3303,13 @@ static void transform_thread_fn(void* arg) { meta_result = strdup("{\"success\":false,\"error\":\"Empty response\"}"); if (meta_result == NULL) meta_result = (char*)OOM_JSON; } - detach_rc = fn_detach_thread(worker_thread); + detach_thread_checked(DETACH_SITE_TRANSFORM_WORKER, 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. @@ -1828,6 +3335,9 @@ static void transform_thread_fn(void* arg) { struct chunk_data* sentinel = w->sentinel; sentinel->buf = meta_result; sentinel->len = -1; + sentinel->flow = NULL; + sentinel->accounted_bytes = 0; + sentinel->sequence = 0; 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 @@ -1861,11 +3371,14 @@ static void transform_thread_fn(void* arg) { free(w->input_mime_type); free(w->input_charset); bridge_end_op(w->bridge, /*env_still_alive=*/false); + output_flow_mark_done(w->flow, NULL); + output_flow_release(w->flow, NULL); free(w); } } static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_info info) { + if (native_callback_active()) return throw_callback_reentrancy(env); if (!g_initialized) { napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; @@ -1905,6 +3418,12 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i // post-adoption op throws "Not initialized". A genuine (non-cancelled) // PENDING_WAIT or a committed TEARING_DOWN still rejects. uv_mutex_lock(&g_mutex); + wait_for_detach_publication_locked(); + if (g_isolate_poisoned) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, ISOLATE_POISONED_MESSAGE); + return NULL; + } 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."); @@ -1936,7 +3455,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i return NULL; } size_t len; - w->handle = (long long)handle64; + w->handle = pinned != NULL ? pinned->native_handle : 0; #define TRANSFORM_FAIL(msg) do { \ bridge_end_op(pinned, /*env_still_alive=*/true); \ @@ -1986,6 +3505,24 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i TRANSFORM_FAIL("runScriptTransformEngine: inputCharset must be a string, null, or undefined"); } #undef TRANSFORM_FAIL + w->flow = output_flow_create(); + if (w->flow == NULL) { + 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; + } + if (!prepare_output_settlement(env, w->flow)) { + output_flow_release(w->flow, env); + 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 settlement fallback"); + return NULL; + } // Round-9 (#3, updated round-11 #2): check each resource creation; on // failure release the engine pin (`pinned`, taken at admission) via @@ -1995,6 +3532,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i // 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) { + output_flow_release(w->flow, env); 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); @@ -2003,14 +3541,18 @@ 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) { + output_flow_release(w->flow, env); 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; } - 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) { + if (napi_create_threadsafe_function(env, argv[7], NULL, resource_name, + OUTPUT_TSFN_QUEUE_SIZE, 1, NULL, NULL, + w, call_js_transform_write, &w->write_tsfn) != napi_ok) { napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); + output_flow_release(w->flow, env); 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); @@ -2029,6 +3571,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i if (w->sentinel == NULL) { napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); napi_release_threadsafe_function(w->write_tsfn, napi_tsfn_release); + output_flow_release(w->flow, env); 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); @@ -2040,6 +3583,7 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i 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); + output_flow_release(w->flow, env); 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); @@ -2047,6 +3591,18 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i return NULL; } + napi_value controller = output_controller_create(env, promise, w->flow); + if (controller == NULL) { + napi_release_threadsafe_function(w->read_tsfn, napi_tsfn_release); + napi_release_threadsafe_function(w->write_tsfn, napi_tsfn_release); + output_flow_release(w->flow, env); + 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); + return NULL; + } + // 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; @@ -2075,9 +3631,10 @@ 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); - 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); + settle_output_deferred( + env, w->deferred, w->flow, + "{\"success\":false,\"error\":\"Failed to spawn transform worker thread\"}" + ); free(w->sentinel); free(w->script); @@ -2085,10 +3642,12 @@ static napi_value napi_run_script_transform_engine(napi_env env, napi_callback_i free(w->input_name); free(w->input_mime_type); free(w->input_charset); + output_flow_mark_done(w->flow, NULL); + output_flow_release(w->flow, NULL); free(w); } - return promise; + return controller; } // --- Resolver callback bridge --- @@ -2144,7 +3703,9 @@ static char* resolve_module_callback(void* thread, void* ctx, const char* module napi_value undefined, result; napi_get_undefined(env, &undefined); + native_callback_enter(); napi_status status = napi_call_function(env, undefined, js_callback, 1, &module_path_str, &result); + native_callback_exit(); if (status != napi_ok) { // JS resolver threw — clear the pending exception so it doesn't leak // into the next napi call, extract and log its message/stack for @@ -2234,6 +3795,7 @@ 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) { + if (native_callback_active()) return throw_callback_reentrancy(env); (void)info; if (!fn_create_engine) { napi_throw_error(env, NULL, "create_engine not available in native library"); return NULL; } @@ -2247,7 +3809,13 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // 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); + wait_for_detach_publication_locked(); env_init_rec_t* self = env_init_rec_find_locked(env); + if (g_isolate_poisoned) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, ISOLATE_POISONED_MESSAGE); + return NULL; + } if (!g_initialized || g_isolate == NULL || g_teardown_state == TEARDOWN_TEARING_DOWN || self == NULL || self->init_refs == 0) { @@ -2264,13 +3832,11 @@ 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); - 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 - // any handle <= 0 means construction failed; never hand that back to JS as - // if it were usable. + detach_thread_checked(DETACH_SITE_CREATE_ENGINE, thread); + // The Java @CEntryPoint exception handler explicitly returns 0 as its ABI + // exception sentinel when engine construction throws. 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) { 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; @@ -2290,29 +3856,65 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { // 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)); + bool fail_record_allocation = false; + if (g_test_hooks) { + uv_mutex_lock(&g_mutex); + fail_record_allocation = + g_test_engine_record_allocation_failure_generation == g_isolate_generation; + if (fail_record_allocation) { + g_test_engine_record_allocation_failure_generation = 0; + } + uv_mutex_unlock(&g_mutex); + } + engine_bridge_t* rec = fail_record_allocation + ? NULL + : (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. - int detach_rc = 0; if (fn_destroy_engine) { void* t2 = NULL; - if (fn_attach_thread(g_isolate, &t2) == 0) { fn_destroy_engine(t2, handle); detach_rc = fn_detach_thread(t2); } + if (fn_attach_thread(g_isolate, &t2) == 0) { + fn_destroy_engine(t2, handle); + detach_thread_checked(DETACH_SITE_CREATE_ROLLBACK, t2); + } } // 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; } - rec->handle = handle; + rec->native_handle = handle; + rec->native_alive = true; + rec->owner_alive = true; 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); + uv_mutex_lock(&g_mutex); + rec->handle = next_engine_handle_locked(); + rec->isolate_generation = g_isolate_generation; + if (rec->handle > 0) { + rec->next = g_bridges; + g_bridges = rec; + } + uv_mutex_unlock(&g_mutex); + if (rec->handle <= 0) { + // No public handle or cleanup hook was published. Use the normal + // generation-aware finalizer so a transient attach failure retains the + // native registry record instead of freeing a resolver ctx still held + // by Java. may_rehook=false because no cleanup hook was registered. + if (bridge_finalize_registry_at_site(rec, DETACH_SITE_CREATE_ROLLBACK)) { + bridge_finalize_free(rec, /*env_still_alive=*/true); + } else { + bridge_retain_stranded(rec); + } + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Engine handle space exhausted"); + return NULL; + } // 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() would strand this record, the Java @@ -2361,13 +3963,14 @@ static napi_value napi_create_engine(napi_env env, napi_callback_info info) { return NULL; } - napi_value out; napi_create_int64(env, (int64_t)handle, &out); + napi_value out; napi_create_int64(env, (int64_t)rec->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 (native_callback_active()) return throw_callback_reentrancy(env); 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); @@ -2378,18 +3981,36 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i 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; } + if (g_test_hooks) { + uv_mutex_lock(&g_mutex); + g_test_live_resolver_refs++; + uv_mutex_unlock(&g_mutex); + } bridge->env = env; bridge->owner = uv_thread_self(); bridge->results = NULL; + bridge->owner_alive = true; + if (!bridge_register_owner_cleanup(bridge)) { + bridge_finalize_free(bridge, /*env_still_alive=*/true); + napi_throw_error(env, NULL, "Failed to register resolver owner cleanup"); + return 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); + wait_for_detach_publication_locked(); env_init_rec_t* self = env_init_rec_find_locked(env); + if (g_isolate_poisoned) { + uv_mutex_unlock(&g_mutex); + bridge_finalize_free(bridge, /*env_still_alive=*/true); + napi_throw_error(env, NULL, ISOLATE_POISONED_MESSAGE); + return NULL; + } 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); + bridge_finalize_free(bridge, /*env_still_alive=*/true); napi_throw_error(env, NULL, "Not initialized. Call initialize() first."); return NULL; } @@ -2399,16 +4020,15 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i 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); + bridge_finalize_free(bridge, /*env_still_alive=*/true); 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); - int detach_rc = fn_detach_thread(thread); - if (detach_rc != 0) poison_isolate_detach_failure(detach_rc); + detach_thread_checked(DETACH_SITE_RESOLVER_CREATE, 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 + // Same invalid-handle guard as napi_create_engine: the Java @CEntryPoint + // exception handler explicitly returns handle == 0 as its ABI sentinel, + // 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, so there's nothing to unlink/unhook. Still use // bridge_finalize (not a manual napi_delete_reference+free) because the failed @@ -2425,9 +4045,27 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i 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); + + bridge->native_handle = handle; + bridge->native_alive = true; + uv_mutex_lock(&g_mutex); + bridge->handle = next_engine_handle_locked(); + bridge->isolate_generation = g_isolate_generation; + if (bridge->handle > 0) { + bridge->next = g_bridges; + g_bridges = bridge; + } + uv_mutex_unlock(&g_mutex); + if (bridge->handle <= 0) { + if (bridge_finalize_registry_at_site(bridge, DETACH_SITE_CREATE_ROLLBACK)) { + bridge_finalize_free(bridge, /*env_still_alive=*/true); + } else { + bridge_retain_stranded(bridge); + } + uv_mutex_lock(&g_mutex); g_active_ops--; uv_cond_broadcast(&g_teardown_cond); uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Engine handle space exhausted"); + return NULL; + } // 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 @@ -2467,13 +4105,14 @@ static napi_value napi_create_engine_with_resolver(napi_env env, napi_callback_i napi_throw_error(env, NULL, "Failed to register engine cleanup hook"); return NULL; } - napi_value out; napi_create_int64(env, (int64_t)handle, &out); + napi_value out; napi_create_int64(env, (int64_t)bridge->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 static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { + if (native_callback_active()) return throw_callback_reentrancy(env); if (!g_initialized) return NULL; size_t argc = 1; napi_value argv[1]; napi_get_cb_info(env, info, &argc, argv, NULL, NULL); @@ -2502,7 +4141,7 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { // 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); + engine_bridge_t* owned = bridge_find_any(handle); if (owned != NULL) { uv_thread_t self = uv_thread_self(); if (!uv_thread_equal(&self, &owned->owner)) { @@ -2586,20 +4225,23 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { // window; only the g_mutex-guarded read here does. if (fn_destroy_engine && fn_attach_thread) { uv_mutex_lock(&g_mutex); + wait_for_detach_publication_locked(); 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; - int detach_rc = 0; if (fn_attach_thread(g_isolate, &thread) == 0 && thread != NULL) { - fn_destroy_engine(thread, handle); - detach_rc = fn_detach_thread(thread); + // Public handles are addon-local and cannot be passed to + // the isolate registry when no current bridge maps them. + // Preserve the native unknown-destroy call with the ABI's + // guaranteed-invalid zero handle. + fn_destroy_engine(thread, 0); + detach_thread_checked(DETACH_SITE_UNKNOWN_DESTROY, 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); @@ -2611,6 +4253,7 @@ static napi_value napi_destroy_engine(napi_env env, napi_callback_info info) { // runScriptEngine(handle, script, inputsJson) -> string static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) { + if (native_callback_active()) return throw_callback_reentrancy(env); 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]; @@ -2659,7 +4302,14 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) // 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); + uv_mutex_lock(&g_mutex); + wait_for_detach_publication_locked(); + if (g_isolate_poisoned) { + uv_mutex_unlock(&g_mutex); + free(script); free(inputs); + napi_throw_error(env, NULL, ISOLATE_POISONED_MESSAGE); + return NULL; + } if (!g_initialized || (g_teardown_state != TEARDOWN_NONE && !g_teardown_cancelled)) { uv_mutex_unlock(&g_mutex); free(script); free(inputs); @@ -2685,7 +4335,8 @@ static napi_value napi_run_script_engine(napi_env env, napi_callback_info info) return NULL; } - char* result = (char*)fn_run_script_engine(thread, handle, script, inputs); + long long native_handle = bridge != NULL ? bridge->native_handle : 0; + char* result = (char*)fn_run_script_engine(thread, native_handle, script, inputs); // 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 @@ -2694,8 +4345,7 @@ 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); - int detach_rc = fn_detach_thread(thread); - if (detach_rc != 0) poison_isolate_detach_failure(detach_rc); + detach_thread_checked(DETACH_SITE_SYNCHRONOUS_RUN, thread); free(script); free(inputs); // Round-11 (#3): release the per-engine pin (may finalize a destroy that a @@ -2750,7 +4400,7 @@ static void call_js_teardown_done(napi_env env, napi_value js_callback, void* co free(waiter); } -// `arg` is a cleanup_result_t* out-param: the caller must set it to +// `arg` is a cleanup_thread_result_t* out-param: the caller must initialize its // 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 @@ -2758,8 +4408,10 @@ static void call_js_teardown_done(napi_env env, napi_value js_callback, void* co // 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). +// Follow-up detaches remain direct: they classify that teardown double failure, +// rather than poisoning an otherwise completed ordinary operation. static void cleanup_thread_fn(void* arg) { - cleanup_result_t* out_result = (cleanup_result_t*)arg; + cleanup_thread_result_t* result = (cleanup_thread_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 @@ -2768,7 +4420,7 @@ 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_result = CLEANUP_TORN_DOWN; + result->outcome = CLEANUP_TORN_DOWN; return; } if (g_isolate_poisoned) { @@ -2777,7 +4429,7 @@ static void cleanup_thread_fn(void* arg) { // 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; + result->outcome = CLEANUP_UNRECOVERABLE; return; } void* local_thread = NULL; @@ -2787,22 +4439,26 @@ static void cleanup_thread_fn(void* arg) { // (or it becomes unreachable and can never be torn down) and arms the retry. return; } + // The attached worker is about to invoke teardown. The caller increments the + // counter after join, avoiding a second g_mutex lock while synchronous callers + // deliberately hold it across this worker. + result->teardown_callable = true; // Check the teardown return code (0 == success). On nonzero the isolate is // 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_result = CLEANUP_TORN_DOWN; + result->outcome = 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; + result->outcome = CLEANUP_RETAIN; } else { // 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; + result->outcome = CLEANUP_UNRECOVERABLE; } } @@ -2811,11 +4467,16 @@ static void cleanup_thread_fn(void* arg) { // 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). +// Its teardown-failure follow-up detach is direct for the same double-failure +// classification documented on cleanup_thread_fn. static void teardown_waiter_thread_fn(void* arg) { (void)arg; uv_mutex_lock(&g_mutex); - while (g_active_ops > 0 && !g_teardown_cancelled) { + while ((g_active_ops > 0 || + (g_detach_in_progress > 0 && + g_detach_in_progress_generation == g_isolate_generation)) && + !g_teardown_cancelled) { uv_cond_wait(&g_teardown_cond, &g_mutex); } bool cancelled = g_teardown_cancelled; @@ -2841,6 +4502,11 @@ static void teardown_waiter_thread_fn(void* arg) { } 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 (g_test_hooks) { + uv_mutex_lock(&g_mutex); + g_test_teardown_calls++; + uv_mutex_unlock(&g_mutex); + } if (fn_tear_down_isolate(local_thread) == 0) { result = CLEANUP_TORN_DOWN; } else if (fn_detach_thread(local_thread) == 0) { @@ -2863,6 +4529,9 @@ static void teardown_waiter_thread_fn(void* arg) { uv_mutex_lock(&g_mutex); if (!cancelled && result == CLEANUP_TORN_DOWN) { + if (g_test_engine_record_allocation_failure_generation == g_isolate_generation) { + g_test_engine_record_allocation_failure_generation = 0; + } g_thread = NULL; g_isolate = NULL; g_initialized = 0; @@ -2997,28 +4666,34 @@ static napi_value already_resolved_promise(napi_env env) { // 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. +// makes no N-API calls and writes only its caller-owned result struct, 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_detach_in_progress > 0 && + g_detach_in_progress_generation == g_isolate_generation) return; 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; - cleanup_result_t result = CLEANUP_RETAIN; + cleanup_thread_result_t result = {CLEANUP_RETAIN, false}; int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, &result); if (spawn_rc == 0) uv_thread_join(&tid); - if (result == CLEANUP_TORN_DOWN) { + if (g_test_hooks && result.teardown_callable) g_test_teardown_calls++; + if (result.outcome == CLEANUP_TORN_DOWN) { + if (g_test_engine_record_allocation_failure_generation == g_isolate_generation) { + g_test_engine_record_allocation_failure_generation = 0; + } g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; g_teardown_needed = false; - } else if (result == CLEANUP_UNRECOVERABLE) { + } else if (result.outcome == 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(); @@ -3033,22 +4708,28 @@ static void isolate_ref_release_n_locked(int n) { 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) { + if (g_active_ops == 0 && + !(g_detach_in_progress > 0 && + g_detach_in_progress_generation == g_isolate_generation)) { uv_thread_t tid; uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; opts.stack_size = 2 * 1024 * 1024; - cleanup_result_t result = CLEANUP_RETAIN; + cleanup_thread_result_t result = {CLEANUP_RETAIN, false}; int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, &result); if (spawn_rc == 0) { uv_thread_join(&tid); } - if (result == CLEANUP_TORN_DOWN) { + if (g_test_hooks && result.teardown_callable) g_test_teardown_calls++; + if (result.outcome == CLEANUP_TORN_DOWN) { + if (g_test_engine_record_allocation_failure_generation == g_isolate_generation) { + g_test_engine_record_allocation_failure_generation = 0; + } g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; - } else if (result == CLEANUP_UNRECOVERABLE) { + } else if (result.outcome == 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(); @@ -3213,7 +4894,9 @@ static napi_value release_isolate_ref_locked(napi_env env) { // Case 4: last release, no teardown pending, and nothing active -- the // original, unchanged synchronous fast path. - if (g_active_ops == 0) { + if (g_active_ops == 0 && + !(g_detach_in_progress > 0 && + g_detach_in_progress_generation == g_isolate_generation)) { uv_thread_t tid; uv_thread_options_t opts; opts.flags = UV_THREAD_HAS_STACK_SIZE; @@ -3224,7 +4907,7 @@ static napi_value release_isolate_ref_locked(napi_env env) { // 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. - cleanup_result_t result = CLEANUP_RETAIN; + cleanup_thread_result_t result = {CLEANUP_RETAIN, false}; int spawn_rc = uv_thread_create_ex(&tid, &opts, cleanup_thread_fn, &result); if (spawn_rc == 0) { uv_thread_join(&tid); @@ -3240,12 +4923,16 @@ 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 (result == CLEANUP_TORN_DOWN) { + if (g_test_hooks && result.teardown_callable) g_test_teardown_calls++; + if (result.outcome == CLEANUP_TORN_DOWN) { + if (g_test_engine_record_allocation_failure_generation == g_isolate_generation) { + g_test_engine_record_allocation_failure_generation = 0; + } g_thread = NULL; g_isolate = NULL; g_initialized = 0; g_ref_count = 0; - } else if (result == CLEANUP_UNRECOVERABLE) { + } else if (result.outcome == 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 @@ -3363,6 +5050,7 @@ static napi_value release_isolate_ref_locked(napi_env env) { } static napi_value napi_cleanup(napi_env env, napi_callback_info info) { + if (native_callback_active()) return throw_callback_reentrancy(env); (void)info; uv_mutex_lock(&g_mutex); return release_isolate_ref_locked(env); // unlocks g_mutex, returns the promise @@ -3372,7 +5060,9 @@ static napi_value napi_cleanup(napi_env env, napi_callback_info info) { static void init_g_mutex(void) { uv_mutex_init(&g_mutex); + uv_mutex_init(&g_test_output_mutex); uv_cond_init(&g_teardown_cond); + g_native_callback_depth_status = uv_key_create(&g_native_callback_depth); } // --- Test-only N-API entrypoints (review #12 #3 / #13) --- @@ -3408,34 +5098,571 @@ static napi_value napi_test_resolver_ref_delete_count(napi_env env, napi_callbac return out; } -static napi_value Init(napi_env env, napi_value exports) { - uv_once(&g_mutex_once, init_g_mutex); +static detach_site_t detach_site_from_name(const char* name, size_t length) { + #define DETACH_SITE_MATCH(value, site) \ + if (length == sizeof(value) - 1 && memcmp(name, value, sizeof(value) - 1) == 0) return site + DETACH_SITE_MATCH("bridge-finalize", DETACH_SITE_BRIDGE_FINALIZE); + DETACH_SITE_MATCH("stream-worker", DETACH_SITE_STREAM_WORKER); + DETACH_SITE_MATCH("transform-worker", DETACH_SITE_TRANSFORM_WORKER); + DETACH_SITE_MATCH("create-engine", DETACH_SITE_CREATE_ENGINE); + DETACH_SITE_MATCH("create-rollback", DETACH_SITE_CREATE_ROLLBACK); + DETACH_SITE_MATCH("resolver-create", DETACH_SITE_RESOLVER_CREATE); + DETACH_SITE_MATCH("unknown-destroy", DETACH_SITE_UNKNOWN_DESTROY); + DETACH_SITE_MATCH("synchronous-run", DETACH_SITE_SYNCHRONOUS_RUN); + #undef DETACH_SITE_MATCH + return DETACH_SITE_NONE; +} - napi_value fn; +static napi_value napi_test_force_detach_failure_once( + napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + napi_valuetype type; + char site_name[64]; + size_t length = 0; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc < 1 || + napi_typeof(env, argv[0], &type) != napi_ok || type != napi_string || + napi_get_value_string_utf8(env, argv[0], NULL, 0, &length) != napi_ok || + length >= sizeof(site_name)) { + napi_throw_type_error(env, NULL, "A detach site string is required"); + return NULL; + } + size_t copied = 0; + if ( + napi_get_value_string_utf8( + env, argv[0], site_name, sizeof(site_name), &copied) != napi_ok || + copied != length || memchr(site_name, '\0', length) != NULL) { + napi_throw_type_error(env, NULL, "A detach site string is required"); + return NULL; + } + detach_site_t site = detach_site_from_name(site_name, length); + if (site == DETACH_SITE_NONE) { + napi_throw_range_error(env, NULL, "Unknown detach site"); + return NULL; + } + uv_mutex_lock(&g_mutex); + if (g_test_detach_failure_site != DETACH_SITE_NONE) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "A detach failure is already armed"); + return NULL; + } + g_test_detach_failure_site = site; + uv_mutex_unlock(&g_mutex); + return NULL; +} + +static napi_value napi_test_isolate_poisoned(napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + bool poisoned = g_isolate_poisoned; + uv_mutex_unlock(&g_mutex); + napi_value out; + napi_get_boolean(env, poisoned, &out); + return out; +} + +static napi_value test_uint64_counter(napi_env env, uint64_t value) { + napi_value out; + if (napi_create_bigint_uint64(env, value, &out) != napi_ok) { + napi_throw_error(env, NULL, "Failed to create test counter"); + return NULL; + } + return out; +} + +static napi_value napi_test_isolate_creation_count(napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + uint64_t value = g_test_isolate_creations; + uv_mutex_unlock(&g_mutex); + return test_uint64_counter(env, value); +} + +static napi_value napi_test_teardown_call_count(napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + uint64_t value = g_test_teardown_calls; + uv_mutex_unlock(&g_mutex); + return test_uint64_counter(env, value); +} + +static napi_value napi_test_abandoned_isolate_count(napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + uint64_t value = g_test_abandoned_isolates; + uv_mutex_unlock(&g_mutex); + return test_uint64_counter(env, value); +} + +static napi_value napi_test_forced_detach_failure_count( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + uint64_t value = g_test_forced_detach_failures; + uv_mutex_unlock(&g_mutex); + return test_uint64_counter(env, value); +} + +static napi_value napi_test_hold_next_async_op(napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + if (g_test_hold_next_async_op || g_test_async_op_held) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "A test async operation gate is already armed"); + return NULL; + } + g_test_hold_next_async_op = true; + g_test_release_async_op = false; + uv_mutex_unlock(&g_mutex); + return NULL; +} + +static napi_value napi_test_async_op_held(napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + bool held = g_test_async_op_held; + uv_mutex_unlock(&g_mutex); + napi_value out; + napi_get_boolean(env, held, &out); + return out; +} + +static napi_value napi_test_release_async_op(napi_env env, napi_callback_info info) { + (void)env; + (void)info; + uv_mutex_lock(&g_mutex); + g_test_release_async_op = true; + uv_cond_broadcast(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); + return NULL; +} + +static napi_value napi_test_hold_detach_publication( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + if (g_test_hold_next_detach_publication || g_test_detach_publication_held) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "A detach publication gate is already armed"); + return NULL; + } + g_test_hold_next_detach_publication = true; + g_test_release_detach_publication = false; + uv_mutex_unlock(&g_mutex); + return NULL; +} + +static napi_value napi_test_detach_publication_held( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + bool held = g_test_detach_publication_held; + uv_mutex_unlock(&g_mutex); + napi_value out; + napi_get_boolean(env, held, &out); + return out; +} + +static napi_value napi_test_detach_publication_waiters( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + uint64_t waiters = g_detach_publication_waiters; + uv_mutex_unlock(&g_mutex); + return test_uint64_counter(env, waiters); +} + +static napi_value napi_test_release_detach_publication( + napi_env env, napi_callback_info info) { + (void)env; + (void)info; + uv_mutex_lock(&g_mutex); + g_test_release_detach_publication = true; + uv_cond_broadcast(&g_teardown_cond); + uv_mutex_unlock(&g_mutex); + return NULL; +} + +static napi_value napi_test_live_stranded_resolver_ref_count( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + uint64_t count = g_test_live_resolver_refs; + uv_mutex_unlock(&g_mutex); + return test_uint64_counter(env, count); +} + +static napi_value napi_test_bridge_free_count( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + uint64_t count = g_test_bridge_frees; + uv_mutex_unlock(&g_mutex); + return test_uint64_counter(env, count); +} + +static napi_value napi_test_post_reclamation_action_count( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + uint64_t count = g_test_post_reclamation_actions; + uv_mutex_unlock(&g_mutex); + return test_uint64_counter(env, count); +} + +static napi_value napi_test_fail_next_engine_record_allocation( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + if (!g_initialized || g_isolate == NULL || g_isolate_poisoned || + g_teardown_state != TEARDOWN_NONE || g_teardown_needed || + g_active_ops != 0 || g_isolate_generation == 0) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "A healthy initialized isolate generation is required"); + return NULL; + } + if (g_test_engine_record_allocation_failure_generation != 0) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "An engine record allocation failure is already armed"); + return NULL; + } + g_test_engine_record_allocation_failure_generation = g_isolate_generation; + uv_mutex_unlock(&g_mutex); + return NULL; +} + +static napi_value napi_test_set_next_engine_handle( + napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + napi_valuetype type; + double next_handle; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc < 1 || + napi_typeof(env, argv[0], &type) != napi_ok || type != napi_number || + napi_get_value_double(env, argv[0], &next_handle) != napi_ok || + next_handle <= 0 || next_handle > (double)MAX_SAFE_ENGINE_HANDLE || + next_handle != (double)(long long)next_handle) { + napi_throw_range_error(env, NULL, "Next engine handle must be a positive safe integer"); + return NULL; + } + uv_mutex_lock(&g_mutex); + if (g_bridges != NULL || g_stranded_bridges != NULL || g_active_ops != 0 || + g_next_engine_handle == 0 || next_handle < g_next_engine_handle) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Next engine handle cannot be changed in the current state"); + return NULL; + } + g_next_engine_handle = (long long)next_handle; + uv_mutex_unlock(&g_mutex); + return NULL; +} + +static napi_value napi_test_set_isolate_generation( + napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + uint64_t generation; + bool lossless = false; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc < 1 || + napi_get_value_bigint_uint64(env, argv[0], &generation, &lossless) != napi_ok || + !lossless) { + napi_throw_range_error(env, NULL, "Isolate generation must be a non-decreasing uint64 BigInt"); + return NULL; + } + uv_mutex_lock(&g_mutex); + if (generation < g_isolate_generation || + !g_initialized || g_isolate == NULL || g_isolate_poisoned || + g_teardown_state != TEARDOWN_NONE || g_active_ops != 0 || + g_bridges != NULL || g_stranded_bridges != NULL || + g_test_engine_record_allocation_failure_generation != 0) { + uv_mutex_unlock(&g_mutex); + napi_throw_error(env, NULL, "Isolate generation cannot be changed in the current state"); + return NULL; + } + g_isolate_generation = generation; + uv_mutex_unlock(&g_mutex); + return NULL; +} + +static napi_value napi_test_isolate_generation( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_mutex); + uint64_t generation = g_isolate_generation; + uv_mutex_unlock(&g_mutex); + return test_uint64_counter(env, generation); +} + +typedef struct foreign_wrapped_value { + uint64_t marker; +} foreign_wrapped_value_t; + +static void foreign_wrapped_finalize(napi_env env, void* data, void* hint) { + (void)env; + (void)hint; + free(data); +} + +static napi_value napi_test_create_foreign_wrapped_object( + napi_env env, napi_callback_info info) { + (void)info; + foreign_wrapped_value_t* value = calloc(1, sizeof(foreign_wrapped_value_t)); + if (value == NULL) { + napi_throw_error(env, NULL, "OOM"); + return NULL; + } + value->marker = 424242; + napi_value object; + if (napi_create_object(env, &object) != napi_ok || + napi_wrap(env, object, value, foreign_wrapped_finalize, NULL, NULL) != napi_ok) { + free(value); + napi_throw_error(env, NULL, "Failed to create foreign wrapped object"); + return NULL; + } + return object; +} + +static napi_value napi_test_fail_next_output_settlement( + napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + size_t length; + char stage[64]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc < 1 || + napi_get_value_string_utf8(env, argv[0], stage, sizeof(stage), &length) != napi_ok) { + napi_throw_type_error(env, NULL, "A settlement fault stage is required"); + return NULL; + } + output_settlement_fault_t fault = OUTPUT_SETTLEMENT_FAULT_NONE; + if (strcmp(stage, "initial-create-generic") == 0) { + fault = OUTPUT_SETTLEMENT_FAULT_INITIAL_CREATE_GENERIC; + } else if (strcmp(stage, "initial-pending-exception") == 0) { + fault = OUTPUT_SETTLEMENT_FAULT_INITIAL_PENDING_EXCEPTION; + } else if (strcmp(stage, "initial-call-generic-after-call") == 0) { + fault = OUTPUT_SETTLEMENT_FAULT_INITIAL_CALL_GENERIC_AFTER_CALL; + } else if (strcmp(stage, "initial-call-pending-after-call") == 0) { + fault = OUTPUT_SETTLEMENT_FAULT_INITIAL_CALL_PENDING_AFTER_CALL; + } else if (strcmp(stage, "fallback-call-generic") == 0) { + fault = OUTPUT_SETTLEMENT_FAULT_FALLBACK_CALL_GENERIC; + } else if (strcmp(stage, "fallback-pending-exception") == 0) { + fault = OUTPUT_SETTLEMENT_FAULT_FALLBACK_PENDING_EXCEPTION; + } else if (strcmp(stage, "fallback-call-generic-after-call") == 0) { + fault = OUTPUT_SETTLEMENT_FAULT_FALLBACK_CALL_GENERIC_AFTER_CALL; + } else { + napi_throw_range_error(env, NULL, "Unknown settlement fault stage"); + return NULL; + } + uv_mutex_lock(&g_test_output_mutex); + if (g_test_next_output_settlement_fault != OUTPUT_SETTLEMENT_FAULT_NONE) { + uv_mutex_unlock(&g_test_output_mutex); + napi_throw_error(env, NULL, "An output settlement fault is already armed"); + return NULL; + } + g_test_next_output_settlement_fault = fault; + uv_mutex_unlock(&g_test_output_mutex); + return NULL; +} + +static napi_value napi_test_fail_next_output_exception_clear( + napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + size_t length; + char stage[64]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc < 1 || + napi_get_value_string_utf8(env, argv[0], stage, sizeof(stage), &length) != napi_ok) { + napi_throw_type_error(env, NULL, "An exception clear fault stage is required"); + return NULL; + } + output_exception_clear_fault_t fault = OUTPUT_EXCEPTION_CLEAR_FAULT_NONE; + if (strcmp(stage, "is-exception-pending") == 0) { + fault = OUTPUT_EXCEPTION_CLEAR_FAULT_IS_PENDING; + } else if (strcmp(stage, "get-and-clear-last-exception") == 0) { + fault = OUTPUT_EXCEPTION_CLEAR_FAULT_GET_AND_CLEAR; + } else { + napi_throw_range_error(env, NULL, "Unknown exception clear fault stage"); + return NULL; + } + uv_mutex_lock(&g_test_output_mutex); + if (g_test_next_output_exception_clear_fault != + OUTPUT_EXCEPTION_CLEAR_FAULT_NONE) { + uv_mutex_unlock(&g_test_output_mutex); + napi_throw_error(env, NULL, "An output exception clear fault is already armed"); + return NULL; + } + g_test_next_output_exception_clear_fault = fault; + uv_mutex_unlock(&g_test_output_mutex); + return NULL; +} + +static napi_value napi_test_hold_next_output_delivery( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_test_output_mutex); + if (g_test_hold_next_output_delivery || g_test_output_delivery_held) { + uv_mutex_unlock(&g_test_output_mutex); + napi_throw_error(env, NULL, "An output delivery gate is already armed"); + return NULL; + } + g_test_hold_next_output_delivery = true; + g_test_release_output_delivery = false; + uv_mutex_unlock(&g_test_output_mutex); + return NULL; +} + +static napi_value napi_test_held_output_delivery( + napi_env env, napi_callback_info info) { + (void)info; + uv_mutex_lock(&g_test_output_mutex); + bool held = g_test_output_delivery_held; + uint64_t sequence = g_test_held_output_sequence; + size_t bytes = g_test_held_output_bytes; + uv_mutex_unlock(&g_test_output_mutex); + + napi_value out; + napi_value value; + if (napi_create_object(env, &out) != napi_ok || + napi_get_boolean(env, held, &value) != napi_ok || + napi_set_named_property(env, out, "held", value) != napi_ok || + napi_create_bigint_uint64(env, sequence, &value) != napi_ok || + napi_set_named_property(env, out, "sequence", value) != napi_ok || + napi_create_double(env, (double)bytes, &value) != napi_ok || + napi_set_named_property(env, out, "bytes", value) != napi_ok) { + napi_throw_error(env, NULL, "Failed to create held output delivery state"); + return NULL; + } + return out; +} + +static napi_value napi_test_release_output_delivery( + napi_env env, napi_callback_info info) { + (void)env; + (void)info; + uv_mutex_lock(&g_test_output_mutex); + g_test_release_output_delivery = true; + if (!g_test_output_delivery_held) { + g_test_hold_next_output_delivery = false; + g_test_held_output_sequence = 0; + g_test_held_output_bytes = 0; + } + uv_mutex_unlock(&g_test_output_mutex); + return NULL; +} + +static void set_named_size(napi_env env, napi_value object, const char* name, size_t value) { + napi_value out; + napi_create_double(env, (double)value, &out); + napi_set_named_property(env, object, name, out); +} - napi_create_function(env, "initialize", NAPI_AUTO_LENGTH, napi_initialize, NULL, &fn); - napi_set_named_property(env, exports, "initialize", fn); +static void set_named_bool(napi_env env, napi_value object, const char* name, bool value) { + napi_value out; + napi_get_boolean(env, value, &out); + napi_set_named_property(env, object, name, out); +} - napi_create_function(env, "createEngine", NAPI_AUTO_LENGTH, napi_create_engine, NULL, &fn); - napi_set_named_property(env, exports, "createEngine", fn); +static napi_value output_stats_value(napi_env env, const output_flow_stats_t* stats) { + napi_value out; + napi_value value; + napi_create_object(env, &out); + napi_create_double(env, (double)stats->operation_id, &value); + napi_set_named_property(env, out, "operationId", value); + set_named_size(env, out, "outstandingBytes", stats->outstanding_bytes); + set_named_size(env, out, "outstandingChunks", stats->outstanding_chunks); + set_named_size(env, out, "peakBufferedBytes", stats->peak_buffered_bytes); + set_named_size(env, out, "peakBufferedChunks", stats->peak_buffered_chunks); + set_named_size(env, out, "largestChunkBytes", stats->largest_chunk_bytes); + set_named_size(env, out, "highBytes", OUTPUT_HIGH_BYTES); + set_named_size(env, out, "lowBytes", OUTPUT_LOW_BYTES); + set_named_size(env, out, "highChunks", OUTPUT_HIGH_CHUNKS); + set_named_size(env, out, "lowChunks", OUTPUT_LOW_CHUNKS); + set_named_bool(env, out, "paused", stats->paused); + set_named_bool(env, out, "cancelled", stats->cancelled); + set_named_bool(env, out, "done", stats->done); + napi_create_int64(env, stats->live_flows, &value); + napi_set_named_property(env, out, "liveFlows", value); + return out; +} - napi_create_function(env, "createEngineWithResolver", NAPI_AUTO_LENGTH, napi_create_engine_with_resolver, NULL, &fn); - napi_set_named_property(env, exports, "createEngineWithResolver", fn); +static napi_value napi_test_output_stats(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); + uint64_t requested = 0; + if (argc == 1) { + int64_t id; + if (napi_get_value_int64(env, argv[0], &id) != napi_ok || id < 0) { + napi_throw_type_error(env, NULL, "operationId must be a non-negative integer"); + return NULL; + } + requested = (uint64_t)id; + } + output_flow_stats_t stats; + uv_mutex_lock(&g_test_output_mutex); + stats = g_test_last_output_stats; + stats.live_flows = g_test_live_output_flows; + uv_mutex_unlock(&g_test_output_mutex); + if (requested != 0 && requested != stats.operation_id) { + napi_throw_error(env, NULL, "Output operation statistics are no longer current"); + return NULL; + } + return output_stats_value(env, &stats); +} - napi_create_function(env, "destroyEngine", NAPI_AUTO_LENGTH, napi_destroy_engine, NULL, &fn); - napi_set_named_property(env, exports, "destroyEngine", fn); +static napi_value napi_test_output_operation_id(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc < 1) { + napi_throw_type_error(env, NULL, "An output controller is required"); + return NULL; + } + bool tagged = false; + if (napi_check_object_type_tag(env, argv[0], &OUTPUT_CONTROLLER_TAG, &tagged) != napi_ok || + !tagged) { + napi_throw_type_error(env, NULL, "Invalid output controller"); + return NULL; + } + output_controller_t* holder = NULL; + if (napi_unwrap(env, argv[0], (void**)&holder) != napi_ok || holder == NULL) { + napi_throw_type_error(env, NULL, "Invalid output controller"); + return NULL; + } + napi_value out; + napi_create_double(env, (double)holder->operation_id, &out); + return out; +} - napi_create_function(env, "runScriptEngine", NAPI_AUTO_LENGTH, napi_run_script_engine, NULL, &fn); - napi_set_named_property(env, exports, "runScriptEngine", fn); +static bool export_function(napi_env env, napi_value exports, const char* name, + napi_callback callback) { + napi_value fn; + return napi_create_function( + env, name, NAPI_AUTO_LENGTH, callback, NULL, &fn) == napi_ok && + napi_set_named_property(env, exports, name, fn) == napi_ok; +} - napi_create_function(env, "runScriptStreamingEngine", NAPI_AUTO_LENGTH, napi_run_script_streaming_engine, NULL, &fn); - napi_set_named_property(env, exports, "runScriptStreamingEngine", fn); +static napi_value Init(napi_env env, napi_value exports) { + uv_once(&g_mutex_once, init_g_mutex); - napi_create_function(env, "runScriptTransformEngine", NAPI_AUTO_LENGTH, napi_run_script_transform_engine, NULL, &fn); - napi_set_named_property(env, exports, "runScriptTransformEngine", fn); + if (g_native_callback_depth_status != 0) { + char message[128]; + snprintf(message, sizeof(message), + "Failed to initialize native callback state (libuv error %d)", + g_native_callback_depth_status); + napi_throw_error(env, NULL, message); + return NULL; + } - napi_create_function(env, "cleanup", NAPI_AUTO_LENGTH, napi_cleanup, NULL, &fn); - napi_set_named_property(env, exports, "cleanup", fn); + if (!export_function(env, exports, "initialize", napi_initialize) || + !export_function(env, exports, "createEngine", napi_create_engine) || + !export_function(env, exports, "createEngineWithResolver", napi_create_engine_with_resolver) || + !export_function(env, exports, "destroyEngine", napi_destroy_engine) || + !export_function(env, exports, "runScriptEngine", napi_run_script_engine) || + !export_function(env, exports, "runScriptStreamingEngine", napi_run_script_streaming_engine) || + !export_function(env, exports, "runScriptTransformEngine", napi_run_script_transform_engine) || + !export_function(env, exports, "cleanup", napi_cleanup)) { + napi_throw_error(env, NULL, "Failed to register DataWeave native exports"); + return NULL; + } // Test-only entrypoints, registered only when the process opts in via // DATAWEAVE_TEST_HOOKS (non-empty). getenv() is safe here: Init runs once per @@ -3444,12 +5671,40 @@ static napi_value Init(napi_env env, napi_value exports) { 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); + if (!export_function(env, exports, "__test_forceStrandOnce", napi_test_force_strand_once) || + !export_function(env, exports, "__test_strandedCount", napi_test_stranded_count) || + !export_function(env, exports, "__test_resolverRefDeleteCount", napi_test_resolver_ref_delete_count) || + !export_function(env, exports, "__test_forceDetachFailureOnce", napi_test_force_detach_failure_once) || + !export_function(env, exports, "__test_isolatePoisoned", napi_test_isolate_poisoned) || + !export_function(env, exports, "__test_isolateCreationCount", napi_test_isolate_creation_count) || + !export_function(env, exports, "__test_teardownCallCount", napi_test_teardown_call_count) || + !export_function(env, exports, "__test_abandonedIsolateCount", napi_test_abandoned_isolate_count) || + !export_function(env, exports, "__test_forcedDetachFailureCount", napi_test_forced_detach_failure_count) || + !export_function(env, exports, "__test_failNextEngineRecordAllocation", napi_test_fail_next_engine_record_allocation) || + !export_function(env, exports, "__test_setNextEngineHandle", napi_test_set_next_engine_handle) || + !export_function(env, exports, "__test_setIsolateGeneration", napi_test_set_isolate_generation) || + !export_function(env, exports, "__test_isolateGeneration", napi_test_isolate_generation) || + !export_function(env, exports, "__test_holdNextAsyncOp", napi_test_hold_next_async_op) || + !export_function(env, exports, "__test_asyncOpHeld", napi_test_async_op_held) || + !export_function(env, exports, "__test_releaseAsyncOp", napi_test_release_async_op) || + !export_function(env, exports, "__test_holdDetachPublication", napi_test_hold_detach_publication) || + !export_function(env, exports, "__test_detachPublicationHeld", napi_test_detach_publication_held) || + !export_function(env, exports, "__test_detachPublicationWaiters", napi_test_detach_publication_waiters) || + !export_function(env, exports, "__test_releaseDetachPublication", napi_test_release_detach_publication) || + !export_function(env, exports, "__test_liveStrandedResolverRefCount", napi_test_live_stranded_resolver_ref_count) || + !export_function(env, exports, "__test_bridgeFreeCount", napi_test_bridge_free_count) || + !export_function(env, exports, "__test_postReclamationActionCount", napi_test_post_reclamation_action_count) || + !export_function(env, exports, "__test_outputStats", napi_test_output_stats) || + !export_function(env, exports, "__test_outputOperationId", napi_test_output_operation_id) || + !export_function(env, exports, "__test_createForeignWrappedObject", napi_test_create_foreign_wrapped_object) || + !export_function(env, exports, "__test_failNextOutputSettlement", napi_test_fail_next_output_settlement) || + !export_function(env, exports, "__test_failNextOutputExceptionClear", napi_test_fail_next_output_exception_clear) || + !export_function(env, exports, "__test_holdNextOutputDelivery", napi_test_hold_next_output_delivery) || + !export_function(env, exports, "__test_heldOutputDelivery", napi_test_held_output_delivery) || + !export_function(env, exports, "__test_releaseOutputDelivery", napi_test_release_output_delivery)) { + napi_throw_error(env, NULL, "Failed to register DataWeave native test exports"); + return NULL; + } } return exports; diff --git a/native-lib/node/src/dataweave.ts b/native-lib/node/src/dataweave.ts index 59f3c4da..fd9791cb 100644 --- a/native-lib/node/src/dataweave.ts +++ b/native-lib/node/src/dataweave.ts @@ -3,11 +3,22 @@ import { resolveAddonPath } from "./addon-path"; import { findLibrary, buildInputsJson } from "./utils"; import { parseNativeResponse } from "./result"; import { createChunkReader } from "./reader"; -import { streamFromNative } from "./stream"; +import { interruptNativeStreamIfParked, nativeStreamParked, streamFromNative } from "./stream"; import { DataWeaveError, DataWeaveScriptError } from "./errors"; +import type { NativeStreamingOperation } from "./ffi"; import type { ExecutionResult, StreamingResult, Inputs, TransformOptions } from "./types"; import type { ModuleResolver } from "./resolver"; +interface EngineOperationToken { + readonly handle: number; + readonly generation: number; +} + +interface ActiveStreamState { + completionSettled: boolean; + closeFinalized: boolean; +} + /** * Constructor options for {@link DataWeave}. */ @@ -52,6 +63,9 @@ export class DataWeave { private readonly resolveModule?: ModuleResolver; private state: "uninitialized" | "ready" | "cleaning-up" = "uninitialized"; private engineHandle: number | null = null; + private engineGeneration = 0; + private readonly activeStreams = new Set(); + private readonly activeStreamStates = new Map(); private cleanupPromise: Promise | null = null; /** @@ -89,9 +103,10 @@ export class DataWeave { try { ffi.initialize(this.libPath, this.addonPath); libRefAcquired = true; - this.engineHandle = this.resolveModule + const engineHandle = this.resolveModule ? ffi.createEngineWithResolver(this.resolveModule) : ffi.createEngine(); + this.engineHandle = engineHandle; } 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 and @@ -136,6 +151,7 @@ export class DataWeave { throw new DataWeaveError(`Failed to initialize: ${e instanceof Error ? e.message : e}`); } this.state = "ready"; + this.engineGeneration++; } /** @@ -170,9 +186,9 @@ export class DataWeave { // 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; + // A lifecycle failure leaves the instance in "cleaning-up" with no live + // cleanupPromise so a later call can retry without admitting new work. + if (this.state === "uninitialized") return; this.cleanupPromise = this.doCleanup(); try { await this.cleanupPromise; @@ -188,7 +204,40 @@ 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; + const activeStreams = [...this.activeStreams]; + let lifecycleError: { readonly hasError: false } | { readonly hasError: true; readonly error: unknown } = { + hasError: false, + }; + for (const operation of activeStreams) { + try { + operation.cancel(); + } catch (error) { + if (!lifecycleError.hasError) lifecycleError = { hasError: true, error }; + } + } + // A failed synchronous cancellation cannot guarantee that completion will + // ever settle. Abort before waiting or destroying the engine and keep the + // instance in cleaning-up state so a later cleanup() can retry. + if (lifecycleError.hasError) throw lifecycleError.error; + + if (activeStreams.length > 0) { + await Promise.allSettled(activeStreams.map((operation) => operation.completion)); + } + + for (const operation of activeStreams) { + const streamState = this.activeStreamStates.get(operation); + if (!streamState || streamState.closeFinalized) continue; + try { + operation.close(); + } catch (error) { + if (!lifecycleError.hasError) lifecycleError = { hasError: true, error }; + } + } + if (lifecycleError.hasError) throw lifecycleError.error; + + let destroyError: { readonly hasError: false } | { readonly hasError: true; readonly error: unknown } = { + hasError: false, + }; try { if (this.engineHandle !== null) { try { @@ -199,7 +248,7 @@ export class DataWeave { // 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; + destroyError = { hasError: true, error: e }; } finally { this.engineHandle = null; } @@ -212,7 +261,7 @@ export class DataWeave { // 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; + if (destroyError.hasError) throw destroyError.error; } /** @@ -251,11 +300,25 @@ export class DataWeave { * @returns An async generator of output chunks, returning the streaming metadata. * @throws DataWeaveError if the runtime is not initialized. */ - async *runStreaming(script: string, inputs?: Inputs): AsyncGenerator { - this.ensureReady(); - const inputsJson = buildInputsJson(inputs ?? {}); - return yield* streamFromNative((chunkCb) => - ffi.runScriptStreamingEngine(this.engineHandle!, script, inputsJson, chunkCb) + runStreaming(script: string, inputs?: Inputs): AsyncGenerator { + const token = this.captureOperationToken(); + return this.runStreamingInternal(token, script, inputs); + } + + private runStreamingInternal( + token: EngineOperationToken, + script: string, + inputs?: Inputs + ): AsyncGenerator { + return streamFromNative( + (chunkCb) => { + this.assertCurrentOperation(token); + const inputsJson = buildInputsJson(inputs ?? {}); + this.assertCurrentOperation(token); + return ffi.runScriptStreamingEngine(token.handle, script, inputsJson, chunkCb); + }, + (operation) => { this.registerActiveStream(operation); }, + (operation) => { this.markActiveStreamClosed(operation); } ); } @@ -275,43 +338,248 @@ export class DataWeave { * @returns An async generator of output chunks, returning the streaming metadata. * @throws DataWeaveError if the runtime is not initialized. */ - async *runTransform( + runTransform( script: string, input: AsyncIterable | Iterable, opts?: TransformOptions ): AsyncGenerator { - this.ensureReady(); + const token = this.captureOperationToken(); + return this.runTransformInternal(token, script, input, opts); + } + + private runTransformInternal( + token: EngineOperationToken, + script: string, + input: AsyncIterable | Iterable, + opts?: TransformOptions + ): AsyncGenerator { + type QueuedRequest = { + readonly kind: "next" | "control"; + readonly run: (onParked: () => void) => Promise; + readonly resolve: (result: T) => void; + readonly reject: (error: unknown) => void; + }; - const inputName = opts?.inputName ?? "payload"; - const inputMimeType = opts?.mimeType ?? "application/json"; - const inputCharset = opts?.charset ?? null; - const extraInputs = opts?.inputs ?? {}; - const inputsJson = Object.keys(extraInputs).length > 0 ? buildInputsJson(extraInputs) : "{}"; + let closed = false; + let controlPending = false; + let stream: AsyncGenerator | null = null; + let setupPromise: Promise | null>; + let admissionState: "pending" | "abandoned" | "admitted" = "pending"; + let abandonSetup = () => { admissionState = "abandoned"; }; + const requestQueue: Array> = []; + let requestRunning = false; - const readCb = await createChunkReader(input); + const enqueue = ( + kind: QueuedRequest["kind"], + request: QueuedRequest["run"] + ): Promise => { + const result = new Promise((resolve, reject) => { + requestQueue.push({ kind, run: request, resolve, reject } as QueuedRequest); + }); + drainRequests(); + return result; + }; - // 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(); + function drainRequests(): void { + if (requestRunning) return; + const request = requestQueue.shift(); + if (!request) return; + requestRunning = true; + let result: Promise; + try { + result = request.run(() => { + if (controlPending) interruptForControl(); + }); + } catch (error) { + result = Promise.reject(error); + } + result.then(request.resolve, request.reject).finally(() => { + requestRunning = false; + drainRequests(); + }); + } - return yield* streamFromNative((writeCb) => - ffi.runScriptTransformEngine( - this.engineHandle!, - script, - inputsJson, - inputName, - inputMimeType, - inputCharset, - readCb, - writeCb - ) + function interruptAdmittedPullForControl(): void { + const controlIndex = requestQueue.findIndex((request) => request.kind === "control"); + if (controlIndex > 0 && requestQueue.slice(0, controlIndex).some((request) => request.kind === "next")) return; + if (stream) interruptNativeStreamIfParked(stream); + } + + function interruptForControl(): void { + // Setup has no native pull to preserve: settle every queued pre-control + // next() as done immediately. Admitted streams instead retain FIFO until + // the last earlier pull is genuinely parked. + if (admissionState !== "admitted") { + abandonSetup(); + return; + } + interruptAdmittedPullForControl(); + } + + const setup = (): Promise | null> => + setupPromise ??= new Promise((resolve, reject) => { + abandonSetup = () => { + admissionState = "abandoned"; + resolve(null); + }; + try { + if (controlPending) { + abandonSetup(); + return; + } + this.assertCurrentOperation(token); + + const inputName = opts?.inputName ?? "payload"; + const inputMimeType = opts?.mimeType ?? "application/json"; + const inputCharset = opts?.charset ?? null; + const extraInputs = opts?.inputs ?? {}; + const inputsJson = Object.keys(extraInputs).length > 0 ? buildInputsJson(extraInputs) : "{}"; + createChunkReader(input).then( + (readCb) => { + if (admissionState === "abandoned" || controlPending) return; + try { + this.assertCurrentOperation(token); + stream = streamFromNative( + (writeCb) => { + this.assertCurrentOperation(token); + return ffi.runScriptTransformEngine( + token.handle, + script, + inputsJson, + inputName, + inputMimeType, + inputCharset, + readCb, + writeCb + ); + }, + (operation) => { + admissionState = "admitted"; + this.registerActiveStream(operation); + }, + (operation) => { this.markActiveStreamClosed(operation); } + ); + resolve(stream); + } catch (error) { + reject(error); + } + }, + (error) => { + if (admissionState !== "abandoned") reject(error); + } + ); + } catch (error) { + reject(error); + } + }); + + return { + next: (...args: [] | [undefined]) => enqueue("next", async (onParked) => { + if (closed) { + return { done: true, value: undefined } as unknown as IteratorReturnResult; + } + let activeStream: AsyncGenerator | null; + try { + activeStream = await setup(); + } catch (error) { + closed = true; + throw error; + } + if (!activeStream || admissionState === "abandoned") { + return { done: true, value: undefined } as unknown as IteratorReturnResult; + } + const nextPromise = activeStream.next(...args); + nativeStreamParked(activeStream).then((parked) => { + if (parked) onParked(); + }); + const result = await nextPromise; + if (result.done) closed = true; + return result; + }), + return: (value) => { + const isPrimaryControl = !closed && !controlPending; + if (isPrimaryControl) controlPending = true; + const result = enqueue("control", async (_onParked) => { + try { + if (stream && !closed) return await stream.return(value); + return { + done: true, + value: await value, + } as IteratorReturnResult; + } finally { + closed = true; + } + }); + if (isPrimaryControl) interruptForControl(); + return result; + }, + throw: (error?: unknown) => { + const isPrimaryControl = !closed && !controlPending; + if (isPrimaryControl) controlPending = true; + const result = enqueue("control", async (_onParked) => { + try { + if (stream && !closed) return await stream.throw(error); + throw error; + } finally { + closed = true; + } + }); + if (isPrimaryControl) interruptForControl(); + return result; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + } + + private registerActiveStream(operation: NativeStreamingOperation): void { + const state: ActiveStreamState = { completionSettled: false, closeFinalized: false }; + this.activeStreams.add(operation); + this.activeStreamStates.set(operation, state); + operation.completion.then( + () => { + state.completionSettled = true; + this.releaseActiveStream(operation, state); + }, + () => { + state.completionSettled = true; + this.releaseActiveStream(operation, state); + } ); } + private markActiveStreamClosed(operation: NativeStreamingOperation): void { + const state = this.activeStreamStates.get(operation); + if (!state) return; + state.closeFinalized = true; + this.releaseActiveStream(operation, state); + } + + private releaseActiveStream( + operation: NativeStreamingOperation, + state: ActiveStreamState + ): void { + if (!state.completionSettled || !state.closeFinalized) return; + this.activeStreams.delete(operation); + this.activeStreamStates.delete(operation); + } + + private captureOperationToken(): EngineOperationToken { + this.ensureReady(); + return { handle: this.engineHandle!, generation: this.engineGeneration }; + } + + private assertCurrentOperation(token: EngineOperationToken): void { + if ( + this.state !== "ready" || + this.engineHandle !== token.handle || + this.engineGeneration !== token.generation + ) { + throw new DataWeaveError("DataWeave operation belongs to a stale engine generation."); + } + } + private ensureReady(): void { if (this.state === "ready") return; if (this.state === "cleaning-up") { diff --git a/native-lib/node/src/ffi.ts b/native-lib/node/src/ffi.ts index 30c3e85b..113bf92f 100644 --- a/native-lib/node/src/ffi.ts +++ b/native-lib/node/src/ffi.ts @@ -1,6 +1,16 @@ import { resolveAddonPath } from "./addon-path"; +import { DataWeaveError } from "./errors"; import type { ModuleResolver } from "./resolver"; +export interface NativeStreamingOperation { + readonly completion: Promise; + acknowledge(sequence: bigint, bytes: number): void; + cancel(): void; + close(): void; +} + +export type NativeChunkCallback = (chunk: Buffer, sequence: bigint) => void; + interface NativeAddon { initialize(libPath: string): void; createEngine(): number; @@ -11,8 +21,8 @@ interface NativeAddon { handle: number, script: string, inputsJson: string, - chunkCb: (chunk: Buffer) => void - ): Promise; + chunkCb: NativeChunkCallback + ): NativeStreamingOperation; runScriptTransformEngine( handle: number, script: string, @@ -21,13 +31,30 @@ interface NativeAddon { inputMimeType: string, inputCharset: string | null, readCb: (bufSize: number) => Buffer | null, - writeCb: (chunk: Buffer) => void - ): Promise; + writeCb: NativeChunkCallback + ): NativeStreamingOperation; cleanup(): Promise; } let addon: NativeAddon | null = null; +function callNative(invoke: () => T): T { + try { + return invoke(); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ERR_DATAWEAVE_CALLBACK_REENTRANCY" + ) { + const message = "message" in error ? String(error.message) : String(error); + throw new DataWeaveError(message); + } + throw error; + } +} + function getAddon(addonPath?: string): NativeAddon { if (!addon) { addon = require(addonPath ?? resolveAddonPath()) as NativeAddon; @@ -36,32 +63,34 @@ function getAddon(addonPath?: string): NativeAddon { } export function initialize(libPath: string, addonPath?: string): void { - getAddon(addonPath).initialize(libPath); + callNative(() => getAddon(addonPath).initialize(libPath)); } export function createEngine(): number { - return getAddon().createEngine(); + return callNative(() => getAddon().createEngine()); } export function createEngineWithResolver(resolver: ModuleResolver): number { - return getAddon().createEngineWithResolver(resolver); + return callNative(() => getAddon().createEngineWithResolver(resolver)); } export function destroyEngine(handle: number): void { - getAddon().destroyEngine(handle); + callNative(() => getAddon().destroyEngine(handle)); } export function runScriptEngine(handle: number, script: string, inputsJson: string): string { - return getAddon().runScriptEngine(handle, script, inputsJson); + return callNative(() => getAddon().runScriptEngine(handle, script, inputsJson)); } export function runScriptStreamingEngine( handle: number, script: string, inputsJson: string, - chunkCb: (chunk: Buffer) => void -): Promise { - return getAddon().runScriptStreamingEngine(handle, script, inputsJson, chunkCb); + chunkCb: NativeChunkCallback +): NativeStreamingOperation { + return callNative(() => + getAddon().runScriptStreamingEngine(handle, script, inputsJson, chunkCb) + ); } export function runScriptTransformEngine( @@ -72,20 +101,22 @@ export function runScriptTransformEngine( inputMimeType: string, inputCharset: string | null, readCb: (bufSize: number) => Buffer | null, - writeCb: (chunk: Buffer) => void -): Promise { - return getAddon().runScriptTransformEngine( - handle, - script, - inputsJson, - inputName, - inputMimeType, - inputCharset, - readCb, - writeCb + writeCb: NativeChunkCallback +): NativeStreamingOperation { + return callNative(() => + getAddon().runScriptTransformEngine( + handle, + script, + inputsJson, + inputName, + inputMimeType, + inputCharset, + readCb, + writeCb + ) ); } export function cleanup(): Promise { - return getAddon().cleanup(); + return callNative(() => getAddon().cleanup()); } diff --git a/native-lib/node/src/stream.ts b/native-lib/node/src/stream.ts index 032d5a68..60a389c3 100644 --- a/native-lib/node/src/stream.ts +++ b/native-lib/node/src/stream.ts @@ -1,81 +1,310 @@ import { parseStreamingResult } from "./result"; +import type { NativeChunkCallback, NativeStreamingOperation } from "./ffi"; import type { StreamingResult } from "./types"; /** * Starts a native streaming call, wiring its chunk callback to `chunkCb` and - * resolving to the raw trailing metadata JSON once the stream completes. + * returning its controller once native admission succeeds. */ -export type StartStreaming = (chunkCb: (chunk: Buffer) => void) => Promise; +export type StartStreaming = (chunkCb: NativeChunkCallback) => NativeStreamingOperation; + +interface NativeChunk { + readonly chunk: Buffer; + readonly sequence: bigint; +} + +interface InterruptibleAsyncGenerator extends AsyncGenerator { + readonly parked: Promise; + interruptIfParked(): boolean; +} + +type NativeStreamIterator = InterruptibleAsyncGenerator; + +/** Interrupts a delegated pull only when it has no buffered result to consume. */ +export function interruptNativeStreamIfParked( + iterator: AsyncGenerator +): boolean { + return (iterator as NativeStreamIterator).interruptIfParked(); +} + +/** Reports whether the currently delegated pull had to wait for native output. */ +export function nativeStreamParked( + iterator: AsyncGenerator +): Promise { + return (iterator as NativeStreamIterator).parked; +} + +type ErrorState = { readonly hasError: false } | { readonly hasError: true; readonly error: unknown }; + +const NO_ERROR: ErrorState = { hasError: false }; /** * Bridges a native push-based streaming call into a pull-based async generator. * - * The native side pushes output chunks through the callback while - * {@link StartStreaming} runs; this generator buffers them and yields in order, - * parking the consumer when no chunk is ready and waking it on the next push or - * on completion. After all chunks drain, it awaits the native promise and - * returns the parsed {@link StreamingResult}. + * Native async generators serialize `return()` behind an outstanding `next()`. + * This wrapper intercepts `return()` and `throw()` so they can request native + * cancellation and wake a parked pull before delegating generator finalization. * - * @param start - Launches the native call and returns its metadata promise. - * @returns An async generator of output chunks whose return value is the terminal metadata. + * @param start - Launches the native call and returns its operation controller. + * @param onStart - Called once after native admission with the managed operation. + * @param onClose - Called once after the native controller closes successfully. + * @returns An async generator of output chunks whose return value is terminal metadata. */ -export async function* streamFromNative( - start: StartStreaming +export function streamFromNative( + start: StartStreaming, + onStart?: (operation: NativeStreamingOperation) => void, + onClose?: (operation: NativeStreamingOperation) => void ): AsyncGenerator { - const chunks: Buffer[] = []; + const chunks: NativeChunk[] = []; const pendingResolves: Array<() => void> = []; - let done = false; + let operation: NativeStreamingOperation | undefined; + let nativeSettled = false; + let nativeSettlementHandled: Promise | undefined; + let nativeRejected = false; + let nativeError: unknown; let metaRaw: string | null = null; + let cancellationRequested = false; + let cancelSucceeded = false; + let nativeCloseSucceeded = false; + let closeFinalized = false; + let registered = false; + let finalized = false; + let finalizationError: ErrorState = NO_ERROR; + let nativeOperation: NativeStreamingOperation | undefined; + let cancelInProgress = false; + let pullParked = false; + let interruptionError: ErrorState = NO_ERROR; + let parked = Promise.resolve(false); + let resolveParked: ((value: boolean) => void) | undefined; + + const wakeAll = () => { + while (pendingResolves.length > 0) { + pendingResolves.shift()!(); + } + }; - const chunkCb = (chunk: Buffer) => { - chunks.push(chunk); - // Resolve one waiting consumer if any - const resolve = pendingResolves.shift(); - if (resolve) { - resolve(); + const acknowledgeBufferedChunks = () => { + while (chunks.length > 0) { + const { chunk, sequence } = chunks.shift()!; + operation!.acknowledge(sequence, chunk.length); } }; - let startError: unknown; - let startRejected = false; - const wakeAll = () => { - while (pendingResolves.length > 0) { - const resolve = pendingResolves.shift(); - if (resolve) resolve(); + const close = () => { + if (!nativeOperation || closeFinalized) return; + if (!nativeCloseSucceeded) { + nativeOperation.close(); + nativeCloseSucceeded = true; + } + if (registered) onClose?.(operation!); + closeFinalized = true; + }; + + const cancel = () => { + cancellationRequested = true; + let lifecycleError: ErrorState = NO_ERROR; + try { + acknowledgeBufferedChunks(); + } catch (error) { + lifecycleError = { hasError: true, error }; + } finally { + wakeAll(); + } + + if (nativeOperation && !nativeSettled && !cancelSucceeded && !cancelInProgress) { + cancelInProgress = true; + try { + nativeOperation.cancel(); + cancelSucceeded = true; + } catch (error) { + if (!lifecycleError.hasError) lifecycleError = { hasError: true, error }; + } finally { + cancelInProgress = false; + } + } + + if (nativeOperation && (nativeSettled || cancelSucceeded)) { + try { + close(); + } catch (error) { + if (!lifecycleError.hasError) lifecycleError = { hasError: true, error }; + } + } + + if (lifecycleError.hasError) throw lifecycleError.error; + }; + + const chunkCb: NativeChunkCallback = (chunk, sequence) => { + if (finalized || cancellationRequested) { + try { + operation?.acknowledge(sequence, chunk.length); + } catch { + // No consumer remains to observe a late callback failure. Ownership is + // retained unless close succeeds, so DataWeave cleanup can still retry. + } + return; } + chunks.push({ chunk, sequence }); + pendingResolves.shift()?.(); }; - // 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, 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; startRejected = true; done = true; wakeAll(); } - ); - - while (true) { - if (chunks.length > 0) { - yield chunks.shift()!; - continue; + function requestCancellation(): ErrorState { + cancellationRequested = true; + wakeAll(); + try { + cancel(); + return NO_ERROR; + } catch (error) { + return { hasError: true, error }; } - if (done) break; - await new Promise((resolve) => { pendingResolves.push(resolve); }); } - // Drain remaining chunks buffered before completion/rejection. - while (chunks.length > 0) { - yield chunks.shift()!; + function interruptPull(): void { + const lifecycleError = requestCancellation(); + if (lifecycleError.hasError) interruptionError = lifecycleError; } - await metaPromise; - // 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 + const generator = (async function* (): AsyncGenerator { + let primaryError = false; + try { + nativeOperation = start(chunkCb); + const startedOperation = nativeOperation; + operation = { + completion: startedOperation.completion, + acknowledge: (sequence, bytes) => startedOperation.acknowledge(sequence, bytes), + cancel, + close, + }; + + nativeSettlementHandled = operation.completion.then( + (raw) => { + metaRaw = raw; + nativeSettled = true; + wakeAll(); + }, + (error) => { + nativeError = error; + nativeRejected = true; + nativeSettled = true; + wakeAll(); + } + ); + + onStart?.(operation); + registered = true; + + while (true) { + if (!cancellationRequested && chunks.length > 0) { + const { chunk, sequence } = chunks.shift()!; + operation.acknowledge(sequence, chunk.length); + yield chunk; + continue; + } + if (nativeSettled) break; + if (cancellationRequested) { + return undefined as unknown as StreamingResult; + } + const wake = new Promise((resolve) => { pendingResolves.push(resolve); }); + pullParked = true; + resolveParked?.(true); + await wake; + pullParked = false; + if (interruptionError.hasError) throw interruptionError.error; + } + + await nativeSettlementHandled; + if (nativeRejected) throw nativeError; + if (cancellationRequested) return undefined as unknown as StreamingResult; + return parseStreamingResult(metaRaw ?? ""); + } catch (error) { + primaryError = true; + if (operation && !registered) { + try { + cancel(); + } catch { + // Preserve the registration failure as primary. + } + } + throw error; + } finally { + finalized = true; + let lifecycleError: ErrorState = NO_ERROR; + if (operation && registered) { + if (!nativeSettled && !cancelSucceeded && registered) { + try { + cancel(); + } catch (error) { + lifecycleError = { hasError: true, error }; + } + } + if ((nativeSettled || cancelSucceeded) && !closeFinalized) { + try { + close(); + } catch (error) { + if (!lifecycleError.hasError) lifecycleError = { hasError: true, error }; + } + } + } + wakeAll(); + if (!primaryError && lifecycleError.hasError) { + finalizationError = lifecycleError; + throw lifecycleError.error; + } + } + })(); + + const iterator: NativeStreamIterator = { + get parked() { + return parked; + }, + next(...args: [] | [undefined]) { + parked = new Promise((resolve) => { resolveParked = resolve; }); + return generator.next(...args).then( + (result) => { + resolveParked?.(false); + return result; + }, + (error) => { + resolveParked?.(false); + throw error; + } + ); + }, + return(value) { + const lifecycleError = requestCancellation(); + return generator.return(value).then( + (result) => { + if (lifecycleError.hasError) throw lifecycleError.error; + if (finalizationError.hasError) throw finalizationError.error; + return result; + }, + (error) => { + // A retry failure from generator finalization cannot replace the + // lifecycle failure observed by this cancellation request. + if (lifecycleError.hasError) throw lifecycleError.error; + throw error; + } + ); + }, + throw(error?: unknown) { + const lifecycleError = requestCancellation(); + return generator.throw(error).then( + (result) => { + if (lifecycleError.hasError) throw lifecycleError.error; + if (finalizationError.hasError) throw finalizationError.error; + return result; + }, + (primary) => { throw primary; } + ); + }, + interruptIfParked() { + if (!pullParked || chunks.length > 0 || nativeSettled || cancellationRequested) return false; + interruptPull(); + return true; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + return iterator; +} diff --git a/native-lib/node/tests/integration/admission-during-teardown.test.ts b/native-lib/node/tests/integration/admission-during-teardown.test.ts index a87b7f18..91bf8513 100644 --- a/native-lib/node/tests/integration/admission-during-teardown.test.ts +++ b/native-lib/node/tests/integration/admission-during-teardown.test.ts @@ -1,101 +1,56 @@ import { describe, it, expect } from "vitest"; import * as ffi from "../../src/ffi"; +import { DataWeaveError } from "../../src/errors"; 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 () => { +interface TestAddon { + __test_holdNextAsyncOp(): void; + __test_asyncOpHeld(): boolean; + __test_releaseAsyncOp(): void; +} + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const testAddon = require("../../build/Release/dwlib_addon.node") as TestAddon; + +async function waitForAsyncOpGate(): Promise { + const deadline = Date.now() + 10000; + while (!testAddon.__test_asyncOpHeld()) { + if (Date.now() >= deadline) throw new Error("async operation did not reach test gate"); + await new Promise((resolve) => setImmediate(resolve)); + } +} + +// Task 6 adds this callback-specific contract alongside the pending-teardown +// lifecycle regression below. While native code invokes a transform read +// callback, all isolate-touching methods are rejected before lifecycle mutation +// or worker admission. Use cleanup and streaming start together to cover both +// guards and the TypeScript mapping. +describe("transform read callback admission guard", () => { + it("rejects cleanup and streaming start before native admission", async () => { ffi.initialize(findLibrary()); const handle = ffi.createEngine(); - let cleanupPromise: Promise | undefined; + let cleanupErr: unknown; 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; } - ); + ffi.cleanup(); + } catch (e) { + cleanupErr = e; + } + try { + ffi.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1,2,3]", + buildInputsJson({}), + () => {} + ); + admitted = true; } catch (e) { admitErr = e; } @@ -108,29 +63,88 @@ describe("admission rejected while teardown pending (round 6 #2)", () => { 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); + try { + 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); + expect(cleanupErr).toBeInstanceOf(DataWeaveError); + expect(admitErr).toBeInstanceOf(DataWeaveError); + expect(admitted).toBe(false); + } finally { + ffi.destroyEngine(handle); + await ffi.cleanup(); + } + }, 20000); +}); + +describe("admission rejected while teardown pending (round 6 #2)", () => { + it("rejects a streaming op on the same handle while teardown is pending", async () => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + let cleanupPromise: Promise | undefined; + let gateArmed = false; + let gateReleased = false; + let handleDestroyed = false; + const chunks: Buffer[] = []; + + try { + testAddon.__test_holdNextAsyncOp(); + gateArmed = true; + const outerPromise = ffi.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1,2,3]", + buildInputsJson({}), + (chunk) => chunks.push(chunk) + ); + await waitForAsyncOpGate(); + cleanupPromise = ffi.cleanup(); - // 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; + let admitErr: unknown; + let admitted = false; + let secondOpSettled: Promise = Promise.resolve(); + try { + secondOpSettled = ffi.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1,2,3]", + buildInputsJson({}), + () => {} + ).then( + () => { admitted = true; }, + (error) => { admitErr = error; } + ); + } catch (error) { + admitErr = error; + } - // 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); + await secondOpSettled; + expect(admitErr).toBeTruthy(); + expect(admitted).toBe(false); + + ffi.destroyEngine(handle); + handleDestroyed = true; + testAddon.__test_releaseAsyncOp(); + gateReleased = true; + const outerResult = JSON.parse(await outerPromise); + expect(outerResult.success).toBe(true); + expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toEqual([1, 2, 3]); + await cleanupPromise; + } finally { + if (gateArmed && !gateReleased) testAddon.__test_releaseAsyncOp(); + try { + if (!handleDestroyed) ffi.destroyEngine(handle); + } finally { + if (cleanupPromise) await cleanupPromise; + await ffi.cleanup(); + } + } }, 20000); }); diff --git a/native-lib/node/tests/integration/dataweave-resolver.test.ts b/native-lib/node/tests/integration/dataweave-resolver.test.ts index eca77297..ed374edb 100644 --- a/native-lib/node/tests/integration/dataweave-resolver.test.ts +++ b/native-lib/node/tests/integration/dataweave-resolver.test.ts @@ -308,12 +308,10 @@ describe('DataWeave with resolver', () => { ]) ).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(); + // cleanup cancels the iterator; Task 8 defines cancellation as a terminal + // undefined return rather than native metadata from the abandoned run. + await firstNext; + await expect(gen.next()).resolves.toEqual({ done: true, value: undefined }); }, 15000); // Same deadlock regression as above, for runTransform() -- the design doc @@ -338,16 +336,11 @@ describe('DataWeave with resolver', () => { 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(); + // Wait until the native transform is admitted, not merely one microtask. + // Input preparation has multiple async boundaries and cleanup before the + // admission boundary correctly invalidates the operation generation. + const firstChunk = await firstNext; + expect(firstChunk.done).toBe(false); const cleanupPromise = dw.cleanup(); @@ -358,11 +351,7 @@ describe('DataWeave with resolver', () => { ]) ).resolves.toBeUndefined(); - let result = await firstNext; - while (!result.done) { - result = await gen.next(); - } - expect(result.value).toBeDefined(); + await expect(gen.next()).resolves.toEqual({ done: true, value: undefined }); }, 15000); // Fast-path regression guard: cleanup() called once a stream has already diff --git a/native-lib/node/tests/integration/detach-poison-hook.test.ts b/native-lib/node/tests/integration/detach-poison-hook.test.ts new file mode 100644 index 00000000..fe752ee1 --- /dev/null +++ b/native-lib/node/tests/integration/detach-poison-hook.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { findLibrary } from "../../src/utils"; + +const ADDON_PATH = join(__dirname, "..", "..", "build", "Release", "dwlib_addon.node"); +const LIB_PATH = findLibrary(); +const SYNC_FIXTURE = join(__dirname, "fixtures", "detach-poison-sync.cjs"); +const TRANSFORM_FIXTURE = join(__dirname, "fixtures", "detach-poison-transform.cjs"); +const FATAL_STDERR = + /Must either be at a safepoint or in native mode|fatal error|SIGSEGV|segmentation fault|SIGABRT|\babort(?:ed|ing)?\b/i; + +const DETACH_SITES = [ + "bridge-finalize", + "stream-worker", + "transform-worker", + "create-engine", + "create-rollback", + "resolver-create", + "unknown-destroy", + "synchronous-run", +] as const; + +function runFixture( + fixture: string, + args: string[], + hooks = true +): Record { + const env = { ...process.env }; + if (hooks) env.DATAWEAVE_TEST_HOOKS = "1"; + else delete env.DATAWEAVE_TEST_HOOKS; + + const child = spawnSync( + process.execPath, + [fixture, ADDON_PATH, LIB_PATH, ...args], + { + cwd: __dirname, + encoding: "utf-8", + timeout: 30_000, + env, + } + ); + + expect(child.error, child.error?.message).toBeUndefined(); + expect(child.signal, child.stderr).toBeNull(); + expect(child.status, child.stderr).not.toBe(99); + expect(child.status, child.stderr).toBe(0); + expect(child.stderr, child.stderr).not.toMatch(FATAL_STDERR); + expect(child.stdout.trim(), child.stderr).not.toBe(""); + return JSON.parse(child.stdout.trim()) as Record; +} + +describe("detach failure poisoning and recovery", () => { + it("preserves a completed synchronous result, fails later admission closed, and recovers with a fresh isolate", () => { + expect(runFixture(SYNC_FIXTURE, ["recovery"])).toMatchObject({ + firstResult: "42", + rejectedAdmissions: 6, + forcedFailures: 1, + abandoned: 1, + freshResult: "42", + }); + }); + + it("prevents an old-generation handle from destroying a fresh engine with the same native handle", () => { + expect(runFixture(SYNC_FIXTURE, ["stale-handle"])).toMatchObject({ + handlesDiffer: true, + freshResult: "42", + }); + }); + + it("prevents an old-generation env finalizer from destroying a fresh engine", () => { + expect(runFixture(SYNC_FIXTURE, ["stale-finalizer"])).toMatchObject({ + handlesDiffer: true, + freshResult: "42", + }); + }); + + it("poisons a transform worker during an active final cleanup without hanging or tearing down the old isolate", () => { + expect(runFixture(TRANSFORM_FIXTURE, [])).toMatchObject({ + transformResult: "detach poison transform", + forcedFailures: 1, + abandoned: 1, + freshResult: "42", + }); + }); + + it.each(DETACH_SITES)("accepts the exact detach site name %s", (site) => { + expect(runFixture(SYNC_FIXTURE, ["validate-site", site])).toEqual({ site }); + }); + + it.each([ + "bridge-finalize", + "stream-worker", + "create-engine", + "resolver-create", + "unknown-destroy", + ] as const)("forces and recovers from the ordinary detach site %s", (site) => { + expect(runFixture(SYNC_FIXTURE, ["exercise-site", site])).toMatchObject({ + site, + forcedFailures: 1, + abandoned: 1, + freshResult: "42", + }); + }); + + it("forces a synchronous-run detach only after the successful result is copied", () => { + expect(runFixture(SYNC_FIXTURE, ["exercise-site", "synchronous-run"])).toMatchObject({ + site: "synchronous-run", + forcedFailures: 1, + abandoned: 1, + triggeringResult: "42", + freshResult: "42", + }); + }); + + it("executes and recovers from the create-rollback detach site", () => { + expect(runFixture(SYNC_FIXTURE, ["exercise-create-rollback"])).toMatchObject({ + forcedFailures: 1, + abandoned: 1, + freshResult: "42", + }); + }); + + it("allocates the final two exact JS handles and rolls back the next native engine", () => { + expect(runFixture(SYNC_FIXTURE, ["handle-exhaustion"])).toEqual({ + firstHandle: Number.MAX_SAFE_INTEGER - 1, + secondHandle: Number.MAX_SAFE_INTEGER, + firstResult: "42", + secondResult: "42", + exhaustionRejected: true, + forcedRollbackDetach: 1, + isolatePoisoned: true, + abandoned: 1, + }); + }); + + it("clears an armed record-allocation fault after normal generation teardown", () => { + expect(runFixture(SYNC_FIXTURE, ["allocation-fault-normal-reset"])).toEqual({ + freshResult: "42", + freshCreateFailed: false, + }); + }); + + it("clears an armed record-allocation fault after poisoned generation abandonment", () => { + expect(runFixture(SYNC_FIXTURE, ["allocation-fault-poison-reset"])).toEqual({ + freshResult: "42", + freshCreateFailed: false, + }); + }); + + it("tears down an unpublishable isolate rather than wrapping generation identity", () => { + expect(runFixture(SYNC_FIXTURE, ["generation-exhaustion"])).toEqual({ + exhaustionRejected: true, + creationDelta: 1, + teardownDelta: 2, + generation: "18446744073709551615", + }); + }); + + it("rejects identity and allocation fault hooks outside their safe mutation states", () => { + expect(runFixture(SYNC_FIXTURE, ["identity-hook-validation"])).toEqual({ + fractionalHandleRejected: true, + nanHandleRejected: true, + armBeforeInitializeRejected: true, + duplicateArmRejected: true, + handleMutationWithBridgeRejected: true, + generationMutationWithBridgeRejected: true, + armAfterPoisonRejected: true, + }); + }); + + it("retains a resolver bridge when handle-exhaustion rollback cannot attach", () => { + expect(runFixture(SYNC_FIXTURE, ["handle-exhaustion-rollback-strand"])).toEqual({ + exhaustionRejected: true, + strandedDelta: 1, + }); + }); + + it("blocks cross-worker admission until synchronous detach failure is published", () => { + expect(runFixture(SYNC_FIXTURE, ["detach-publication-race", "synchronous-run"])).toEqual({ + admittedBeforeRelease: false, + admissionRejectedPoison: true, + forcedFailures: 1, + abandoned: 1, + freshResult: "42", + }); + }); + + it("blocks cross-worker admission until stream-worker detach failure is published", () => { + expect(runFixture(SYNC_FIXTURE, ["detach-publication-race", "stream-worker"])).toEqual({ + admittedBeforeRelease: false, + admissionRejectedPoison: true, + forcedFailures: 1, + abandoned: 1, + freshResult: "42", + }); + }); + + it("deletes a stranded rollback resolver reference on its live owner env", () => { + expect(runFixture(SYNC_FIXTURE, ["handle-exhaustion-owner-cleanup"])).toEqual({ + exhaustionRejected: true, + strandedBeforeCleanup: 1, + strandedAfterHandoff: 0, + resolverDeletes: 1, + liveStrandedResolverRefs: 0, + }); + }); + + it("frees resolver-less bridges exactly once through explicit destroy and env finalization", () => { + expect(runFixture(SYNC_FIXTURE, ["resolverless-finalization"])).toEqual({ + expectedFrees: 200, + actualFrees: 200, + postReclamationActions: 0, + }); + }); + + it("rejects invalid sites and refuses to silently replace an armed failure", () => { + expect(runFixture(SYNC_FIXTURE, ["invalid-arguments"])).toEqual({ + invalidArguments: 6, + duplicateArmRejected: true, + }); + }); + + it("consumes only the selected site once while poison remains set", () => { + expect(runFixture(SYNC_FIXTURE, ["one-shot-site"])).toEqual({ + forcedFailures: 1, + poisonPersisted: true, + }); + }); + + it("does not export detach-poison hooks without DATAWEAVE_TEST_HOOKS", () => { + expect(runFixture(SYNC_FIXTURE, ["hooks-absent"], false)).toEqual({ + hooksAbsent: true, + }); + }); +}); diff --git a/native-lib/node/tests/integration/fixtures/detach-poison-sync.cjs b/native-lib/node/tests/integration/fixtures/detach-poison-sync.cjs new file mode 100644 index 00000000..f2c03fc6 --- /dev/null +++ b/native-lib/node/tests/integration/fixtures/detach-poison-sync.cjs @@ -0,0 +1,710 @@ +"use strict"; + +const path = require("node:path"); +const { once } = require("node:events"); +const { Worker } = require("node:worker_threads"); + +const addonPath = path.resolve(process.cwd(), process.argv[2]); +const libPath = process.argv[3]; +const mode = process.argv[4]; +const addon = require(addonPath); + +const POISONED_MESSAGE = + "DataWeave isolate is unavailable after a thread detach failure; clean up and initialize again."; +const DETACH_HOOKS = [ + "__test_forceDetachFailureOnce", + "__test_isolatePoisoned", + "__test_isolateCreationCount", + "__test_teardownCallCount", + "__test_abandonedIsolateCount", + "__test_forcedDetachFailureCount", +]; +const TEST_HOOKS = [ + ...DETACH_HOOKS, + "__test_failNextEngineRecordAllocation", + "__test_setNextEngineHandle", + "__test_setIsolateGeneration", + "__test_isolateGeneration", + "__test_holdDetachPublication", + "__test_detachPublicationHeld", + "__test_detachPublicationWaiters", + "__test_releaseDetachPublication", + "__test_liveStrandedResolverRefCount", + "__test_bridgeFreeCount", + "__test_postReclamationActionCount", +]; +const MAX_SAFE_HANDLE = Number.MAX_SAFE_INTEGER; +const UINT64_MAX = 18_446_744_073_709_551_615n; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function expectThrow(invoke, message) { + try { + invoke(); + } catch (error) { + assert(error instanceof Error, "expected an Error"); + assert(error.message === message, `expected ${JSON.stringify(message)}, got ${JSON.stringify(error.message)}`); + return; + } + throw new Error(`expected synchronous throw: ${message}`); +} + +function count(name) { + const value = addon[name](); + assert(typeof value === "bigint", `${name} must return a lossless bigint`); + return value; +} + +function successfulResult(raw) { + const result = JSON.parse(raw); + assert(result.success === true, `expected successful result, got ${raw}`); + return Buffer.from(result.result, "base64").toString("utf8"); +} + +function withTimeout(promise, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), 10_000); + }), + ]).finally(() => clearTimeout(timer)); +} + +async function recovery() { + for (const hook of DETACH_HOOKS) { + assert(typeof addon[hook] === "function", `${hook} is undefined`); + } + + addon.initialize(libPath); + const firstHandle = addon.createEngine(); + const creationBefore = count("__test_isolateCreationCount"); + const teardownBefore = count("__test_teardownCallCount"); + const abandonedBefore = count("__test_abandonedIsolateCount"); + const forcedBefore = count("__test_forcedDetachFailureCount"); + + addon.__test_forceDetachFailureOnce("synchronous-run"); + const otherHandle = addon.createEngine(); + assert(addon.__test_isolatePoisoned() === false, "a different detach site consumed the arm"); + assert(count("__test_forcedDetachFailureCount") === forcedBefore, "forced count changed at a different site"); + + const firstResult = successfulResult( + addon.runScriptEngine(firstHandle, "output application/json --- 6 * 7", "{}") + ); + assert(firstResult === "42", `unexpected first result: ${firstResult}`); + assert(addon.__test_isolatePoisoned() === true, "the isolate was not poisoned"); + assert(count("__test_forcedDetachFailureCount") === forcedBefore + 1n, "forced failure was not one-shot"); + + let rejectedAdmissions = 0; + const liveFlowsBefore = addon.__test_outputStats().liveFlows; + const reject = (invoke) => { + expectThrow(invoke, POISONED_MESSAGE); + rejectedAdmissions++; + }; + reject(() => addon.runScriptEngine(firstHandle, "output application/json --- 1", "{}")); + reject(() => addon.createEngine()); + reject(() => addon.createEngineWithResolver(() => null)); + reject(() => addon.runScriptStreamingEngine(firstHandle, "output application/json --- []", "{}", () => {})); + reject(() => addon.runScriptTransformEngine( + firstHandle, + "output application/json --- payload", + "{}", + "payload", + "application/json", + "UTF-8", + () => null, + () => {} + )); + reject(() => addon.initialize(libPath)); + assert(addon.__test_outputStats().liveFlows === liveFlowsBefore, "rejected async work allocated an output flow"); + + // Engine destruction remains permitted after poison. Final cleanup abandons + // the generation without attempting isolate teardown. + addon.destroyEngine(otherHandle); + addon.destroyEngine(firstHandle); + await withTimeout(addon.cleanup(), "poisoned isolate cleanup"); + assert(count("__test_teardownCallCount") === teardownBefore, "poisoned cleanup invoked teardown"); + assert(count("__test_abandonedIsolateCount") === abandonedBefore + 1n, "poisoned cleanup did not abandon once"); + + addon.initialize(libPath); + assert(count("__test_isolateCreationCount") === creationBefore + 1n, "recovery did not create one fresh isolate"); + const freshHandle = addon.createEngine(); + const freshResult = successfulResult( + addon.runScriptEngine(freshHandle, "output application/json --- 6 * 7", "{}") + ); + assert(freshResult === "42", `unexpected fresh result: ${freshResult}`); + assert(addon.__test_isolatePoisoned() === false, "fresh isolate inherited poison"); + addon.destroyEngine(freshHandle); + await withTimeout(addon.cleanup(), "fresh isolate cleanup"); + assert(count("__test_teardownCallCount") === teardownBefore + 1n, "fresh isolate was not torn down once"); + assert(count("__test_abandonedIsolateCount") === abandonedBefore + 1n, "fresh cleanup changed abandon count"); + + return { + firstResult, + rejectedAdmissions, + forcedFailures: Number(count("__test_forcedDetachFailureCount") - forcedBefore), + abandoned: Number(count("__test_abandonedIsolateCount") - abandonedBefore), + freshResult, + }; +} + +async function staleHandle() { + addon.initialize(libPath); + const oldHandle = addon.createEngine(); + addon.__test_forceDetachFailureOnce("synchronous-run"); + successfulResult(addon.runScriptEngine(oldHandle, "output application/json --- 6 * 7", "{}")); + await withTimeout(addon.cleanup(), "old isolate cleanup"); + + addon.initialize(libPath); + const freshHandle = addon.createEngine(); + addon.destroyEngine(oldHandle); + const freshResult = successfulResult( + addon.runScriptEngine(freshHandle, "output application/json --- 6 * 7", "{}") + ); + addon.destroyEngine(freshHandle); + await withTimeout(addon.cleanup(), "fresh isolate cleanup"); + return { handlesDiffer: oldHandle !== freshHandle, freshResult }; +} + +async function staleFinalizer() { + const worker = new Worker(` + "use strict"; + const { parentPort, workerData } = require("node:worker_threads"); + const addon = require(workerData.addonPath); + function successful(raw) { + const result = JSON.parse(raw); + if (result.success !== true) throw new Error(\`worker run failed: \${raw}\`); + } + (async () => { + addon.initialize(workerData.libPath); + const handle = addon.createEngine(); + addon.__test_forceDetachFailureOnce("synchronous-run"); + successful(addon.runScriptEngine(handle, "output application/json --- 6 * 7", "{}")); + await addon.cleanup(); + parentPort.postMessage({ handle }); + parentPort.once("message", () => parentPort.close()); + })().catch((error) => { + parentPort.postMessage({ error: error.stack || String(error) }); + }); + `, { eval: true, workerData: { addonPath, libPath } }); + + const [message] = await once(worker, "message"); + assert(message.error === undefined, message.error ?? "worker failed"); + addon.initialize(libPath); + const freshHandle = addon.createEngine(); + const exit = once(worker, "exit"); + worker.postMessage("exit"); + const [exitCode] = await exit; + assert(exitCode === 0, `worker exited with ${exitCode}`); + + const freshResult = successfulResult( + addon.runScriptEngine(freshHandle, "output application/json --- 6 * 7", "{}") + ); + addon.destroyEngine(freshHandle); + await withTimeout(addon.cleanup(), "fresh isolate cleanup after stale finalizer"); + return { handlesDiffer: message.handle !== freshHandle, freshResult }; +} + +function validateSite(site) { + assert(typeof addon.__test_forceDetachFailureOnce === "function", "detach failure hook is undefined"); + addon.__test_forceDetachFailureOnce(site); + return { site }; +} + +function invalidArguments() { + let invalidArguments = 0; + for (const value of [ + undefined, + null, + 42, + "not-a-detach-site", + "synchronous-run\0unknown", + "x".repeat(64), + ]) { + try { + if (value === undefined) addon.__test_forceDetachFailureOnce(); + else addon.__test_forceDetachFailureOnce(value); + } catch (error) { + assert(error instanceof Error, "invalid detach site did not throw Error"); + invalidArguments++; + } + } + addon.__test_forceDetachFailureOnce("synchronous-run"); + let duplicateArmRejected = false; + try { + addon.__test_forceDetachFailureOnce("create-engine"); + } catch (error) { + duplicateArmRejected = error instanceof Error; + } + assert(duplicateArmRejected, "a second arm silently replaced the first"); + return { invalidArguments, duplicateArmRejected }; +} + +async function exerciseCreateRollback() { + assert( + typeof addon.__test_failNextEngineRecordAllocation === "function", + "engine record allocation hook is undefined" + ); + addon.initialize(libPath); + const forcedBefore = count("__test_forcedDetachFailureCount"); + const abandonedBefore = count("__test_abandonedIsolateCount"); + addon.__test_forceDetachFailureOnce("create-rollback"); + addon.__test_failNextEngineRecordAllocation(); + expectThrow(() => addon.createEngine(), "Failed to allocate engine record"); + assert(addon.__test_isolatePoisoned() === true, "create rollback did not poison isolate"); + await withTimeout(addon.cleanup(), "create rollback cleanup"); + + addon.initialize(libPath); + const fresh = addon.createEngine(); + const freshResult = successfulResult( + addon.runScriptEngine(fresh, "output application/json --- 6 * 7", "{}") + ); + addon.destroyEngine(fresh); + await withTimeout(addon.cleanup(), "create rollback fresh cleanup"); + return { + forcedFailures: Number(count("__test_forcedDetachFailureCount") - forcedBefore), + abandoned: Number(count("__test_abandonedIsolateCount") - abandonedBefore), + freshResult, + }; +} + +async function handleExhaustion() { + assert(typeof addon.__test_setNextEngineHandle === "function", "handle setter is undefined"); + addon.initialize(libPath); + addon.__test_setNextEngineHandle(MAX_SAFE_HANDLE - 1); + const firstHandle = addon.createEngine(); + const secondHandle = addon.createEngineWithResolver(() => null); + assert(firstHandle === MAX_SAFE_HANDLE - 1, `unexpected first handle: ${firstHandle}`); + assert(secondHandle === MAX_SAFE_HANDLE, `unexpected second handle: ${secondHandle}`); + assert(Number.isSafeInteger(firstHandle), "first handle is not a safe integer"); + assert(Number.isSafeInteger(secondHandle), "second handle is not a safe integer"); + const firstResult = successfulResult( + addon.runScriptEngine(firstHandle, "output application/json --- 6 * 7", "{}") + ); + const secondResult = successfulResult( + addon.runScriptEngine(secondHandle, "output application/json --- 6 * 7", "{}") + ); + addon.destroyEngine(firstHandle); + addon.destroyEngine(secondHandle); + + const forcedBefore = count("__test_forcedDetachFailureCount"); + const abandonedBefore = count("__test_abandonedIsolateCount"); + addon.__test_forceDetachFailureOnce("create-rollback"); + let exhaustionRejected = false; + try { + addon.createEngine(); + } catch (error) { + exhaustionRejected = error instanceof Error && error.message === "Engine handle space exhausted"; + } + assert(exhaustionRejected, "handle exhaustion was not rejected"); + const forcedRollbackDetach = Number(count("__test_forcedDetachFailureCount") - forcedBefore); + const isolatePoisoned = addon.__test_isolatePoisoned(); + await withTimeout(addon.cleanup(), "handle exhaustion cleanup"); + const abandoned = Number(count("__test_abandonedIsolateCount") - abandonedBefore); + return { + firstHandle, + secondHandle, + firstResult, + secondResult, + exhaustionRejected, + forcedRollbackDetach, + isolatePoisoned, + abandoned, + }; +} + +async function allocationFaultReset(poison) { + addon.initialize(libPath); + addon.__test_failNextEngineRecordAllocation(); + if (poison) { + const handle = addon.createEngineWithResolver(() => null); + addon.__test_forceDetachFailureOnce("synchronous-run"); + successfulResult(addon.runScriptEngine(handle, "output application/json --- 6 * 7", "{}")); + } + await withTimeout(addon.cleanup(), poison ? "poisoned fault reset" : "normal fault reset"); + + addon.initialize(libPath); + let freshCreateFailed = false; + let fresh; + try { + fresh = addon.createEngine(); + } catch (error) { + freshCreateFailed = error instanceof Error && error.message === "Failed to allocate engine record"; + if (!freshCreateFailed) throw error; + } + assert(!freshCreateFailed, "fresh generation consumed the stale allocation fault"); + const freshResult = successfulResult( + addon.runScriptEngine(fresh, "output application/json --- 6 * 7", "{}") + ); + addon.destroyEngine(fresh); + await withTimeout(addon.cleanup(), "fresh allocation fault reset cleanup"); + return { freshResult, freshCreateFailed }; +} + +async function generationExhaustion() { + assert(typeof addon.__test_setIsolateGeneration === "function", "generation setter is undefined"); + assert(typeof addon.__test_isolateGeneration === "function", "generation counter is undefined"); + addon.initialize(libPath); + const creationBefore = count("__test_isolateCreationCount"); + const teardownBefore = count("__test_teardownCallCount"); + addon.__test_setIsolateGeneration(UINT64_MAX); + await withTimeout(addon.cleanup(), "generation max cleanup"); + + let exhaustionRejected = false; + try { + addon.initialize(libPath); + } catch (error) { + exhaustionRejected = error instanceof Error && error.message === "DataWeave isolate generation space exhausted"; + } + assert(exhaustionRejected, "generation exhaustion was not rejected"); + return { + exhaustionRejected, + creationDelta: Number(count("__test_isolateCreationCount") - creationBefore), + teardownDelta: Number(count("__test_teardownCallCount") - teardownBefore), + generation: addon.__test_isolateGeneration().toString(), + }; +} + +async function identityHookValidation() { + let fractionalHandleRejected = false; + try { + addon.__test_setNextEngineHandle(1.5); + } catch (error) { + fractionalHandleRejected = error instanceof Error; + } + let nanHandleRejected = false; + try { + addon.__test_setNextEngineHandle(Number.NaN); + } catch (error) { + nanHandleRejected = error instanceof Error; + } + let armBeforeInitializeRejected = false; + try { + addon.__test_failNextEngineRecordAllocation(); + } catch (error) { + armBeforeInitializeRejected = error instanceof Error; + } + addon.initialize(libPath); + addon.__test_failNextEngineRecordAllocation(); + let duplicateArmRejected = false; + try { + addon.__test_failNextEngineRecordAllocation(); + } catch (error) { + duplicateArmRejected = error instanceof Error; + } + expectThrow(() => addon.createEngine(), "Failed to allocate engine record"); + + const handle = addon.createEngine(); + let handleMutationWithBridgeRejected = false; + try { + addon.__test_setNextEngineHandle(MAX_SAFE_HANDLE - 1); + } catch (error) { + handleMutationWithBridgeRejected = error instanceof Error; + } + let generationMutationWithBridgeRejected = false; + try { + addon.__test_setIsolateGeneration(UINT64_MAX); + } catch (error) { + generationMutationWithBridgeRejected = error instanceof Error; + } + addon.__test_forceDetachFailureOnce("synchronous-run"); + successfulResult(addon.runScriptEngine(handle, "output application/json --- 6 * 7", "{}")); + let armAfterPoisonRejected = false; + try { + addon.__test_failNextEngineRecordAllocation(); + } catch (error) { + armAfterPoisonRejected = error instanceof Error; + } + await withTimeout(addon.cleanup(), "identity hook validation cleanup"); + return { + fractionalHandleRejected, + nanHandleRejected, + armBeforeInitializeRejected, + duplicateArmRejected, + handleMutationWithBridgeRejected, + generationMutationWithBridgeRejected, + armAfterPoisonRejected, + }; +} + +async function handleExhaustionRollbackStrand() { + addon.initialize(libPath); + addon.__test_setNextEngineHandle(MAX_SAFE_HANDLE); + const finalHandle = addon.createEngine(); + addon.destroyEngine(finalHandle); + + const strandedBefore = addon.__test_strandedCount(); + addon.__test_forceStrandOnce(); + let exhaustionRejected = false; + try { + addon.createEngineWithResolver(() => null); + } catch (error) { + exhaustionRejected = error instanceof Error && error.message === "Engine handle space exhausted"; + } + assert(exhaustionRejected, "handle exhaustion was not rejected"); + const strandedDelta = addon.__test_strandedCount() - strandedBefore; + await withTimeout(addon.cleanup(), "handle exhaustion rollback strand cleanup"); + return { exhaustionRejected, strandedDelta }; +} + +async function detachPublicationRace(site) { + addon.initialize(libPath); + const handle = addon.createEngine(); + const forcedBefore = count("__test_forcedDetachFailureCount"); + const abandonedBefore = count("__test_abandonedIsolateCount"); + addon.__test_forceDetachFailureOnce(site); + addon.__test_holdDetachPublication(); + + const trigger = new Worker(` + "use strict"; + const { parentPort, workerData } = require("node:worker_threads"); + const addon = require(workerData.addonPath); + (async () => { + addon.initialize(workerData.libPath); + if (workerData.site === "synchronous-run") { + addon.runScriptEngine(workerData.handle, "output application/json --- 6 * 7", "{}"); + } else { + const operation = addon.runScriptStreamingEngine( + workerData.handle, + "output application/json --- []", + "{}", + (chunk, sequence) => operation.acknowledge(sequence, chunk.length) + ); + await operation.completion; + } + parentPort.postMessage("trigger-returned"); + })().catch((error) => parentPort.postMessage({ error: error.stack || String(error) })); + `, { eval: true, workerData: { addonPath, libPath, site, handle } }); + const triggerMessagePromise = once(trigger, "message"); + const triggerExitPromise = once(trigger, "exit"); + + while (!addon.__test_detachPublicationHeld()) { + await new Promise((resolve) => setImmediate(resolve)); + } + + const admission = new Worker(` + "use strict"; + const { parentPort, workerData } = require("node:worker_threads"); + const addon = require(workerData.addonPath); + (async () => { + parentPort.postMessage({ ready: true }); + await new Promise((resolve) => parentPort.once("message", resolve)); + addon.initialize(workerData.libPath); + const handle = addon.createEngine(); + parentPort.postMessage({ admitted: true, handle }); + })().catch((error) => { + parentPort.postMessage({ admitted: false, message: error && error.message }); + }); + `, { eval: true, workerData: { addonPath, libPath } }); + const admissionExitPromise = once(admission, "exit"); + + const [ready] = await withTimeout(once(admission, "message"), "detach publication admission ready"); + assert(ready.ready === true, "admission worker did not become ready"); + const admissionResult = new Promise((resolve, reject) => { + admission.once("message", resolve); + admission.once("error", reject); + }); + admission.postMessage("admit"); + while (addon.__test_detachPublicationWaiters() === 0n) { + await new Promise((resolve) => setImmediate(resolve)); + } + const admittedBeforeRelease = await Promise.race([ + admissionResult.then(() => true), + new Promise((resolve) => setImmediate(() => resolve(false))), + ]); + addon.__test_releaseDetachPublication(); + const admissionMessage = await withTimeout(admissionResult, "detach publication admission"); + const admissionRejectedPoison = + admissionMessage.admitted === false && admissionMessage.message === POISONED_MESSAGE; + const [triggerMessage] = await withTimeout(triggerMessagePromise, "detach publication trigger"); + assert(triggerMessage.error === undefined, triggerMessage.error ?? "trigger worker failed"); + await withTimeout(Promise.all([triggerExitPromise, admissionExitPromise]), "detach publication workers"); + + addon.destroyEngine(handle); + await withTimeout(addon.cleanup(), "detach publication cleanup"); + addon.initialize(libPath); + const fresh = addon.createEngine(); + const freshResult = successfulResult( + addon.runScriptEngine(fresh, "output application/json --- 6 * 7", "{}") + ); + addon.destroyEngine(fresh); + await withTimeout(addon.cleanup(), "detach publication fresh cleanup"); + return { + admittedBeforeRelease, + admissionRejectedPoison, + forcedFailures: Number(count("__test_forcedDetachFailureCount") - forcedBefore), + abandoned: Number(count("__test_abandonedIsolateCount") - abandonedBefore), + freshResult, + }; +} + +async function handleExhaustionOwnerCleanup() { + addon.initialize(libPath); + addon.__test_setNextEngineHandle(MAX_SAFE_HANDLE); + const finalHandle = addon.createEngine(); + addon.destroyEngine(finalHandle); + const deletesBefore = addon.__test_resolverRefDeleteCount(); + addon.__test_forceStrandOnce(); + let exhaustionRejected = false; + try { + addon.createEngineWithResolver(() => null); + } catch (error) { + exhaustionRejected = error instanceof Error && error.message === "Engine handle space exhausted"; + } + const strandedBeforeCleanup = addon.__test_strandedCount(); + await withTimeout(addon.cleanup(), "owner cleanup old isolate"); + addon.initialize(libPath); + await new Promise((resolve) => setImmediate(resolve)); + const strandedAfterHandoff = addon.__test_strandedCount(); + const resolverDeletes = addon.__test_resolverRefDeleteCount() - deletesBefore; + const liveStrandedResolverRefs = Number(addon.__test_liveStrandedResolverRefCount()); + await withTimeout(addon.cleanup(), "owner cleanup fresh isolate"); + return { + exhaustionRejected, + strandedBeforeCleanup, + strandedAfterHandoff, + resolverDeletes, + liveStrandedResolverRefs, + }; +} + +async function resolverlessFinalization() { + const iterations = 100; + const freesBefore = count("__test_bridgeFreeCount"); + const actionsBefore = count("__test_postReclamationActionCount"); + addon.initialize(libPath); + for (let i = 0; i < iterations; i++) { + const handle = addon.createEngine(); + addon.destroyEngine(handle); + } + + const worker = new Worker(` + "use strict"; + const { parentPort, workerData } = require("node:worker_threads"); + const addon = require(workerData.addonPath); + addon.initialize(workerData.libPath); + for (let i = 0; i < workerData.iterations; i++) addon.createEngine(); + parentPort.postMessage("created"); + `, { eval: true, workerData: { addonPath, libPath, iterations } }); + const [message] = await withTimeout(once(worker, "message"), "resolver-less worker creation"); + assert(message === "created", "resolver-less worker did not create engines"); + const [exitCode] = await withTimeout(once(worker, "exit"), "resolver-less worker finalization"); + assert(exitCode === 0, `resolver-less worker exited with ${exitCode}`); + + await withTimeout(addon.cleanup(), "resolver-less finalization cleanup"); + return { + expectedFrees: iterations * 2, + actualFrees: Number(count("__test_bridgeFreeCount") - freesBefore), + postReclamationActions: Number(count("__test_postReclamationActionCount") - actionsBefore), + }; +} + +async function oneShotSite() { + addon.initialize(libPath); + const first = addon.createEngine(); + const second = addon.createEngine(); + const forcedBefore = count("__test_forcedDetachFailureCount"); + addon.__test_forceDetachFailureOnce("bridge-finalize"); + addon.destroyEngine(first); + assert(addon.__test_isolatePoisoned() === true, "selected bridge detach did not poison"); + addon.destroyEngine(second); + const forcedFailures = Number(count("__test_forcedDetachFailureCount") - forcedBefore); + const poisonPersisted = addon.__test_isolatePoisoned(); + assert(forcedFailures === 1, "the same detach site consumed more than once"); + assert(poisonPersisted === true, "a later successful detach cleared poison early"); + await withTimeout(addon.cleanup(), "one-shot isolate cleanup"); + return { forcedFailures, poisonPersisted }; +} + +async function exerciseSite(site) { + addon.initialize(libPath); + const handle = addon.createEngine(); + const forcedBefore = count("__test_forcedDetachFailureCount"); + const abandonedBefore = count("__test_abandonedIsolateCount"); + addon.__test_forceDetachFailureOnce(site); + let triggeringResult; + + if (site === "bridge-finalize") { + addon.destroyEngine(handle); + } else if (site === "stream-worker") { + const operation = addon.runScriptStreamingEngine( + handle, + "output application/json --- []", + "{}", + (_chunk, sequence) => operation.acknowledge(sequence, _chunk.length) + ); + const result = JSON.parse(await withTimeout(operation.completion, "stream detach completion")); + assert(result.success === true, `stream detach operation failed: ${JSON.stringify(result)}`); + addon.destroyEngine(handle); + } else if (site === "create-engine") { + const created = addon.createEngine(); + addon.destroyEngine(created); + addon.destroyEngine(handle); + } else if (site === "resolver-create") { + const created = addon.createEngineWithResolver(() => null); + addon.destroyEngine(created); + addon.destroyEngine(handle); + } else if (site === "unknown-destroy") { + addon.destroyEngine(handle + 1_000_000); + addon.destroyEngine(handle); + } else if (site === "synchronous-run") { + triggeringResult = successfulResult( + addon.runScriptEngine(handle, "output application/json --- 6 * 7", "{}") + ); + addon.destroyEngine(handle); + } else { + throw new Error(`exercise-site does not support ${site}`); + } + + assert(addon.__test_isolatePoisoned() === true, `${site} did not poison isolate`); + await withTimeout(addon.cleanup(), `${site} cleanup`); + addon.initialize(libPath); + const fresh = addon.createEngine(); + const freshResult = successfulResult( + addon.runScriptEngine(fresh, "output application/json --- 6 * 7", "{}") + ); + addon.destroyEngine(fresh); + await withTimeout(addon.cleanup(), `${site} fresh cleanup`); + return { + site, + forcedFailures: Number(count("__test_forcedDetachFailureCount") - forcedBefore), + abandoned: Number(count("__test_abandonedIsolateCount") - abandonedBefore), + triggeringResult, + freshResult, + }; +} + +function hooksAbsent() { + return { hooksAbsent: TEST_HOOKS.every((hook) => addon[hook] === undefined) }; +} + +async function main() { + let result; + if (mode === "recovery") result = await recovery(); + else if (mode === "stale-handle") result = await staleHandle(); + else if (mode === "stale-finalizer") result = await staleFinalizer(); + else if (mode === "validate-site") result = validateSite(process.argv[5]); + else if (mode === "invalid-arguments") result = invalidArguments(); + else if (mode === "one-shot-site") result = await oneShotSite(); + else if (mode === "exercise-site") result = await exerciseSite(process.argv[5]); + else if (mode === "exercise-create-rollback") result = await exerciseCreateRollback(); + else if (mode === "handle-exhaustion") result = await handleExhaustion(); + else if (mode === "allocation-fault-normal-reset") result = await allocationFaultReset(false); + else if (mode === "allocation-fault-poison-reset") result = await allocationFaultReset(true); + else if (mode === "generation-exhaustion") result = await generationExhaustion(); + else if (mode === "identity-hook-validation") result = await identityHookValidation(); + else if (mode === "handle-exhaustion-rollback-strand") result = await handleExhaustionRollbackStrand(); + else if (mode === "detach-publication-race") result = await detachPublicationRace(process.argv[5]); + else if (mode === "handle-exhaustion-owner-cleanup") result = await handleExhaustionOwnerCleanup(); + else if (mode === "resolverless-finalization") result = await resolverlessFinalization(); + else if (mode === "hooks-absent") result = hooksAbsent(); + else throw new Error(`unknown fixture mode: ${mode}`); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error.stack ?? error}\n`); + process.exitCode = 99; +}); diff --git a/native-lib/node/tests/integration/fixtures/detach-poison-transform.cjs b/native-lib/node/tests/integration/fixtures/detach-poison-transform.cjs new file mode 100644 index 00000000..91d18580 --- /dev/null +++ b/native-lib/node/tests/integration/fixtures/detach-poison-transform.cjs @@ -0,0 +1,124 @@ +"use strict"; + +const path = require("node:path"); + +const addonPath = path.resolve(process.cwd(), process.argv[2]); +const libPath = process.argv[3]; +const addon = require(addonPath); +const INPUT = Buffer.from("detach poison transform"); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function count(name) { + const value = addon[name](); + assert(typeof value === "bigint", `${name} must return a lossless bigint`); + return value; +} + +function successfulResult(raw) { + const result = JSON.parse(raw); + assert(result.success === true, `expected successful result, got ${raw}`); + return result; +} + +function immediate() { + return new Promise((resolve) => setImmediate(resolve)); +} + +async function waitFor(predicate, label) { + const deadline = Date.now() + 10_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`${label} timed out`); + await immediate(); + } +} + +function withTimeout(promise, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), 10_000); + }), + ]).finally(() => clearTimeout(timer)); +} + +async function main() { + addon.initialize(libPath); + const handle = addon.createEngine(); + const creationBefore = count("__test_isolateCreationCount"); + const teardownBefore = count("__test_teardownCallCount"); + const abandonedBefore = count("__test_abandonedIsolateCount"); + const forcedBefore = count("__test_forcedDetachFailureCount"); + + addon.__test_forceDetachFailureOnce("transform-worker"); + addon.__test_holdNextAsyncOp(); + const chunks = []; + let read = false; + let operation; + operation = addon.runScriptTransformEngine( + handle, + "output application/octet-stream deferred=true --- payload", + "{}", + "payload", + "application/octet-stream", + null, + () => { + if (read) return null; + read = true; + return INPUT; + }, + (chunk, sequence) => { + chunks.push(chunk); + operation.acknowledge(sequence, chunk.length); + } + ); + + await waitFor(() => addon.__test_asyncOpHeld(), "transform admission barrier"); + addon.destroyEngine(handle); + const cleanup = addon.cleanup(); + addon.__test_releaseAsyncOp(); + + const [raw] = await withTimeout( + Promise.all([operation.completion, cleanup]), + "transform completion and poisoned cleanup" + ); + successfulResult(raw); + const transformResult = Buffer.concat(chunks).toString("utf8"); + assert(transformResult === INPUT.toString("utf8"), `unexpected transform output: ${transformResult}`); + assert(count("__test_forcedDetachFailureCount") === forcedBefore + 1n, "transform detach was not forced once"); + assert(count("__test_teardownCallCount") === teardownBefore, "poisoned transform cleanup invoked teardown"); + assert(count("__test_abandonedIsolateCount") === abandonedBefore + 1n, "poisoned transform cleanup did not abandon once"); + + addon.initialize(libPath); + assert(count("__test_isolateCreationCount") === creationBefore + 1n, "transform recovery did not create a fresh isolate"); + const freshHandle = addon.createEngine(); + const freshResultRaw = addon.runScriptEngine( + freshHandle, + "output application/json --- 6 * 7", + "{}" + ); + const freshEnvelope = successfulResult(freshResultRaw); + const freshResult = Buffer.from(freshEnvelope.result, "base64").toString("utf8"); + assert(freshResult === "42", `unexpected fresh result: ${freshResult}`); + assert(addon.__test_isolatePoisoned() === false, "fresh isolate inherited poison"); + addon.destroyEngine(freshHandle); + await withTimeout(addon.cleanup(), "fresh transform isolate cleanup"); + assert(count("__test_teardownCallCount") === teardownBefore + 1n, "fresh transform isolate was not torn down"); + assert(count("__test_abandonedIsolateCount") === abandonedBefore + 1n, "fresh cleanup changed abandon count"); + + process.stdout.write(`${JSON.stringify({ + transformResult, + forcedFailures: Number(count("__test_forcedDetachFailureCount") - forcedBefore), + abandoned: Number(count("__test_abandonedIsolateCount") - abandonedBefore), + freshResult, + })}\n`); +} + +main().catch((error) => { + addon.__test_releaseAsyncOp?.(); + process.stderr.write(`${error.stack ?? error}\n`); + process.exitCode = 99; +}); diff --git a/native-lib/node/tests/integration/fixtures/output-controller-finalizer.cjs b/native-lib/node/tests/integration/fixtures/output-controller-finalizer.cjs new file mode 100644 index 00000000..62478eca --- /dev/null +++ b/native-lib/node/tests/integration/fixtures/output-controller-finalizer.cjs @@ -0,0 +1,75 @@ +"use strict"; + +const path = require("node:path"); + +const addonPath = path.resolve(process.cwd(), process.argv[2]); +const libPath = process.argv[3]; +const addon = require(addonPath); + +async function timeout(promise, label) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), 10000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +const immediate = () => new Promise((resolve) => setImmediate(resolve)); + +async function main() { + if (typeof global.gc !== "function") throw new Error("global.gc is unavailable"); + addon.initialize(libPath); + const handle = addon.createEngine(); + + const completion = (() => { + const payload = Buffer.alloc(2 * 1024 * 1024 + 32771, 65).toString("base64"); + const operation = addon.runScriptStreamingEngine( + handle, + "output application/octet-stream deferred=true\n---\npayload", + JSON.stringify({ + payload: { content: payload, mimeType: "application/octet-stream" }, + }), + () => {} + ); + return operation.completion; + })(); + + const deadline = Date.now() + 10000; + while (addon.__test_outputStats().liveFlows !== 0) { + global.gc(); + await immediate(); + if (Date.now() >= deadline) throw new Error("controller finalizer timed out"); + } + + await timeout(completion, "finalized controller completion"); + const completionSettled = true; + addon.destroyEngine(handle); + await timeout(addon.cleanup(), "finalized controller cleanup"); + + addon.initialize(libPath); + const reuseHandle = addon.createEngine(); + const reused = JSON.parse(addon.runScriptEngine( + reuseHandle, + "output application/json --- 6 * 7", + "{}" + )); + addon.destroyEngine(reuseHandle); + await timeout(addon.cleanup(), "reuse cleanup"); + + process.stdout.write(JSON.stringify({ + completionSettled, + liveFlows: addon.__test_outputStats().liveFlows, + reusedResult: Buffer.from(reused.result, "base64").toString("utf-8"), + })); +} + +main().catch((error) => { + process.stderr.write(`${error.stack ?? error}\n`); + process.exitCode = 1; +}); diff --git a/native-lib/node/tests/integration/fixtures/output-settlement-after-call.cjs b/native-lib/node/tests/integration/fixtures/output-settlement-after-call.cjs new file mode 100644 index 00000000..5cea6d1a --- /dev/null +++ b/native-lib/node/tests/integration/fixtures/output-settlement-after-call.cjs @@ -0,0 +1,40 @@ +"use strict"; + +const path = require("node:path"); + +const addonPath = path.resolve(process.cwd(), process.argv[2]); +const libPath = process.argv[3]; +const fault = process.argv[4]; +const mode = process.argv[5] ?? "streaming"; +const clearFault = process.argv[6]; +const addon = require(addonPath); + +function main() { + addon.initialize(libPath); + const handle = addon.createEngine(); + addon.__test_failNextOutputSettlement(fault); + if (clearFault) addon.__test_failNextOutputExceptionClear(clearFault); + if (mode === "streaming") { + addon.runScriptStreamingEngine( + handle, + "output application/json\n---\n[]", + "{}", + () => {} + ); + } else if (mode === "transform") { + addon.runScriptTransformEngine( + handle, + "output application/json\n---\npayload", + "{}", + "payload", + "application/json", + "UTF-8", + () => null, + () => {} + ); + } else { + throw new Error(`Unknown output mode: ${mode}`); + } +} + +main(); diff --git a/native-lib/node/tests/integration/fixtures/resolver-reentrancy.cjs b/native-lib/node/tests/integration/fixtures/resolver-reentrancy.cjs new file mode 100644 index 00000000..649b5970 --- /dev/null +++ b/native-lib/node/tests/integration/fixtures/resolver-reentrancy.cjs @@ -0,0 +1,163 @@ +const path = require("node:path"); + +const ROOT = path.join(__dirname, "..", "..", ".."); +const MODULE_SOURCE = "%dw 2.0\nfun answer() = 42"; +const OUTER_SCRIPT = [ + "%dw 2.0", + "import org::test::reentrant", + "output application/json", + "---", + "reentrant::answer()", +].join("\n"); + +async function runFacade() { + const { DataWeave } = require(path.join(ROOT, "dist", "index.js")); + const inner = new DataWeave(); + let nestedErrorName = null; + const outer = new DataWeave({ + resolveModule: () => { + try { + inner.run("21 * 2"); + } catch (error) { + nestedErrorName = error && error.name; + } + return MODULE_SOURCE; + }, + }); + + try { + inner.initialize(); + outer.initialize(); + const result = outer.run(OUTER_SCRIPT); + console.log(JSON.stringify({ nestedErrorName, outerResult: result.getString() })); + } finally { + try { + await outer.cleanup(); + } finally { + await inner.cleanup(); + } + } +} + +async function runRaw() { + const addon = require(path.join(ROOT, "build", "Release", "dwlib_addon.node")); + const { findLibrary } = require(path.join(ROOT, "dist", "utils.js")); + let innerHandle = null; + let outerHandle = null; + let nestedErrorCode = null; + + addon.initialize(findLibrary()); + try { + innerHandle = addon.createEngine(); + outerHandle = addon.createEngineWithResolver(() => { + try { + addon.runScriptEngine(innerHandle, "21 * 2", "{}"); + } catch (error) { + nestedErrorCode = error && error.code; + } + return MODULE_SOURCE; + }); + const raw = addon.runScriptEngine(outerHandle, OUTER_SCRIPT, "{}"); + const result = JSON.parse(raw); + console.log(JSON.stringify({ + nestedErrorCode, + outerResult: Buffer.from(result.result, "base64").toString("utf-8"), + })); + } finally { + try { + if (outerHandle !== null) addon.destroyEngine(outerHandle); + } finally { + try { + if (innerHandle !== null) addon.destroyEngine(innerHandle); + } finally { + await addon.cleanup(); + } + } + } +} + +async function runRawOutput(mode) { + const addon = require(path.join(ROOT, "build", "Release", "dwlib_addon.node")); + const { findLibrary } = require(path.join(ROOT, "dist", "utils.js")); + let innerHandle = null; + let outerHandle = null; + let nestedErrorCode = null; + const chunks = []; + + addon.initialize(findLibrary()); + try { + innerHandle = addon.createEngine(); + outerHandle = addon.createEngine(); + const write = (chunk) => { + if (nestedErrorCode === null) { + try { + addon.runScriptEngine(innerHandle, "21 * 2", "{}"); + } catch (error) { + nestedErrorCode = error && error.code; + } + } + chunks.push(chunk); + }; + + let raw; + if (mode === "raw-streaming") { + raw = await addon.runScriptStreamingEngine( + outerHandle, + "%dw 2.0\noutput application/json\n---\n[1,2,3]", + "{}", + write + ); + } else { + let read = false; + raw = await addon.runScriptTransformEngine( + outerHandle, + "output application/json\n---\npayload map ($ * 2)", + "{}", + "payload", + "application/json", + null, + () => { + if (read) return null; + read = true; + return Buffer.from("[1,2,3]"); + }, + write + ); + } + console.log(JSON.stringify({ + nestedErrorCode, + outerSuccess: JSON.parse(raw).success, + outerResult: JSON.parse(Buffer.concat(chunks).toString("utf-8")), + })); + } finally { + try { + if (outerHandle !== null) addon.destroyEngine(outerHandle); + } finally { + try { + if (innerHandle !== null) addon.destroyEngine(innerHandle); + } finally { + await addon.cleanup(); + } + } + } +} + +const mode = process.argv[2]; +const run = mode === "facade" + ? runFacade + : mode === "raw" + ? runRaw + : mode === "raw-streaming" + ? () => runRawOutput(mode) + : mode === "raw-transform" + ? () => runRawOutput(mode) + : null; +if (run === null) { + console.error(`unknown mode: ${mode}`); + process.exit(2); +} + +run().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exitCode = 99; +}); diff --git a/native-lib/node/tests/integration/instance-lifecycle.test.ts b/native-lib/node/tests/integration/instance-lifecycle.test.ts index e644662e..e0032fd6 100644 --- a/native-lib/node/tests/integration/instance-lifecycle.test.ts +++ b/native-lib/node/tests/integration/instance-lifecycle.test.ts @@ -4,6 +4,26 @@ import { DataWeaveError } from "../../src/errors"; import * as ffi from "../../src/ffi"; import { findLibrary, buildInputsJson } from "../../src/utils"; +async function cleanupTask7Instances( + target: DataWeave, + anchor: DataWeave, + bodySucceeded: boolean +): Promise { + let cleanupError: unknown; + try { + await target.cleanup(); + } catch (error) { + cleanupError = error; + } + try { + await anchor.cleanup(); + } catch (error) { + if (cleanupError === undefined) cleanupError = error; + } + // Cleanup must not replace a more actionable failure from the test body. + if (bodySucceeded && cleanupError !== undefined) throw cleanupError; +} + // 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. @@ -60,22 +80,25 @@ describe("instance lifecycle during cleanup (round 6)", () => { 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 () => { + // Finding #1, streaming/transform variants: ordinary public methods capture + // their engine identity at call time, so a call during cleanup rejects before + // it can return a generator or admit native work. + it("runStreaming()/runTransform() during pending cleanup reject at call time", 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); + expect(() => + dw!.runStreaming("%dw 2.0\noutput application/json\n---\n[1,2,3]") + ).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); + expect(() => + dw!.runTransform( + "output application/json\n---\npayload", + [Buffer.from("[1,2,3]")], + { mimeType: "application/json" } + ) + ).toThrow(DataWeaveError); await closing; }); @@ -160,6 +183,90 @@ describe("runTransform re-checks readiness after async input pre-buffering (roun }); }); +describe("lazy streams are bound to their engine generation (Task 7)", () => { + const staleGenerationMessage = "DataWeave operation belongs to a stale engine generation."; + const expectStaleGenerationError = async (operation: Promise): Promise => { + let error: unknown; + try { + await operation; + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(DataWeaveError); + expect((error as DataWeaveError).message).toBe(staleGenerationMessage); + }; + + it("rejects stale runStreaming work and allows a replacement-generation stream", async () => { + const anchor = new DataWeave(); + const target = new DataWeave(); + let bodySucceeded = false; + + try { + anchor.initialize(); + target.initialize(); + const stale = target.runStreaming("output application/json --- [1, 2, 3]"); + + await target.cleanup(); + target.initialize(); + + const stalePull = stale.next(); + await expectStaleGenerationError(stalePull); + + const current = target.runStreaming("output application/json --- [4, 5, 6]"); + const chunks: Buffer[] = []; + let result = await current.next(); + while (!result.done) { + chunks.push(result.value); + result = await current.next(); + } + expect(result.value.success).toBe(true); + expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toEqual([4, 5, 6]); + bodySucceeded = true; + } finally { + await cleanupTask7Instances(target, anchor, bodySucceeded); + } + }); + + it("rejects stale runTransform work and allows a replacement-generation transform", async () => { + const anchor = new DataWeave(); + const target = new DataWeave(); + let bodySucceeded = false; + + try { + anchor.initialize(); + target.initialize(); + const stale = target.runTransform( + "output application/json --- payload map ($ * 2)", + [Buffer.from("[1, 2, 3]")], + { mimeType: "application/json" } + ); + + await target.cleanup(); + target.initialize(); + + const stalePull = stale.next(); + await expectStaleGenerationError(stalePull); + + const current = target.runTransform( + "output application/json --- payload map ($ * 2)", + [Buffer.from("[4, 5, 6]")], + { mimeType: "application/json" } + ); + const chunks: Buffer[] = []; + let result = await current.next(); + while (!result.done) { + chunks.push(result.value); + result = await current.next(); + } + expect(result.value.success).toBe(true); + expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toEqual([8, 10, 12]); + bodySucceeded = true; + } finally { + await cleanupTask7Instances(target, anchor, bodySucceeded); + } + }); +}); + // 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 diff --git a/native-lib/node/tests/integration/resolver-reentrancy.test.ts b/native-lib/node/tests/integration/resolver-reentrancy.test.ts new file mode 100644 index 00000000..54a0dcf4 --- /dev/null +++ b/native-lib/node/tests/integration/resolver-reentrancy.test.ts @@ -0,0 +1,59 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const FIXTURE = join(__dirname, "fixtures", "resolver-reentrancy.cjs"); +const DIST_ENTRY = join(__dirname, "..", "..", "dist", "index.js"); +const ADDON_PATH = join(__dirname, "..", "..", "build", "Release", "dwlib_addon.node"); +const FATAL_GRAAL_ERROR = /Fatal error|Must either be at a safepoint or in native mode/i; + +function runFixture(mode: "facade" | "raw" | "raw-streaming" | "raw-transform") { + const child = spawnSync(process.execPath, [FIXTURE, mode], { + encoding: "utf-8", + timeout: 30_000, + }); + + expect(child.error, child.error?.message).toBeUndefined(); + expect(child.signal, child.stderr).toBeNull(); + expect(child.status, child.stderr).toBe(0); + expect(child.stderr).not.toMatch(FATAL_GRAAL_ERROR); + + return JSON.parse(child.stdout.trim()) as Record; +} + +describe("resolver callback reentrancy guard", () => { + it("maps nested public DataWeave calls to DataWeaveError and preserves the outer run", () => { + expect(existsSync(DIST_ENTRY), `built entry missing at ${DIST_ENTRY} - run \`npm run build:ts\``).toBe(true); + + expect(runFixture("facade")).toEqual({ + nestedErrorName: "DataWeaveError", + outerResult: "42", + }); + }); + + it("exposes a stable raw-addon error code for nested native admission", () => { + expect(existsSync(ADDON_PATH), `native addon missing at ${ADDON_PATH} - run \`npm run build:addon\``).toBe(true); + + expect(runFixture("raw")).toEqual({ + nestedErrorCode: "ERR_DATAWEAVE_CALLBACK_REENTRANCY", + outerResult: "42", + }); + }); + + it("guards the streaming output callback and preserves the outer operation", () => { + expect(runFixture("raw-streaming")).toEqual({ + nestedErrorCode: "ERR_DATAWEAVE_CALLBACK_REENTRANCY", + outerSuccess: true, + outerResult: [1, 2, 3], + }); + }); + + it("guards the transform output callback and preserves the outer operation", () => { + expect(runFixture("raw-transform")).toEqual({ + nestedErrorCode: "ERR_DATAWEAVE_CALLBACK_REENTRANCY", + outerSuccess: true, + outerResult: [2, 4, 6], + }); + }); +}); diff --git a/native-lib/node/tests/integration/run-admission.test.ts b/native-lib/node/tests/integration/run-admission.test.ts index 88dea6a2..558515ff 100644 --- a/native-lib/node/tests/integration/run-admission.test.ts +++ b/native-lib/node/tests/integration/run-admission.test.ts @@ -1,44 +1,36 @@ import { describe, it, expect } from "vitest"; import * as ffi from "../../src/ffi"; +import { DataWeaveError } from "../../src/errors"; 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 () => { +interface TestAddon { + __test_holdNextAsyncOp(): void; + __test_asyncOpHeld(): boolean; + __test_releaseAsyncOp(): void; +} + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const testAddon = require("../../build/Release/dwlib_addon.node") as TestAddon; + +async function waitForAsyncOpGate(): Promise { + const deadline = Date.now() + 10000; + while (!testAddon.__test_asyncOpHeld()) { + if (Date.now() >= deadline) throw new Error("async operation did not reach test gate"); + await new Promise((resolve) => setImmediate(resolve)); + } +} + +// Task 6 adds this callback-specific contract alongside the pending-teardown +// lifecycle regression below: lifecycle and execution entry from a native +// callback are rejected before cleanup can queue teardown or run can attach to +// the isolate. Drive the real addon through ffi so this also verifies TypeScript +// error normalization, while the successful outer transform proves callback +// depth is restored afterward. +describe("transform read callback reentrancy guard", () => { + it("rejects cleanup and run before native admission and preserves the outer transform", 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 cleanupErr: unknown; let runErr: unknown; let ran = false; @@ -46,14 +38,11 @@ describe("run() admission rejected while teardown pending (round 7 #1)", () => { 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.cleanup(); + } catch (e) { + cleanupErr = e; + } try { ffi.runScriptEngine( handle, @@ -71,23 +60,83 @@ describe("run() admission rejected while teardown pending (round 7 #1)", () => { 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); + try { + 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); + expect(cleanupErr).toBeInstanceOf(DataWeaveError); + expect(runErr).toBeInstanceOf(DataWeaveError); + expect(ran).toBe(false); + } finally { + ffi.destroyEngine(handle); + await ffi.cleanup(); + } + }, 20000); +}); + +describe("run() admission rejected while teardown pending (round 7 #1)", () => { + it("rejects a synchronous run instead of attaching to a tearing-down isolate", async () => { + ffi.initialize(findLibrary()); + const handle = ffi.createEngine(); + let cleanupPromise: Promise | undefined; + let gateArmed = false; + let gateReleased = false; + let handleDestroyed = false; + const chunks: Buffer[] = []; + + try { + testAddon.__test_holdNextAsyncOp(); + gateArmed = true; + const outerPromise = ffi.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[1,2,3]", + buildInputsJson({}), + (chunk) => chunks.push(chunk) + ); + await waitForAsyncOpGate(); + cleanupPromise = ffi.cleanup(); - await cleanupPromise; + let runErr: unknown; + let ran = false; + try { + ffi.runScriptEngine( + handle, + "%dw 2.0\noutput application/json\n---\n1 + 1", + buildInputsJson({}) + ); + ran = true; + } catch (error) { + runErr = error; + } - // run() started while teardown was pending must have been rejected. - expect(runErr).toBeTruthy(); - expect(ran).toBe(false); + expect(runErr).toBeTruthy(); + expect(ran).toBe(false); + + ffi.destroyEngine(handle); + handleDestroyed = true; + testAddon.__test_releaseAsyncOp(); + gateReleased = true; + const outerResult = JSON.parse(await outerPromise); + expect(outerResult.success).toBe(true); + expect(JSON.parse(Buffer.concat(chunks).toString("utf-8"))).toEqual([1, 2, 3]); + await cleanupPromise; + } finally { + if (gateArmed && !gateReleased) testAddon.__test_releaseAsyncOp(); + try { + if (!handleDestroyed) ffi.destroyEngine(handle); + } finally { + if (cleanupPromise) await cleanupPromise; + await ffi.cleanup(); + } + } }, 20000); }); diff --git a/native-lib/node/tests/integration/stream-backpressure.test.ts b/native-lib/node/tests/integration/stream-backpressure.test.ts new file mode 100644 index 00000000..240ea33f --- /dev/null +++ b/native-lib/node/tests/integration/stream-backpressure.test.ts @@ -0,0 +1,965 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { DataWeave } from "../../src/dataweave"; +import { buildInputsJson, findLibrary } from "../../src/utils"; + +interface NativeStreamingOperation { + readonly completion: Promise; + acknowledge(sequence: bigint, bytes: number): void; + cancel(): void; + close(): void; + then( + onfulfilled?: ((value: string) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise; + catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise; + finally(onfinally?: (() => void) | null): Promise; +} + +interface OutputFlowStats { + operationId: number; + outstandingBytes: number; + outstandingChunks: number; + peakBufferedBytes: number; + peakBufferedChunks: number; + largestChunkBytes: number; + highBytes: number; + lowBytes: number; + highChunks: number; + lowChunks: number; + paused: boolean; + cancelled: boolean; + done: boolean; + liveFlows: number; +} + +type OutputSettlementFault = + | "initial-create-generic" + | "initial-pending-exception" + | "initial-call-generic-after-call" + | "initial-call-pending-after-call" + | "fallback-call-generic" + | "fallback-pending-exception" + | "fallback-call-generic-after-call"; + +type OutputExceptionClearFault = + | "is-exception-pending" + | "get-and-clear-last-exception"; + +interface TestAddon { + initialize(libPath: string): void; + createEngine(): number; + destroyEngine(handle: number): void; + runScriptEngine(handle: number, script: string, inputsJson: string): string; + runScriptStreamingEngine( + handle: number, + script: string, + inputsJson: string, + chunkCb: (chunk: Buffer, sequence: bigint) => void + ): NativeStreamingOperation; + runScriptTransformEngine( + handle: number, + script: string, + inputsJson: string, + inputName: string, + inputMimeType: string, + inputCharset: string | null, + readCb: (bufSize: number) => Buffer | null, + writeCb: (chunk: Buffer, sequence: bigint) => void + ): NativeStreamingOperation; + cleanup(): Promise; + __test_outputStats(operationId?: number): OutputFlowStats; + __test_outputOperationId(operation: NativeStreamingOperation): number; + __test_createForeignWrappedObject(): object; + __test_failNextOutputSettlement(stage: OutputSettlementFault): void; + __test_failNextOutputExceptionClear(stage: OutputExceptionClearFault): void; + __test_holdNextOutputDelivery(): void; + __test_heldOutputDelivery(): { held: boolean; sequence: bigint; bytes: number }; + __test_releaseOutputDelivery(): void; + __test_holdNextAsyncOp(): void; + __test_asyncOpHeld(): boolean; + __test_releaseAsyncOp(): void; +} + +interface PendingChunk { + readonly chunk: Buffer; + readonly sequence: bigint; +} + +const ADDON_PATH = "../../build/Release/dwlib_addon.node"; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const addon = require(ADDON_PATH) as TestAddon; +const LIB_PATH = findLibrary(); +const LARGE_SIZE = 2 * 1024 * 1024 + 32771; +const PASSTHROUGH_SCRIPT = + "output application/octet-stream deferred=true\n---\npayload"; + +function patternedBytes(size = LARGE_SIZE): Buffer { + const bytes = Buffer.allocUnsafe(size); + for (let i = 0; i < size; i++) bytes[i] = 32 + (i % 95); + return bytes; +} + +function octetStreamInputs(payload: Buffer): string { + return buildInputsJson({ + payload: { + content: payload, + mimeType: "application/octet-stream", + }, + }); +} + +function immediate(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +async function withTimeout(promise: Promise, label: string, timeoutMs = 10000): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function waitForStats( + predicate: (stats: OutputFlowStats) => boolean, + label: string, + operationId?: number, + timeoutMs = 10000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (true) { + const stats = operationId === undefined + ? addon.__test_outputStats() + : addon.__test_outputStats(operationId); + if (predicate(stats)) return stats; + if (Date.now() >= deadline) { + throw new Error(`${label} timed out; last stats: ${JSON.stringify(stats)}`); + } + await immediate(); + } +} + +function expectBounded(stats: OutputFlowStats): void { + expect(stats.peakBufferedChunks).toBeLessThanOrEqual(stats.highChunks + 1); + expect(stats.peakBufferedBytes).toBeLessThanOrEqual( + stats.highBytes + stats.largestChunkBytes + ); +} + +async function waitForPendingChunk(pending: PendingChunk[], label: string): Promise { + const deadline = Date.now() + 10000; + while (pending.length === 0) { + if (Date.now() >= deadline) throw new Error(`${label} timed out`); + await immediate(); + } + return pending.shift()!; +} + +async function drainAfterPause( + operation: NativeStreamingOperation, + operationId: number, + pending: PendingChunk[], + received: Buffer[], + settled: () => boolean +): Promise { + const paused = await waitForStats( + (stats) => stats.paused && stats.outstandingChunks === pending.length, + "producer pause with every admitted callback delivered", + operationId + ); + expectBounded(paused); + + const deliveredAtPause = received.length; + let projectedBytes = paused.outstandingBytes; + let projectedChunks = paused.outstandingChunks; + while (projectedBytes > paused.lowBytes || projectedChunks > paused.lowChunks) { + const { chunk, sequence } = await waitForPendingChunk(pending, "low-water acknowledgement"); + operation.acknowledge(sequence, chunk.length); + projectedBytes -= chunk.length; + projectedChunks--; + } + + expect(projectedBytes).toBeLessThanOrEqual(paused.lowBytes); + expect(projectedChunks).toBeLessThanOrEqual(paused.lowChunks); + await waitForStats( + () => received.length > deliveredAtPause, + "producer wake below both low watermarks", + operationId + ); + + const deadline = Date.now() + 15000; + while ( + !settled() || + pending.length > 0 || + addon.__test_outputStats(operationId).outstandingChunks > 0 + ) { + if (pending.length > 0) { + const { chunk, sequence } = pending.shift()!; + operation.acknowledge(sequence, chunk.length); + } + if (Date.now() >= deadline) { + throw new Error( + `stepwise drain timed out; stats: ${JSON.stringify(addon.__test_outputStats(operationId))}` + ); + } + await immediate(); + } + + return withTimeout(operation.completion, "native completion after drain"); +} + +async function runWithEngine( + body: (handle: number) => Promise +): Promise { + const handle = addon.createEngine(); + try { + await body(handle); + } finally { + addon.destroyEngine(handle); + } +} + +beforeAll(() => { + addon.initialize(LIB_PATH); +}); + +afterAll(async () => { + await addon.cleanup(); +}); + +describe.sequential("native Node output flow control", () => { + it("returns a validated, idempotent operation controller", async () => { + await runWithEngine(async (handle) => { + const chunks: PendingChunk[] = []; + let operation: NativeStreamingOperation | undefined; + try { + operation = addon.runScriptStreamingEngine( + handle, + "output application/json deferred=true --- [1, 2, 3]", + "{}", + (chunk, sequence) => chunks.push({ chunk, sequence }) + ); + + expect(operation).toEqual( + expect.objectContaining({ + completion: expect.any(Promise), + acknowledge: expect.any(Function), + cancel: expect.any(Function), + close: expect.any(Function), + }) + ); + + for (const invalid of [-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, "1"]) { + expect(() => operation!.acknowledge(1n, invalid as number)).toThrow(); + } + + for (const invalid of [ + -1, + 0, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + "1", + -1n, + 0n, + 1n << 64n, + ]) { + expect(() => operation!.acknowledge(invalid as bigint, 1)).toThrow(); + } + + const raw = await withTimeout(operation.completion, "controller completion"); + expect(JSON.parse(raw).success).toBe(true); + for (const { chunk, sequence } of chunks) { + operation.acknowledge(sequence, chunk.length); + } + expect(() => operation.cancel()).not.toThrow(); + expect(() => operation.cancel()).not.toThrow(); + expect(() => operation.close()).not.toThrow(); + expect(() => operation.close()).not.toThrow(); + expect(() => operation.acknowledge(1n, 1)).not.toThrow(); + } finally { + if (operation !== undefined) { + operation.cancel?.(); + operation.close?.(); + const completion = operation.completion ?? (operation as unknown as Promise); + await withTimeout(Promise.resolve(completion), "controller test cleanup"); + } + } + }); + }); + + it("rejects borrowed controller methods and test introspection on a foreign wrapper", async () => { + await runWithEngine(async (handle) => { + const operation = addon.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[]", + "{}", + () => {} + ); + const foreign = addon.__test_createForeignWrappedObject(); + + try { + for (const receiver of [foreign, {}]) { + for (const method of [operation.acknowledge, operation.cancel, operation.close]) { + expect(() => method.call(receiver, 1n, 1)).toThrow(TypeError); + } + expect(() => operation.then.call(receiver, () => {})).toThrow(TypeError); + expect(() => operation.catch.call(receiver, () => {})).toThrow(TypeError); + expect(() => operation.finally.call(receiver, () => {})).toThrow(TypeError); + expect(() => addon.__test_outputOperationId(receiver as NativeStreamingOperation)).toThrow( + TypeError + ); + } + + await withTimeout(operation.completion, "receiver-tag operation completion"); + } finally { + operation.cancel(); + operation.close(); + } + }); + }); + + it("bounds a paused streaming producer and resumes only after low-water credit", async () => { + await runWithEngine(async (handle) => { + expect(typeof addon.__test_outputStats).toBe("function"); + const expected = patternedBytes(); + const received: Buffer[] = []; + const pending: PendingChunk[] = []; + let operation!: NativeStreamingOperation; + let operationId!: number; + let completionSettled = false; + + operation = addon.runScriptStreamingEngine( + handle, + PASSTHROUGH_SCRIPT, + octetStreamInputs(expected), + (chunk, sequence) => { + received.push(chunk); + pending.push({ chunk, sequence }); + if (received.length === 1) { + const first = pending.shift()!; + operation.acknowledge(first.sequence, first.chunk.length); + } + } + ); + operationId = addon.__test_outputOperationId(operation); + operation.completion.finally(() => { completionSettled = true; }).catch(() => {}); + + try { + const paused = await waitForStats( + (stats) => stats.paused, + "streaming producer pause", + operationId + ); + expect(completionSettled).toBe(false); + expectBounded(paused); + + const raw = await drainAfterPause( + operation, + operationId, + pending, + received, + () => completionSettled + ); + expect(Buffer.concat(received)).toEqual(expected); + expect(JSON.parse(raw)).toMatchObject({ + success: true, + mimeType: "application/octet-stream", + }); + + const drained = addon.__test_outputStats(operationId); + expect(drained).toMatchObject({ + outstandingBytes: 0, + outstandingChunks: 0, + done: true, + }); + } finally { + operation.cancel(); + operation.close(); + await withTimeout(operation.completion, "streaming test cleanup"); + } + }); + }, 30000); + + it("rejects duplicate, early, out-of-order, and mismatched acknowledgements without releasing credit", async () => { + await runWithEngine(async (handle) => { + const pending: PendingChunk[] = []; + let earlyAcknowledgementRejected = false; + let operation!: NativeStreamingOperation; + operation = addon.runScriptStreamingEngine( + handle, + PASSTHROUGH_SCRIPT, + octetStreamInputs(Buffer.alloc(LARGE_SIZE, 65)), + (chunk, sequence) => { + if (pending.length === 0) { + try { + operation.acknowledge(sequence + 1n, chunk.length); + } catch { + earlyAcknowledgementRejected = true; + } + } + pending.push({ chunk, sequence }); + } + ); + const operationId = addon.__test_outputOperationId(operation); + + try { + const paused = await waitForStats( + (stats) => stats.paused && pending.length >= 2, + "equal-sized acknowledgement validation window", + operationId + ); + const first = pending[0]; + const second = pending[1]; + expect(first.chunk.length).toBe(second.chunk.length); + expect(earlyAcknowledgementRejected).toBe(true); + + const expectRejectedWithoutAccountingChange = ( + invoke: () => void, + before: OutputFlowStats + ): void => { + expect(invoke).toThrow(); + expect(addon.__test_outputStats(operationId)).toMatchObject({ + outstandingBytes: before.outstandingBytes, + outstandingChunks: before.outstandingChunks, + paused: true, + }); + }; + + expectRejectedWithoutAccountingChange( + () => operation.acknowledge(second.sequence, second.chunk.length), + paused + ); + expectRejectedWithoutAccountingChange( + () => operation.acknowledge(first.sequence + 1000000n, first.chunk.length), + paused + ); + expectRejectedWithoutAccountingChange( + () => operation.acknowledge(first.sequence, first.chunk.length - 1), + paused + ); + + operation.acknowledge(first.sequence, first.chunk.length); + pending.shift(); + const afterFirst = addon.__test_outputStats(operationId); + expect(afterFirst.outstandingChunks).toBe(paused.outstandingChunks - 1); + expectRejectedWithoutAccountingChange( + () => operation.acknowledge(first.sequence, first.chunk.length), + afterFirst + ); + + operation.cancel(); + expect(() => operation.acknowledge(first.sequence, first.chunk.length)).not.toThrow(); + await withTimeout(operation.completion, "invalid acknowledgement cleanup"); + } finally { + operation.cancel(); + operation.close(); + await withTimeout(operation.completion, "acknowledgement test cleanup"); + } + }); + }, 30000); + + it.each([ + { + name: "streaming", + expected: Buffer.from("reserved streaming output"), + start: ( + handle: number, + received: Buffer[], + acknowledge: (chunk: Buffer, sequence: bigint) => void + ) => addon.runScriptStreamingEngine( + handle, + PASSTHROUGH_SCRIPT, + octetStreamInputs(Buffer.from("reserved streaming output")), + (chunk, sequence) => { + received.push(chunk); + acknowledge(chunk, sequence); + } + ), + }, + { + name: "transform", + expected: Buffer.from("reserved transform output"), + start: ( + handle: number, + received: Buffer[], + acknowledge: (chunk: Buffer, sequence: bigint) => void + ) => { + const input = Buffer.from("reserved transform output"); + let offset = 0; + return addon.runScriptTransformEngine( + handle, + PASSTHROUGH_SCRIPT, + "{}", + "payload", + "application/octet-stream", + null, + (bufSize) => { + if (offset >= input.length) return null; + const chunk = input.subarray(offset, Math.min(offset + bufSize, input.length)); + offset += chunk.length; + return chunk; + }, + (chunk, sequence) => { + received.push(chunk); + acknowledge(chunk, sequence); + } + ); + }, + }, + ])("rejects a reserved-but-undelivered $name acknowledgement without changing accounting", async ({ expected, start }) => { + const received: Buffer[] = []; + await runWithEngine(async (handle) => { + addon.__test_holdNextOutputDelivery(); + let operation!: NativeStreamingOperation; + operation = start(handle, received, (chunk, sequence) => { + operation.acknowledge(sequence, chunk.length); + }); + const operationId = addon.__test_outputOperationId(operation); + + try { + const held = await waitForOutputDeliveryBarrier("reserved output delivery"); + const before = addon.__test_outputStats(operationId); + expect(held).toMatchObject({ held: true, sequence: 1n }); + expect(before).toMatchObject({ + outstandingBytes: held.bytes, + outstandingChunks: 1, + }); + + expect(() => operation.acknowledge(held.sequence, held.bytes)).toThrow( + "has not been delivered" + ); + expect(addon.__test_outputStats(operationId)).toEqual(before); + + addon.__test_releaseOutputDelivery(); + const raw = await withTimeout(operation.completion, "released output completion"); + expect(Buffer.concat(received)).toEqual(expected); + expect(JSON.parse(raw)).toMatchObject({ success: true }); + expect(addon.__test_outputStats(operationId)).toMatchObject({ + outstandingBytes: 0, + outstandingChunks: 0, + done: true, + }); + } finally { + addon.__test_releaseOutputDelivery(); + operation?.cancel(); + operation?.close(); + } + }); + }); + + it.each([ + { + name: "streaming", + start: (handle: number, callback: (chunk: Buffer, sequence: bigint) => void) => + addon.runScriptStreamingEngine( + handle, + PASSTHROUGH_SCRIPT, + octetStreamInputs(patternedBytes()), + callback + ), + }, + { + name: "transform", + start: (handle: number, callback: (chunk: Buffer, sequence: bigint) => void) => { + const input = patternedBytes(); + let offset = 0; + return addon.runScriptTransformEngine( + handle, + PASSTHROUGH_SCRIPT, + "{}", + "payload", + "application/octet-stream", + null, + (bufSize) => { + if (offset >= input.length) return null; + const chunk = input.subarray(offset, Math.min(offset + bufSize, input.length)); + offset += chunk.length; + return chunk; + }, + callback + ); + }, + }, + ])("cancels and settles raw $name output when its callback throws", async ({ start }) => { + await runWithEngine(async (handle) => { + const operation = start(handle, () => { throw new Error("output callback boom"); }); + const operationId = addon.__test_outputOperationId(operation); + + try { + const raw = await withTimeout(operation.completion, "throwing callback completion"); + expect(JSON.parse(raw)).toMatchObject({ success: false }); + await waitForStats( + (stats) => stats.cancelled && stats.done && stats.outstandingChunks === 0, + "throwing callback cancellation", + operationId + ); + } finally { + operation.close(); + } + + await waitForStats((stats) => stats.liveFlows === 0, "throwing callback flow release"); + expect(JSON.parse(addon.runScriptEngine(handle, "output application/json --- 6 * 7", "{}"))) + .toMatchObject({ success: true }); + }); + }); + + it("settles without leaking when JS ownership closes while a producer is paused", async () => { + await runWithEngine(async (handle) => { + const operation = addon.runScriptStreamingEngine( + handle, + PASSTHROUGH_SCRIPT, + octetStreamInputs(patternedBytes()), + () => {} + ); + const operationId = addon.__test_outputOperationId(operation); + + await waitForStats((stats) => stats.paused, "close-before-completion pause", operationId); + operation.close(); + operation.cancel(); + await withTimeout(operation.completion, "close-before-completion settlement"); + await waitForStats( + (stats) => stats.cancelled && stats.done && stats.liveFlows === 0, + "close-before-completion flow release", + operationId + ); + }); + }); + + it("settles without leaking when close cancels a paused producer by itself", async () => { + await runWithEngine(async (handle) => { + const operation = addon.runScriptStreamingEngine( + handle, + PASSTHROUGH_SCRIPT, + octetStreamInputs(patternedBytes()), + () => {} + ); + const operationId = addon.__test_outputOperationId(operation); + + await waitForStats((stats) => stats.paused, "close-only pause", operationId); + operation.close(); + await withTimeout(operation.completion, "close-only settlement"); + await waitForStats( + (stats) => stats.cancelled && stats.done && stats.liveFlows === 0, + "close-only flow release", + operationId + ); + }); + }); + + it("runs the exact controller finalizer path under exposed GC", () => { + const fixture = join(__dirname, "fixtures", "output-controller-finalizer.cjs"); + const addonPath = join(__dirname, "..", "..", "build", "Release", "dwlib_addon.node"); + const child = spawnSync(process.execPath, ["--expose-gc", fixture, addonPath, LIB_PATH], { + cwd: __dirname, + encoding: "utf-8", + timeout: 30000, + env: { ...process.env, DATAWEAVE_TEST_HOOKS: "1" }, + }); + + expect(child.error, child.error?.message).toBeUndefined(); + expect(child.signal, child.stderr).toBeNull(); + expect(child.status, child.stderr).toBe(0); + expect(JSON.parse(child.stdout.trim())).toEqual({ + completionSettled: true, + liveFlows: 0, + reusedResult: "42", + }); + }); + + it("bounds transform output without changing the transform read bridge", async () => { + await runWithEngine(async (handle) => { + const expected = patternedBytes(); + const received: Buffer[] = []; + const pending: PendingChunk[] = []; + let offset = 0; + let operation!: NativeStreamingOperation; + let operationId!: number; + let completionSettled = false; + + operation = addon.runScriptTransformEngine( + handle, + PASSTHROUGH_SCRIPT, + "{}", + "payload", + "application/octet-stream", + null, + (bufSize) => { + if (offset >= expected.length) return null; + const chunk = expected.subarray(offset, Math.min(offset + bufSize, expected.length)); + offset += chunk.length; + return chunk; + }, + (chunk, sequence) => { + received.push(chunk); + pending.push({ chunk, sequence }); + if (received.length === 1) { + const first = pending.shift()!; + operation.acknowledge(first.sequence, first.chunk.length); + } + } + ); + operationId = addon.__test_outputOperationId(operation); + operation.completion.finally(() => { completionSettled = true; }).catch(() => {}); + + try { + const paused = await waitForStats( + (stats) => stats.paused, + "transform producer pause", + operationId + ); + expect(completionSettled).toBe(false); + expectBounded(paused); + + const raw = await drainAfterPause( + operation, + operationId, + pending, + received, + () => completionSettled + ); + expect(offset).toBe(expected.length); + expect(Buffer.concat(received)).toEqual(expected); + expect(JSON.parse(raw)).toMatchObject({ + success: true, + mimeType: "application/octet-stream", + }); + } finally { + operation.cancel(); + operation.close(); + await withTimeout(operation.completion, "transform test cleanup"); + } + }); + }, 30000); + + it("settles and closes when an iterator returns while the producer is paused", async () => { + const dw = new DataWeave(); + dw.initialize(); + const stream = dw.runStreaming(PASSTHROUGH_SCRIPT, { + payload: { + content: patternedBytes(), + mimeType: "application/octet-stream", + }, + }); + + try { + const first = await withTimeout(stream.next(), "first iterator chunk"); + expect(first.done).toBe(false); + await waitForStats((stats) => stats.paused, "iterator producer pause"); + + await withTimeout(stream.return(undefined), "paused iterator return"); + await withTimeout(dw.cleanup(), "cleanup after paused iterator return"); + await waitForStats( + (stats) => stats.cancelled && stats.done && stats.outstandingBytes === 0, + "cancelled iterator settlement" + ); + expect(addon.__test_outputStats().liveFlows).toBe(0); + + await expect(stream.next()).resolves.toEqual({ done: true, value: undefined }); + dw.initialize(); + expect(dw.run("output application/json --- 6 * 7").getString()).toBe("42"); + } finally { + await withTimeout(dw.cleanup(), "iterator test final cleanup"); + } + }, 30000); + + it("DataWeave.cleanup cancels a paused producer and deterministically settles its iterator", async () => { + const dw = new DataWeave(); + dw.initialize(); + const anchor = new DataWeave(); + anchor.initialize(); + const stream = dw.runStreaming(PASSTHROUGH_SCRIPT, { + payload: { + content: patternedBytes(), + mimeType: "application/octet-stream", + }, + }); + + try { + const first = await withTimeout(stream.next(), "first cleanup-test chunk"); + expect(first.done).toBe(false); + await waitForStats((stats) => stats.paused, "cleanup-test producer pause"); + + await withTimeout(dw.cleanup(), "DataWeave.cleanup while producer paused"); + await waitForStats( + (stats) => stats.cancelled && stats.done && stats.outstandingChunks === 0, + "cleanup cancellation settlement" + ); + expect(addon.__test_outputStats().liveFlows).toBe(0); + await expect(stream.next()).resolves.toEqual({ done: true, value: undefined }); + + dw.initialize(); + expect(dw.run("output application/json --- 20 + 22").getString()).toBe("42"); + } finally { + await withTimeout(dw.cleanup(), "cleanup test final cleanup"); + await withTimeout(anchor.cleanup(), "cleanup test anchor cleanup"); + } + }, 30000); + + it.each([ + { + name: "streaming", + start: (handle: number) => addon.runScriptStreamingEngine( + handle, + "%dw 2.0\noutput application/json\n---\n[]", + "{}", + () => {} + ), + startPublic: (dw: DataWeave) => dw.runStreaming( + "%dw 2.0\noutput application/json\n---\n[]" + ), + }, + { + name: "transform", + start: (handle: number) => addon.runScriptTransformEngine( + handle, + "%dw 2.0\noutput application/json\n---\npayload", + "{}", + "payload", + "application/json", + "UTF-8", + () => null, + () => {} + ), + startPublic: (dw: DataWeave) => dw.runTransform( + "%dw 2.0\noutput application/json\n---\npayload", + [Buffer.from("[]")] + ), + }, + ])("settles $name completion and cleanup for every recoverable terminal N-API fault", async ({ start, startPublic }) => { + for (const fault of [ + "initial-create-generic", + "initial-pending-exception", + "fallback-call-generic", + "fallback-pending-exception", + ] as const) { + await runWithEngine(async (handle) => { + addon.__test_failNextOutputSettlement(fault); + const operation = start(handle); + const operationId = addon.__test_outputOperationId(operation); + + try { + const raw = await withTimeout( + operation.completion, + `${fault} terminal settlement` + ); + expect(JSON.parse(raw)).toMatchObject({ success: false }); + } finally { + operation.cancel(); + operation.close(); + } + + await waitForStats( + (stats) => stats.done && stats.liveFlows === 0, + `${fault} terminal flow completion`, + operationId + ); + }); + + const dw = new DataWeave(); + dw.initialize(); + addon.__test_holdNextAsyncOp(); + addon.__test_failNextOutputSettlement(fault); + const stream = startPublic(dw); + const firstPull = stream.next(); + try { + await waitForAsyncOpHeld(`${fault} public operation admission`); + const cleanup = dw.cleanup(); + addon.__test_releaseAsyncOp(); + await withTimeout(cleanup, `${fault} DataWeave cleanup`); + await firstPull; + await waitForStats((stats) => stats.liveFlows === 0, `${fault} public flow release`); + dw.initialize(); + expect(dw.run("output application/json --- 6 * 7").getString()).toBe("42"); + } finally { + addon.__test_releaseAsyncOp(); + await withTimeout(dw.cleanup(), `${fault} final cleanup`); + } + } + }, 30000); + + it.each([ + "initial-call-generic-after-call", + "fallback-call-generic-after-call", + ] as const)("fails closed instead of reusing a consumed deferred after %s", (fault) => { + const fixture = join(__dirname, "fixtures", "output-settlement-after-call.cjs"); + const addonPath = join(__dirname, "..", "..", "build", "Release", "dwlib_addon.node"); + const child = spawnSync(process.execPath, [fixture, addonPath, LIB_PATH, fault], { + cwd: __dirname, + encoding: "utf-8", + timeout: 30000, + env: { ...process.env, DATAWEAVE_TEST_HOOKS: "1" }, + }); + + expect(child.error, child.error?.message).toBeUndefined(); + expect(child.status === 0 && child.signal === null, child.stderr).toBe(false); + expect(child.signal).not.toBe("SIGSEGV"); + expect(child.stderr).toContain("Output completion settlement failed after deferred consumption"); + }); + + it.each([ + { settlement: "initial-pending-exception", point: "before consumption" }, + { settlement: "initial-call-pending-after-call", point: "after consumption" }, + ] as const)("fails closed when pending-exception clearing fails $point", ({ settlement }) => { + const fixture = join(__dirname, "fixtures", "output-settlement-after-call.cjs"); + const addonPath = join(__dirname, "..", "..", "build", "Release", "dwlib_addon.node"); + + for (const mode of ["streaming", "transform"] as const) { + for (const clearFault of [ + "is-exception-pending", + "get-and-clear-last-exception", + ] as const) { + const child = spawnSync( + process.execPath, + [fixture, addonPath, LIB_PATH, settlement, mode, clearFault], + { + cwd: __dirname, + encoding: "utf-8", + timeout: 30000, + env: { ...process.env, DATAWEAVE_TEST_HOOKS: "1" }, + } + ); + + expect(child.error, `${mode}/${clearFault}: ${child.error?.message}`).toBeUndefined(); + expect( + child.status === 0 && child.signal === null, + `${mode}/${clearFault}: ${child.stderr}` + ).toBe(false); + expect(child.signal, `${mode}/${clearFault}: ${child.stderr}`).not.toBe("SIGSEGV"); + expect(child.stderr).toContain("Output completion settlement failed"); + } + } + }); +}); + +async function waitForOutputDeliveryBarrier(label: string): Promise<{ + held: boolean; + sequence: bigint; + bytes: number; +}> { + const deadline = Date.now() + 10000; + while (true) { + const held = addon.__test_heldOutputDelivery(); + if (held.held) return held; + if (Date.now() >= deadline) throw new Error(`${label} timed out`); + await immediate(); + } +} + +async function waitForAsyncOpHeld(label: string): Promise { + const deadline = Date.now() + 10000; + while (!addon.__test_asyncOpHeld()) { + if (Date.now() >= deadline) throw new Error(`${label} timed out`); + await immediate(); + } +} diff --git a/native-lib/node/tests/integration/teardown-deadlock.test.ts b/native-lib/node/tests/integration/teardown-deadlock.test.ts index efa44cec..193651f9 100644 --- a/native-lib/node/tests/integration/teardown-deadlock.test.ts +++ b/native-lib/node/tests/integration/teardown-deadlock.test.ts @@ -1,56 +1,33 @@ import { describe, it, expect } from "vitest"; -import { run, runTransform, cleanup } from "../../src/dataweave"; +import { run, runStreaming, runTransform, cleanup } from "../../src/dataweave"; +import { DataWeaveError } from "../../src/errors"; -// 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. +interface TestAddon { + __test_holdNextAsyncOp(): void; + __test_asyncOpHeld(): boolean; + __test_releaseAsyncOp(): void; +} + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const testAddon = require("../../build/Release/dwlib_addon.node") as TestAddon; + +async function waitForAsyncOpGate(): Promise { + const deadline = Date.now() + 10000; + while (!testAddon.__test_asyncOpHeld()) { + if (Date.now() >= deadline) throw new Error("async operation did not reach test gate"); + await new Promise((resolve) => setImmediate(resolve)); + } +} + +// Task 6 adds this callback-specific contract alongside the adoption regression +// below: public DataWeave execution is rejected while native code is invoking +// the transform input callback. The real-addon test also proves the worker and +// outer transform drain without a deadlock after that rejection. +describe("public API transform callback reentrancy guard", () => { it( - "module-level cleanup() during an active transform read does not deadlock a concurrent run()", + "rejects a nested module-level run and lets the outer transform drain", 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 @@ -64,20 +41,8 @@ describe("re-init during pending teardown (W-23692110, round 5 P1)", () => { 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'); + run('%dw 2.0\noutput application/json\n---\n1 + 1'); } catch (e) { runError = e; } @@ -86,35 +51,60 @@ describe("re-init during pending teardown (W-23692110, round 5 P1)", () => { } } - const gen = runTransform( - "output application/octet-stream\n---\npayload", - input(), - { mimeType: "application/octet-stream" } - ); + try { + 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(); + // 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).toBeInstanceOf(DataWeaveError); + expect(result.value.success).toBe(true); + } finally { + await cleanup(); } + }, + 20000 + ); +}); + +describe("re-init during pending teardown (W-23692110, round 5 P1)", () => { + it("adopts the live isolate instead of blocking initialize behind an active op", async () => { + let cleanupPromise: Promise | undefined; + let gateArmed = false; + let gateReleased = false; - 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); + try { + expect(run("%dw 2.0\noutput application/json\n---\n6 * 7").success).toBe(true); + testAddon.__test_holdNextAsyncOp(); + gateArmed = true; + const outer = runStreaming("%dw 2.0\noutput application/json\n---\n[1,2,3]"); + const firstNext = outer.next(); + await waitForAsyncOpGate(); + cleanupPromise = cleanup(); - // 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. + const result = run("%dw 2.0\noutput application/json\n---\n1 + 1"); + expect(result.success).toBe(true); + expect(JSON.parse(result.getString()!)).toBe(2); + + testAddon.__test_releaseAsyncOp(); + gateReleased = true; + await firstNext; + await expect(outer.next()).resolves.toEqual({ done: true, value: undefined }); 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. + } finally { + if (gateArmed && !gateReleased) testAddon.__test_releaseAsyncOp(); + if (cleanupPromise) await cleanupPromise; await cleanup(); - }, - 20000 - ); + } + }, 20000); }); diff --git a/native-lib/node/tests/unit/dataweave-initialize.test.ts b/native-lib/node/tests/unit/dataweave-initialize.test.ts index 51d199f0..a051d7eb 100644 --- a/native-lib/node/tests/unit/dataweave-initialize.test.ts +++ b/native-lib/node/tests/unit/dataweave-initialize.test.ts @@ -16,10 +16,46 @@ vi.mock("../../src/ffi", () => ({ runScriptTransformEngine: vi.fn(), cleanup: vi.fn(), })); +vi.mock("../../src/reader", async () => { + const actual = await vi.importActual("../../src/reader"); + return { ...actual, createChunkReader: vi.fn(actual.createChunkReader) }; +}); import * as ffi from "../../src/ffi"; import { DataWeave, run, cleanup } from "../../src/dataweave"; import { DataWeaveError } from "../../src/errors"; +import { createChunkReader } from "../../src/reader"; +import type { NativeStreamingOperation } from "../../src/ffi"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +function operation(completion: Promise): NativeStreamingOperation { + return { + completion, + acknowledge: vi.fn(), + cancel: vi.fn(), + close: vi.fn(), + }; +} + +function sequencedCallback void>( + callback: T +): (chunk: Buffer) => void { + let sequence = 0n; + return (chunk) => callback(chunk, ++sequence); +} + +const okStreamingMeta = () => JSON.stringify({ + success: true, + mimeType: "application/json", + charset: "utf-8", + binary: false, +}); describe("DataWeave.initialize() native ref-count safety", () => { beforeEach(() => { @@ -27,7 +63,10 @@ describe("DataWeave.initialize() native ref-count safety", () => { vi.mocked(ffi.createEngine).mockReset(); vi.mocked(ffi.createEngineWithResolver).mockReset(); vi.mocked(ffi.destroyEngine).mockReset(); + vi.mocked(ffi.runScriptStreamingEngine).mockReset(); + vi.mocked(ffi.runScriptTransformEngine).mockReset(); vi.mocked(ffi.cleanup).mockReset(); + vi.mocked(createChunkReader).mockClear(); }); it("releases the native library ref-count if engine creation fails after ffi.initialize() succeeded", () => { @@ -397,4 +436,1278 @@ describe("DataWeave.initialize() native ref-count safety", () => { await dw.cleanup(); expect(ffi.destroyEngine).toHaveBeenCalledWith(11); }); + + describe("active streaming cleanup", () => { + it("starts and registers native work only on first iteration", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(12); + const nativeOperation = operation(Promise.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptStreamingEngine).mockReturnValue(nativeOperation); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const stream = dw.runStreaming("output application/json --- [1]"); + + expect(ffi.runScriptStreamingEngine).not.toHaveBeenCalled(); + const result = await stream.next(); + expect(result.done).toBe(true); + expect(ffi.runScriptStreamingEngine).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + expect(nativeOperation.cancel).not.toHaveBeenCalled(); + }); + + it("cancels active operations and awaits their settlement before destroying the engine", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(13); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + vi.mocked(ffi.runScriptStreamingEngine).mockReturnValue(nativeOperation); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const stream = dw.runStreaming("output application/json --- [1]"); + const firstPull = stream.next(); + await vi.waitFor(() => expect(ffi.runScriptStreamingEngine).toHaveBeenCalledTimes(1)); + + const cleanupPromise = dw.cleanup(); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(ffi.destroyEngine).not.toHaveBeenCalled(); + + completion.resolve(okStreamingMeta()); + await Promise.all([firstPull, cleanupPromise]); + + expect(ffi.destroyEngine).toHaveBeenCalledWith(13); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("does not wait on unresolved completion and permits cleanup retry when cancellation fails", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(19); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn() + .mockImplementationOnce(() => { throw new Error("cancel boom"); }) + .mockImplementationOnce(() => completion.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptStreamingEngine).mockReturnValue(nativeOperation); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const firstPull = dw.runStreaming("output application/json --- [1]").next(); + const firstPullOutcome = firstPull.catch((error) => error); + await vi.waitFor(() => expect(ffi.runScriptStreamingEngine).toHaveBeenCalledTimes(1)); + + const firstCleanupOutcome = dw.cleanup().then( + () => undefined, + (error: unknown) => error + ); + const stillPending = Symbol("still pending"); + const observed = await Promise.race([ + firstCleanupOutcome, + new Promise((resolve) => setImmediate(() => resolve(stillPending))), + ]); + + // Release the pre-fix cleanup after capturing that it hung on completion. + if (observed === stillPending) completion.resolve(okStreamingMeta()); + await firstCleanupOutcome; + await firstPullOutcome; + + expect(observed).toEqual(new Error("cancel boom")); + expect(ffi.destroyEngine).not.toHaveBeenCalled(); + expect(() => dw.run("output application/json --- 1")).toThrow(/cleaning up/i); + + await expect(dw.cleanup()).resolves.toBeUndefined(); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(2); + expect(ffi.destroyEngine).toHaveBeenCalledWith(19); + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + }); + + it("retry cleanup still awaits an earlier successfully canceled operation", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(27); + const completionA = deferred(); + const operationA = operation(completionA.promise); + const completionB = deferred(); + const operationB = operation(completionB.promise); + operationB.cancel = vi.fn() + .mockImplementationOnce(() => { throw undefined; }) + .mockImplementationOnce(() => completionB.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptStreamingEngine) + .mockReturnValueOnce(operationA) + .mockReturnValueOnce(operationB); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const firstPullA = dw.runStreaming("output application/json --- [1]").next(); + const firstPullB = dw.runStreaming("output application/json --- [2]").next(); + const firstPullAOutcome = firstPullA.catch((error) => error); + const firstPullBOutcome = firstPullB.catch((error) => error); + await vi.waitFor(() => expect(ffi.runScriptStreamingEngine).toHaveBeenCalledTimes(2)); + + const firstCleanup = await dw.cleanup().then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + expect(firstCleanup).toEqual({ status: "rejected", reason: undefined }); + expect(operationA.cancel).toHaveBeenCalledTimes(1); + expect(operationA.close).toHaveBeenCalledTimes(1); + expect(operationB.cancel).toHaveBeenCalledTimes(2); + expect(ffi.destroyEngine).not.toHaveBeenCalled(); + + const retry = dw.cleanup(); + expect(operationA.cancel).toHaveBeenCalledTimes(1); + expect(operationB.cancel).toHaveBeenCalledTimes(2); + const retryState = Symbol("retry pending"); + expect(await Promise.race([ + retry.then(() => "settled"), + Promise.resolve(retryState), + ])).toBe(retryState); + expect(ffi.destroyEngine).not.toHaveBeenCalled(); + + completionA.resolve(okStreamingMeta()); + await retry; + await Promise.all([firstPullAOutcome, firstPullBOutcome]); + expect(ffi.destroyEngine).toHaveBeenCalledWith(27); + }); + + it("acknowledges buffered chunks abandoned by cleanup", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(16); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + vi.mocked(ffi.runScriptStreamingEngine).mockImplementation((_handle, _script, _inputs, cb) => { + const push = sequencedCallback(cb); + push(Buffer.from("x")); + push(Buffer.from("yy")); + return nativeOperation; + }); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const stream = dw.runStreaming("output application/json --- [1]"); + const first = await stream.next(); + expect(first.value?.toString()).toBe("x"); + expect(nativeOperation.acknowledge).toHaveBeenCalledWith(1n, 1); + + const cleanupPromise = dw.cleanup(); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(2, 2n, 2); + expect(nativeOperation.acknowledge).toHaveBeenCalledTimes(2); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + + completion.resolve(okStreamingMeta()); + await cleanupPromise; + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("tracks and cancels transform operations after lazy input preparation", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(17); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + vi.mocked(ffi.runScriptTransformEngine).mockReturnValue(nativeOperation); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform( + "output application/json --- payload", + [Buffer.from("[1]")] + ); + + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + const firstPull = transform.next(); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + + const cleanupPromise = dw.cleanup(); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + completion.resolve(okStreamingMeta()); + await Promise.all([firstPull, cleanupPromise]); + + expect(ffi.destroyEngine).toHaveBeenCalledWith(17); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("return cancels a transform after native admission while its first pull is pending", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(21); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptTransformEngine).mockReturnValue(nativeOperation); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform( + "output application/json --- payload", + [Buffer.from("[1]")] + ); + const firstPull = transform.next(); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + + const returned = transform.return(undefined); + await expect(Promise.all([firstPull, returned])).resolves.toEqual([ + { done: true, value: undefined }, + { done: true, value: undefined }, + ]); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + }); + + it("return before first transform pull closes permanently without preparing or admitting work", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(22); + let inputReads = 0; + const input = { + *[Symbol.iterator]() { + inputReads++; + yield Buffer.from("[1]"); + }, + }; + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", input); + + await expect(transform.return(undefined)).resolves.toEqual({ done: true, value: undefined }); + await expect(transform.next()).resolves.toEqual({ done: true, value: undefined }); + expect(inputReads).toBe(0); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("keeps next before return ordered when transform setup has not started", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(36); + const nativeOperation = operation(Promise.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptTransformEngine).mockImplementation( + (_handle, _script, _inputs, _inputName, _mimeType, _charset, _readCb, writeCb) => { + writeCb(Buffer.from("x")); + return nativeOperation; + } + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + const returned = transform.return(undefined); + + await expect(firstPull).resolves.toEqual({ done: true, value: undefined }); + await expect(returned).resolves.toEqual({ done: true, value: undefined }); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("assimilates a promised return value before first transform pull", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(23); + const promisedResult = Promise.resolve({ success: true } as StreamingResult); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + + const returned = await (transform.return as (value: unknown) => Promise>)(promisedResult); + expect(returned).toEqual({ done: true, value: { success: true } }); + expect(returned.value).not.toBe(promisedResult); + await expect(transform.next()).resolves.toEqual({ done: true, value: undefined }); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("throw before first transform pull closes permanently without admission", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(24); + const thrown = new Error("early throw"); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + + await expect(transform.throw(thrown)).rejects.toBe(thrown); + await expect(transform.next()).resolves.toEqual({ done: true, value: undefined }); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("return during pending transform setup wins over setup rejection", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(28); + const readerGate = deferred>>(); + vi.mocked(createChunkReader).mockReturnValueOnce(readerGate.promise); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(createChunkReader).toHaveBeenCalledTimes(1)); + const returned = transform.return(undefined); + readerGate.reject(new Error("setup boom")); + + await expect(firstPull).resolves.toEqual({ done: true, value: undefined }); + await expect(returned).resolves.toEqual({ done: true, value: undefined }); + await expect(transform.next()).resolves.toEqual({ done: true, value: undefined }); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("return abandons unresolved async transform setup without waiting for input", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(31); + const inputNext = deferred>(); + const inputStarted = deferred(); + const input = { + [Symbol.asyncIterator]() { + return { + next() { + inputStarted.resolve(); + return inputNext.promise; + }, + }; + }, + }; + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", input); + const firstPull = transform.next(); + await inputStarted.promise; + + const returned = transform.return(undefined); + const pending = Symbol("pending setup"); + const observed = await Promise.race([ + Promise.allSettled([firstPull, returned]), + new Promise((resolve) => setImmediate(() => resolve(pending))), + ]); + + inputNext.resolve({ done: true, value: undefined }); + await Promise.allSettled([firstPull, returned]); + await dw.cleanup(); + + expect(observed).toEqual([ + { status: "fulfilled", value: { done: true, value: undefined } }, + { status: "fulfilled", value: { done: true, value: undefined } }, + ]); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + }); + + it("throw abandons unresolved async transform setup without waiting for input", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(32); + const inputNext = deferred>(); + const inputStarted = deferred(); + const input = { + [Symbol.asyncIterator]() { + return { + next() { + inputStarted.resolve(); + return inputNext.promise; + }, + }; + }, + }; + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", input); + const firstPull = transform.next(); + await inputStarted.promise; + + const thrown = new Error("consumer boom"); + const throwing = transform.throw(thrown); + const pending = Symbol("pending setup"); + const observed = await Promise.race([ + Promise.allSettled([firstPull, throwing]), + new Promise((resolve) => setImmediate(() => resolve(pending))), + ]); + + inputNext.resolve({ done: true, value: undefined }); + await Promise.allSettled([firstPull, throwing]); + await dw.cleanup(); + + expect(observed).toEqual([ + { status: "fulfilled", value: { done: true, value: undefined } }, + { status: "rejected", reason: thrown }, + ]); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + }); + + it("does not admit transform work when setup resolves after return", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(35); + const inputNext = deferred>(); + const inputStarted = deferred(); + const input = { + [Symbol.asyncIterator]() { + return { + next() { + inputStarted.resolve(); + return inputNext.promise; + }, + }; + }, + }; + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", input); + const firstPull = transform.next(); + await inputStarted.promise; + + await transform.return(undefined); + await firstPull; + inputNext.resolve({ done: true, value: undefined }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + await dw.cleanup(); + }); + + it("abandons unresolved setup for two pulls before return and ignores late fulfillment", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(45); + const readerGate = deferred>>(); + vi.mocked(createChunkReader).mockReturnValueOnce(readerGate.promise); + const returnGate = deferred(); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const settlements = [0, 0, 0]; + const firstPull = transform.next().finally(() => { settlements[0]++; }); + await vi.waitFor(() => expect(createChunkReader).toHaveBeenCalledTimes(1)); + const secondPull = transform.next().finally(() => { settlements[1]++; }); + const returned = (transform.return as (value: unknown) => Promise>)(returnGate.promise) + .finally(() => { settlements[2]++; }); + const laterPull = transform.next(); + const pendingSetup = Symbol("pending setup"); + + expect(await Promise.race([ + Promise.all([firstPull, secondPull]), + new Promise((resolve) => setImmediate(() => resolve(pendingSetup))), + ])).toEqual([ + { done: true, value: undefined }, + { done: true, value: undefined }, + ]); + + const returnValue = { success: true } as StreamingResult; + returnGate.resolve(returnValue); + await expect(Promise.all([returned, laterPull])).resolves.toEqual([ + { done: true, value: returnValue }, + { done: true, value: undefined }, + ]); + expect(settlements).toEqual([1, 1, 1]); + + readerGate.resolve(() => null); + await readerGate.promise; + await Promise.resolve(); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("abandons unresolved setup for two pulls before throw and ignores late fulfillment", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(46); + const readerGate = deferred>>(); + vi.mocked(createChunkReader).mockReturnValueOnce(readerGate.promise); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const settlements = [0, 0, 0]; + const firstPull = transform.next().finally(() => { settlements[0]++; }); + await vi.waitFor(() => expect(createChunkReader).toHaveBeenCalledTimes(1)); + const secondPull = transform.next().finally(() => { settlements[1]++; }); + const thrown = new Error("consumer boom"); + const throwing = transform.throw(thrown).finally(() => { settlements[2]++; }); + const laterPull = transform.next(); + const pendingSetup = Symbol("pending setup"); + + expect(await Promise.race([ + Promise.allSettled([firstPull, secondPull, throwing, laterPull]), + new Promise((resolve) => setImmediate(() => resolve(pendingSetup))), + ])).toEqual([ + { status: "fulfilled", value: { done: true, value: undefined } }, + { status: "fulfilled", value: { done: true, value: undefined } }, + { status: "rejected", reason: thrown }, + { status: "fulfilled", value: { done: true, value: undefined } }, + ]); + expect(settlements).toEqual([1, 1, 1]); + + readerGate.resolve(() => null); + await readerGate.promise; + await Promise.resolve(); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("keeps returned setup outcomes unchanged when abandoned setup rejects late", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(47); + const readerGate = deferred>>(); + vi.mocked(createChunkReader).mockReturnValueOnce(readerGate.promise); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { unhandled.push(reason); }; + process.on("unhandledRejection", onUnhandled); + + try { + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(createChunkReader).toHaveBeenCalledTimes(1)); + const secondPull = transform.next(); + const returnValue = { success: true } as StreamingResult; + const returned = transform.return(returnValue); + const laterPull = transform.next(); + const pendingSetup = Symbol("pending setup"); + const outcomes = await Promise.race([ + Promise.all([firstPull, secondPull, returned, laterPull]), + new Promise((resolve) => setImmediate(() => resolve(pendingSetup))), + ]); + + expect(outcomes).toEqual([ + { done: true, value: undefined }, + { done: true, value: undefined }, + { done: true, value: returnValue }, + { done: true, value: undefined }, + ]); + + readerGate.reject(new Error("late setup boom")); + await readerGate.promise.catch(() => {}); + await Promise.resolve(); + expect(unhandled).toEqual([]); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("keeps thrown setup outcomes unchanged when abandoned setup rejects late", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(48); + const readerGate = deferred>>(); + vi.mocked(createChunkReader).mockReturnValueOnce(readerGate.promise); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { unhandled.push(reason); }; + process.on("unhandledRejection", onUnhandled); + + try { + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(createChunkReader).toHaveBeenCalledTimes(1)); + const secondPull = transform.next(); + const thrown = new Error("consumer boom"); + const throwing = transform.throw(thrown); + const laterPull = transform.next(); + const pendingSetup = Symbol("pending setup"); + const outcomes = await Promise.race([ + Promise.allSettled([firstPull, secondPull, throwing, laterPull]), + new Promise((resolve) => setImmediate(() => resolve(pendingSetup))), + ]); + + expect(outcomes).toEqual([ + { status: "fulfilled", value: { done: true, value: undefined } }, + { status: "fulfilled", value: { done: true, value: undefined } }, + { status: "rejected", reason: thrown }, + { status: "fulfilled", value: { done: true, value: undefined } }, + ]); + + readerGate.reject(new Error("late setup boom")); + await readerGate.promise.catch(() => {}); + await Promise.resolve(); + expect(unhandled).toEqual([]); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("delivers a buffered transform chunk to an earlier next before return", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(33); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptTransformEngine).mockImplementation( + (_handle, _script, _inputs, _inputName, _mimeType, _charset, _readCb, writeCb) => { + const push = sequencedCallback(writeCb); + push(Buffer.from("a")); + push(Buffer.from("b")); + return nativeOperation; + } + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + await expect(transform.next()).resolves.toEqual({ done: false, value: Buffer.from("a") }); + + const secondPull = transform.next(); + const returned = transform.return(undefined); + + await expect(secondPull).resolves.toEqual({ done: false, value: Buffer.from("b") }); + await expect(returned).resolves.toEqual({ done: true, value: undefined }); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(1, 1n, 1); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(2, 2n, 1); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + }); + + it("delivers two buffered chunks to two earlier pulls before return", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(37); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okStreamingMeta())); + let push!: (chunk: Buffer) => void; + vi.mocked(ffi.runScriptTransformEngine).mockImplementation( + (_handle, _script, _inputs, _inputName, _mimeType, _charset, _readCb, writeCb) => { + push = sequencedCallback(writeCb); + return nativeOperation; + } + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const admissionPull = transform.next(); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + push(Buffer.from("admitted")); + await expect(admissionPull).resolves.toEqual({ done: false, value: Buffer.from("admitted") }); + push(Buffer.from("a")); + push(Buffer.from("bb")); + const firstPull = transform.next(); + const secondPull = transform.next(); + const returned = transform.return(undefined); + + await expect(Promise.all([firstPull, secondPull, returned])).resolves.toEqual([ + { done: false, value: Buffer.from("a") }, + { done: false, value: Buffer.from("bb") }, + { done: true, value: undefined }, + ]); + expect(nativeOperation.acknowledge.mock.calls).toEqual([ + [1n, 8], + [2n, 1], + [3n, 2], + ]); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + }); + + it("delivers two buffered chunks to two earlier pulls before throw", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(38); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okStreamingMeta())); + let push!: (chunk: Buffer) => void; + vi.mocked(ffi.runScriptTransformEngine).mockImplementation( + (_handle, _script, _inputs, _inputName, _mimeType, _charset, _readCb, writeCb) => { + push = sequencedCallback(writeCb); + return nativeOperation; + } + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const admissionPull = transform.next(); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + push(Buffer.from("admitted")); + await expect(admissionPull).resolves.toEqual({ done: false, value: Buffer.from("admitted") }); + push(Buffer.from("a")); + push(Buffer.from("bb")); + const firstPull = transform.next(); + const secondPull = transform.next(); + const thrown = new Error("consumer boom"); + const throwing = transform.throw(thrown); + + const outcomes = await Promise.allSettled([firstPull, secondPull, throwing]); + expect(outcomes).toEqual([ + { status: "fulfilled", value: { done: false, value: Buffer.from("a") } }, + { status: "fulfilled", value: { done: false, value: Buffer.from("bb") } }, + { status: "rejected", reason: thrown }, + ]); + expect(nativeOperation.acknowledge.mock.calls).toEqual([ + [1n, 8], + [2n, 1], + [3n, 2], + ]); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + }); + + it("settles four earlier pulls exactly once before return", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(39); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptTransformEngine).mockImplementation( + (_handle, _script, _inputs, _inputName, _mimeType, _charset, _readCb, writeCb) => { + const push = sequencedCallback(writeCb); + push(Buffer.from("a")); + push(Buffer.from("bb")); + push(Buffer.from("ccc")); + push(Buffer.from("dddd")); + return nativeOperation; + } + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const settlements = [0, 0, 0, 0, 0]; + const firstPull = transform.next().finally(() => { settlements[0]++; }); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + const secondPull = transform.next().finally(() => { settlements[1]++; }); + const thirdPull = transform.next().finally(() => { settlements[2]++; }); + const fourthPull = transform.next().finally(() => { settlements[3]++; }); + const returned = transform.return(undefined).finally(() => { settlements[4]++; }); + + await expect(Promise.all([firstPull, secondPull, thirdPull, fourthPull, returned])).resolves.toEqual([ + { done: false, value: Buffer.from("a") }, + { done: false, value: Buffer.from("bb") }, + { done: false, value: Buffer.from("ccc") }, + { done: false, value: Buffer.from("dddd") }, + { done: true, value: undefined }, + ]); + expect(settlements).toEqual([1, 1, 1, 1, 1]); + expect(nativeOperation.acknowledge.mock.calls).toEqual([ + [1n, 1], + [2n, 2], + [3n, 3], + [4n, 4], + ]); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + }); + + it("wakes only a genuinely parked tail after earlier pulls drain buffered chunks", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(40); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptTransformEngine).mockImplementation( + (_handle, _script, _inputs, _inputName, _mimeType, _charset, _readCb, writeCb) => { + const push = sequencedCallback(writeCb); + push(Buffer.from("a")); + push(Buffer.from("bb")); + return nativeOperation; + } + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + const secondPull = transform.next(); + const parkedPull = transform.next(); + await secondPull; + await Promise.resolve(); + const returned = transform.return(undefined); + + await expect(Promise.all([firstPull, secondPull, parkedPull, returned])).resolves.toEqual([ + { done: false, value: Buffer.from("a") }, + { done: false, value: Buffer.from("bb") }, + { done: true, value: undefined }, + { done: true, value: undefined }, + ]); + expect(nativeOperation.acknowledge.mock.calls).toEqual([[1n, 1], [2n, 2]]); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + }); + + it("throws after waking only a genuinely parked tail behind earlier buffered pulls", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(41); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptTransformEngine).mockImplementation( + (_handle, _script, _inputs, _inputName, _mimeType, _charset, _readCb, writeCb) => { + const push = sequencedCallback(writeCb); + push(Buffer.from("a")); + push(Buffer.from("bb")); + return nativeOperation; + } + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + const secondPull = transform.next(); + const parkedPull = transform.next(); + await secondPull; + await Promise.resolve(); + const thrown = new Error("consumer boom"); + const throwing = transform.throw(thrown); + + const outcomes = await Promise.allSettled([firstPull, secondPull, parkedPull, throwing]); + expect(outcomes).toEqual([ + { status: "fulfilled", value: { done: false, value: Buffer.from("a") } }, + { status: "fulfilled", value: { done: false, value: Buffer.from("bb") } }, + { status: "fulfilled", value: { done: true, value: undefined } }, + { status: "rejected", reason: thrown }, + ]); + expect(nativeOperation.acknowledge.mock.calls).toEqual([[1n, 1], [2n, 2]]); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + }); + + it("surfaces cancellation failure after interrupting a parked transform pull", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(42); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => { throw new Error("cancel boom"); }); + vi.mocked(ffi.runScriptTransformEngine).mockReturnValue(nativeOperation); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const parkedPull = transform.next(); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + const returned = transform.return(undefined); + + await expect(parkedPull).rejects.toThrow("cancel boom"); + await expect(returned).rejects.toThrow("cancel boom"); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(3); + expect(nativeOperation.close).not.toHaveBeenCalled(); + }); + + it("does not let a pull after return block interruption of an earlier parked pull", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(43); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okStreamingMeta())); + vi.mocked(ffi.runScriptTransformEngine).mockImplementation( + (_handle, _script, _inputs, _inputName, _mimeType, _charset, _readCb, writeCb) => { + sequencedCallback(writeCb)(Buffer.from("a")); + return nativeOperation; + } + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + const parkedPull = transform.next(); + const returned = transform.return(undefined); + const laterPull = transform.next(); + + await vi.waitFor(() => expect(nativeOperation.cancel).toHaveBeenCalledTimes(1), { timeout: 250 }); + await expect(Promise.all([firstPull, parkedPull, returned, laterPull])).resolves.toEqual([ + { done: false, value: Buffer.from("a") }, + { done: true, value: undefined }, + { done: true, value: undefined }, + { done: true, value: undefined }, + ]); + expect(nativeOperation.acknowledge.mock.calls).toEqual([[1n, 1]]); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + }); + + it("does not interrupt a parked pull while another earlier pull remains queued", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(44); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okStreamingMeta())); + let push!: (chunk: Buffer) => void; + vi.mocked(ffi.runScriptTransformEngine).mockImplementation( + (_handle, _script, _inputs, _inputName, _mimeType, _charset, _readCb, writeCb) => { + push = sequencedCallback(writeCb); + return nativeOperation; + } + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(ffi.runScriptTransformEngine).toHaveBeenCalledTimes(1)); + const secondPull = transform.next(); + const returned = transform.return(undefined); + + expect(nativeOperation.cancel).not.toHaveBeenCalled(); + push(Buffer.from("a")); + + await expect(Promise.all([firstPull, secondPull, returned])).resolves.toEqual([ + { done: false, value: Buffer.from("a") }, + { done: true, value: undefined }, + { done: true, value: undefined }, + ]); + expect(nativeOperation.acknowledge.mock.calls).toEqual([[1n, 1]]); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + await dw.cleanup(); + }); + + it("throw during pending transform setup wins over setup rejection", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(29); + const readerGate = deferred>>(); + vi.mocked(createChunkReader).mockReturnValueOnce(readerGate.promise); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(createChunkReader).toHaveBeenCalledTimes(1)); + const thrown = new Error("consumer boom"); + const throwing = transform.throw(thrown); + readerGate.reject(new Error("setup boom")); + + await expect(firstPull).resolves.toEqual({ done: true, value: undefined }); + await expect(throwing).rejects.toBe(thrown); + await expect(transform.next()).resolves.toEqual({ done: true, value: undefined }); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("serializes next behind promised return assimilation during transform setup", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(30); + const readerGate = deferred>>(); + vi.mocked(createChunkReader).mockReturnValueOnce(readerGate.promise); + const returnGate = deferred(); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(createChunkReader).toHaveBeenCalledTimes(1)); + const returned = (transform.return as (value: unknown) => Promise>)(returnGate.promise); + const laterNext = transform.next(); + + readerGate.resolve(() => null); + await expect(firstPull).resolves.toEqual({ done: true, value: undefined }); + const stillPending = Symbol("still pending"); + expect(await Promise.race([ + laterNext.then(() => "settled"), + new Promise((resolve) => setImmediate(() => resolve(stillPending))), + ])).toBe(stillPending); + + returnGate.resolve({ success: true } as StreamingResult); + await expect(returned).resolves.toEqual({ done: true, value: { success: true } }); + await expect(laterNext).resolves.toEqual({ done: true, value: undefined }); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("does not fail cleanup when a canceled operation rejects", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(14); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + vi.mocked(ffi.runScriptStreamingEngine).mockReturnValue(nativeOperation); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const firstPull = dw.runStreaming("output application/json --- [1]").next(); + const firstPullOutcome = firstPull.catch((error) => error); + await vi.waitFor(() => expect(ffi.runScriptStreamingEngine).toHaveBeenCalledTimes(1)); + + const cleanupPromise = dw.cleanup(); + completion.reject(new Error("operation canceled")); + + await expect(cleanupPromise).resolves.toBeUndefined(); + await expect(firstPullOutcome).resolves.toEqual({ done: true, value: undefined }); + expect(ffi.destroyEngine).toHaveBeenCalledWith(14); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("does not let operation rejection mask a primary destroy error", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(15); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + vi.mocked(ffi.runScriptStreamingEngine).mockReturnValue(nativeOperation); + vi.mocked(ffi.destroyEngine).mockImplementation(() => { + throw new Error("destroy boom"); + }); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const firstPull = dw.runStreaming("output application/json --- [1]").next(); + const firstPullOutcome = firstPull.catch((error) => error); + await vi.waitFor(() => expect(ffi.runScriptStreamingEngine).toHaveBeenCalledTimes(1)); + + const cleanupPromise = dw.cleanup(); + completion.reject(new Error("operation canceled")); + + await expect(cleanupPromise).rejects.toThrow("destroy boom"); + await firstPullOutcome; + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("retains active ownership and retries close after close failure", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(20); + const nativeOperation = operation(Promise.resolve(okStreamingMeta())); + nativeOperation.close = vi.fn() + .mockImplementationOnce(() => { throw undefined; }) + .mockImplementationOnce(() => {}); + vi.mocked(ffi.runScriptStreamingEngine).mockReturnValue(nativeOperation); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const firstPull = dw.runStreaming("output application/json --- [1]").next(); + + const firstPullResult = await firstPull.then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + expect(firstPullResult).toEqual({ status: "rejected", reason: undefined }); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + await expect(dw.cleanup()).resolves.toBeUndefined(); + expect(nativeOperation.close).toHaveBeenCalledTimes(2); + expect(ffi.destroyEngine).toHaveBeenCalledWith(20); + }); + + it("does not wait on unresolved completion when cancellation throws undefined", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(25); + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => { throw undefined; }); + vi.mocked(ffi.runScriptStreamingEngine).mockReturnValue(nativeOperation); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const firstPull = dw.runStreaming("output application/json --- [1]").next(); + firstPull.catch(() => {}); + await vi.waitFor(() => expect(ffi.runScriptStreamingEngine).toHaveBeenCalledTimes(1)); + + const cleanupOutcome = dw.cleanup().then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + const stillPending = Symbol("still pending"); + const observed = await Promise.race([ + cleanupOutcome, + new Promise((resolve) => setImmediate(() => resolve(stillPending))), + ]); + + if (observed === stillPending) completion.resolve(okStreamingMeta()); + expect(observed).toEqual({ status: "rejected", reason: undefined }); + expect(ffi.destroyEngine).not.toHaveBeenCalled(); + }); + + it("surfaces destroyEngine throwing undefined after releasing the native reference", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(26); + vi.mocked(ffi.destroyEngine).mockImplementation(() => { throw undefined; }); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + + const cleanupResult = await dw.cleanup().then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + + expect(cleanupResult).toEqual({ status: "rejected", reason: undefined }); + expect(ffi.cleanup).toHaveBeenCalledTimes(1); + }); + }); + + describe("stale engine generation", () => { + const staleGenerationMessage = "DataWeave operation belongs to a stale engine generation."; + const expectStaleGenerationError = async (operation: Promise): Promise => { + let error: unknown; + try { + await operation; + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(DataWeaveError); + expect((error as DataWeaveError).message).toBe(staleGenerationMessage); + }; + + it("rejects a lazy runStreaming operation after replacement with a different handle before native admission", async () => { + vi.mocked(ffi.createEngine).mockReturnValueOnce(2).mockReturnValueOnce(3); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const stream = dw.runStreaming("output application/json --- [1, 2, 3]"); + + await dw.cleanup(); + dw.initialize(); + + const firstPull = stream.next(); + await expectStaleGenerationError(firstPull); + expect(ffi.runScriptStreamingEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("rejects a lazy runStreaming operation when the replacement reuses its handle before native admission", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(2); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const stream = dw.runStreaming("output application/json --- [1, 2, 3]"); + + await dw.cleanup(); + dw.initialize(); + + const firstPull = stream.next(); + await expectStaleGenerationError(firstPull); + expect(ffi.runScriptStreamingEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("rejects stale runStreaming work before input serialization can run", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(2); + let inputEnumerations = 0; + const inputs = new Proxy({ payload: 1 }, { + ownKeys() { + inputEnumerations++; + throw new Error("stale inputs must not be enumerated"); + }, + }); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const stream = dw.runStreaming("output application/json --- payload", inputs); + + await dw.cleanup(); + dw.initialize(); + + await expectStaleGenerationError(stream.next()); + expect(inputEnumerations).toBe(0); + expect(ffi.runScriptStreamingEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("revalidates runStreaming after input serialization before native admission", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(2); + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + + let inputReads = 0; + let invalidatingCleanup: Promise | undefined; + const inputs = {}; + Object.defineProperty(inputs, "payload", { + enumerable: true, + get() { + inputReads++; + invalidatingCleanup = dw.cleanup(); + return 1; + }, + }); + + const stream = dw.runStreaming("output application/json --- payload", inputs); + await expectStaleGenerationError(stream.next()); + + expect(inputReads).toBe(1); + expect(ffi.runScriptStreamingEngine).not.toHaveBeenCalled(); + await invalidatingCleanup; + }); + + it("rejects a lazy runTransform operation after replacement with a different handle before native admission", async () => { + vi.mocked(ffi.createEngine).mockReturnValueOnce(2).mockReturnValueOnce(3); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform( + "output application/json --- payload", + [Buffer.from("[1, 2, 3]")], + { mimeType: "application/json" } + ); + + await dw.cleanup(); + dw.initialize(); + + const firstPull = transform.next(); + await expectStaleGenerationError(firstPull); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("rejects a lazy runTransform operation when the replacement reuses its handle before native admission", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(2); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform( + "output application/json --- payload", + [Buffer.from("[1, 2, 3]")], + { mimeType: "application/json" } + ); + + await dw.cleanup(); + dw.initialize(); + + const firstPull = transform.next(); + await expectStaleGenerationError(firstPull); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("revalidates a runTransform operation after async input pre-buffering before native admission", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(2); + + let markInputStarted!: () => void; + const inputStarted = new Promise((resolve) => { markInputStarted = resolve; }); + let resumeInput!: () => void; + const inputPaused = new Promise((resolve) => { resumeInput = resolve; }); + async function* slowInput(): AsyncGenerator { + markInputStarted(); + await inputPaused; + yield Buffer.from("[1, 2, 3]"); + } + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform( + "output application/json --- payload", + slowInput(), + { mimeType: "application/json" } + ); + const firstPull = transform.next(); + await inputStarted; + + await dw.cleanup(); + dw.initialize(); + resumeInput(); + + await expectStaleGenerationError(firstPull); + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + + await dw.cleanup(); + }); + + it("revalidates runTransform at its actual lazy native admission boundary", async () => { + vi.mocked(ffi.createEngine).mockReturnValue(34); + const readerGate = deferred>>(); + vi.mocked(createChunkReader).mockReturnValueOnce(readerGate.promise); + vi.mocked(ffi.runScriptTransformEngine).mockReturnValue( + operation(Promise.resolve(okStreamingMeta())) + ); + + const dw = new DataWeave("/fake/lib"); + dw.initialize(); + const transform = dw.runTransform("output application/json --- payload", []); + const firstPull = transform.next(); + await vi.waitFor(() => expect(createChunkReader).toHaveBeenCalledTimes(1)); + + let boundaryCleanup: Promise | undefined; + readerGate.resolve(() => null); + queueMicrotask(() => { boundaryCleanup = dw.cleanup(); }); + + await expectStaleGenerationError(firstPull); + await boundaryCleanup; + expect(ffi.runScriptTransformEngine).not.toHaveBeenCalled(); + }); + }); }); diff --git a/native-lib/node/tests/unit/stream.test.ts b/native-lib/node/tests/unit/stream.test.ts index ab6e3580..d5da6a70 100644 --- a/native-lib/node/tests/unit/stream.test.ts +++ b/native-lib/node/tests/unit/stream.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { streamFromNative } from "../../src/stream"; +import type { NativeStreamingOperation } from "../../src/ffi"; import type { StreamingResult } from "../../src/types"; const okMeta = (extra: Record = {}) => @@ -25,42 +26,137 @@ function deferred() { return { promise, resolve, reject }; } +function operation(completion: Promise): NativeStreamingOperation { + return { + completion, + acknowledge: vi.fn(), + cancel: vi.fn(), + close: vi.fn(), + }; +} + +function deliver( + callback: (chunk: Buffer, sequence: bigint) => void, + chunk: Buffer, + sequence: bigint +): void { + callback(chunk, sequence); +} + describe("streamFromNative", () => { + it("preserves opaque sequence identity until dequeue while yielding only Buffer", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + let push!: (chunk: Buffer, sequence: bigint) => void; + const gen = streamFromNative((callback) => { + push = callback as unknown as typeof push; + return nativeOperation; + }); + + const pending = gen.next(); + push(Buffer.from("same"), 9007199254740993n); + expect(nativeOperation.acknowledge).not.toHaveBeenCalled(); + + const first = await pending; + expect(first).toEqual({ done: false, value: Buffer.from("same") }); + expect(Buffer.isBuffer(first.value)).toBe(true); + expect(nativeOperation.acknowledge).toHaveBeenCalledExactlyOnceWith( + 9007199254740993n, + 4 + ); + + completion.resolve(okMeta()); + await gen.next(); + }); + + it("returns each abandoned callback's exact sequence and bytes once", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okMeta())); + const gen = streamFromNative((callback) => { + deliver(callback, Buffer.from("x"), 41n); + deliver(callback, Buffer.from("x"), 42n); + deliver(callback, Buffer.from("yy"), 43n); + return nativeOperation; + }); + + const first = await gen.next(); + expect(first.value).toEqual(Buffer.from("x")); + await gen.return(undefined); + + expect(nativeOperation.acknowledge.mock.calls).toEqual([ + [41n, 1], + [42n, 1], + [43n, 2], + ]); + }); + it("yields chunks pushed before completion, in order", async () => { + const nativeOperation = operation(Promise.resolve(okMeta())); const { chunks, result } = await collect( streamFromNative((cb) => { - cb(Buffer.from("a")); - cb(Buffer.from("b")); - cb(Buffer.from("c")); - return Promise.resolve(okMeta()); + deliver(cb, Buffer.from("a"), 1n); + deliver(cb, Buffer.from("b"), 2n); + deliver(cb, Buffer.from("c"), 3n); + return nativeOperation; }) ); expect(chunks.map((c) => c.toString())).toEqual(["a", "b", "c"]); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(1, 1n, 1); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(2, 2n, 1); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(3, 3n, 1); expect(result.success).toBe(true); expect(result.mimeType).toBe("application/json"); + expect(nativeOperation.cancel).not.toHaveBeenCalled(); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); }); it("returns success metadata with no chunks", async () => { - const { chunks, result } = await collect(streamFromNative(() => Promise.resolve(okMeta()))); + const nativeOperation = operation(Promise.resolve(okMeta())); + const { chunks, result } = await collect(streamFromNative(() => nativeOperation)); expect(chunks).toEqual([]); expect(result.success).toBe(true); + expect(nativeOperation.acknowledge).not.toHaveBeenCalled(); + expect(nativeOperation.cancel).not.toHaveBeenCalled(); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); }); - it("parks the consumer until a chunk arrives, then wakes it (backpressure)", async () => { + it("propagates a synchronous start failure without registering an operation", async () => { + const start = vi.fn((): NativeStreamingOperation => { + throw new Error("start boom"); + }); + const onStart = vi.fn(); + const gen = streamFromNative(start, onStart); + + expect(start).not.toHaveBeenCalled(); + await expect(gen.next()).rejects.toThrow("start boom"); + + expect(start).toHaveBeenCalledTimes(1); + expect(onStart).not.toHaveBeenCalled(); + await expect(gen.return(undefined)).resolves.toEqual({ done: true, value: undefined }); + expect(start).toHaveBeenCalledTimes(1); + }); + + it("parks the consumer until a chunk arrives, then wakes it", async () => { const meta = deferred(); - let push!: (chunk: Buffer) => void; + const nativeOperation = operation(meta.promise); + let push!: (chunk: Buffer, sequence: bigint) => void; const gen = streamFromNative((cb) => { push = cb; - return meta.promise; + return nativeOperation; }); // First pull starts the generator and parks — no chunk is ready yet. const pending = gen.next(); // Producing a chunk should wake the parked consumer. - push(Buffer.from("late")); + push(Buffer.from("late"), 1n); + // Callback enqueue alone does not return native credit. + expect(nativeOperation.acknowledge).not.toHaveBeenCalled(); const first = await pending; expect(first.done).toBe(false); expect(first.value!.toString()).toBe("late"); + expect(nativeOperation.acknowledge).toHaveBeenCalledTimes(1); + expect(nativeOperation.acknowledge).toHaveBeenCalledWith(1n, 4); // Completing the stream ends the generator with the parsed metadata. meta.resolve(okMeta({ mimeType: "text/plain" })); @@ -69,22 +165,73 @@ describe("streamFromNative", () => { expect((last.value as StreamingResult).mimeType).toBe("text/plain"); }); + it("return cancels and settles a pending first pull", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okMeta())); + const gen = streamFromNative(() => nativeOperation); + + let firstPullSettled = false; + const firstPull = gen.next().finally(() => { firstPullSettled = true; }); + await vi.waitFor(() => expect(nativeOperation.cancel).not.toHaveBeenCalled()); + expect(firstPullSettled).toBe(false); + + const returned = gen.return(undefined); + await Promise.resolve(); + const cancelCallsAfterReturn = vi.mocked(nativeOperation.cancel).mock.calls.length; + // Let the pre-fix serialized async generator settle so RED does not leave + // dangling promises after recording that return() could not cancel it. + if (cancelCallsAfterReturn === 0) completion.resolve(okMeta()); + + const [firstResult, returnResult] = await Promise.allSettled([firstPull, returned]); + expect(cancelCallsAfterReturn).toBe(1); + expect(firstResult.status).toBe("fulfilled"); + expect(returnResult).toEqual({ + status: "fulfilled", + value: { done: true, value: undefined }, + }); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + it("drains chunks that arrive together with completion", async () => { + const nativeOperation = operation(Promise.resolve(okMeta())); const { chunks, result } = await collect( streamFromNative((cb) => { // Chunks buffered but not yet consumed when the native call resolves. - cb(Buffer.from("x")); - cb(Buffer.from("y")); - return Promise.resolve(okMeta()); + deliver(cb, Buffer.from("x"), 1n); + deliver(cb, Buffer.from("yy"), 2n); + return nativeOperation; }) ); - expect(chunks.map((c) => c.toString())).toEqual(["x", "y"]); + expect(chunks.map((c) => c.toString())).toEqual(["x", "yy"]); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(1, 1n, 1); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(2, 2n, 2); + expect(nativeOperation.acknowledge).toHaveBeenCalledTimes(2); expect(result.success).toBe(true); }); + it("acknowledges a late callback after completion and close", async () => { + const nativeOperation = operation(Promise.resolve(okMeta())); + let push!: (chunk: Buffer, sequence: bigint) => void; + const gen = streamFromNative((cb) => { + push = cb; + return nativeOperation; + }); + + await collect(gen); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + + push(Buffer.from("late"), 1n); + expect(nativeOperation.acknowledge).toHaveBeenCalledTimes(1); + expect(nativeOperation.acknowledge).toHaveBeenCalledWith(1n, 4); + }); + it("propagates a failure envelope as the terminal result", async () => { + const nativeOperation = operation( + Promise.resolve(JSON.stringify({ success: false, error: "stream boom" })) + ); const { chunks, result } = await collect( - streamFromNative(() => Promise.resolve(JSON.stringify({ success: false, error: "stream boom" }))) + streamFromNative(() => nativeOperation) ); expect(chunks).toEqual([]); expect(result.success).toBe(false); @@ -92,14 +239,15 @@ describe("streamFromNative", () => { }); it("treats empty terminal metadata as a failure", async () => { - const { result } = await collect(streamFromNative(() => Promise.resolve(""))); + const { result } = await collect(streamFromNative(() => operation(Promise.resolve("")))); 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); + const nativeOperation = operation(startGate.promise); + const gen = streamFromNative(() => nativeOperation); // Park a consumer in next() BEFORE the start promise settles: no chunk is // ready and done is false, so next() awaits on pendingResolves. @@ -110,29 +258,129 @@ describe("streamFromNative", () => { startGate.reject(new Error("native start boom")); await expect(pending).rejects.toThrow("native start boom"); + expect(nativeOperation.cancel).not.toHaveBeenCalled(); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); }); it("drains buffered chunks, then throws, when start() rejects after pushing chunks", async () => { + const nativeOperation = operation(Promise.reject(new Error("late boom"))); const gen = streamFromNative((cb) => { - cb(Buffer.from("x")); - cb(Buffer.from("y")); - return Promise.reject(new Error("late boom")); + deliver(cb, Buffer.from("x"), 1n); + deliver(cb, Buffer.from("yy"), 2n); + return nativeOperation; }); // Buffered chunks yield first... const a = await gen.next(); const b = await gen.next(); - expect([a.value?.toString(), b.value?.toString()]).toEqual(["x", "y"]); + expect([a.value?.toString(), b.value?.toString()]).toEqual(["x", "yy"]); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(1, 1n, 1); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(2, 2n, 2); // ...then the drained generator surfaces the start error. await expect(gen.next()).rejects.toThrow("late boom"); + expect(nativeOperation.cancel).not.toHaveBeenCalled(); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("preserves native rejection when close also throws", async () => { + const nativeOperation = operation(Promise.reject(new Error("native boom"))); + nativeOperation.close = vi.fn(() => { throw new Error("close boom"); }); + const gen = streamFromNative(() => nativeOperation); + + await expect(gen.next()).rejects.toThrow("native boom"); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("surfaces close failure when completion has no primary error", async () => { + const nativeOperation = operation(Promise.resolve(okMeta())); + nativeOperation.close = vi.fn(() => { throw new Error("close boom"); }); + const gen = streamFromNative(() => nativeOperation); + + await expect(gen.next()).rejects.toThrow("close boom"); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("retries only close finalization tracking after native close succeeds", async () => { + const nativeOperation = operation(Promise.resolve(okMeta())); + const onClose = vi.fn() + .mockImplementationOnce(() => { throw undefined; }) + .mockImplementationOnce(() => {}); + let managedOperation!: NativeStreamingOperation; + const gen = streamFromNative( + () => nativeOperation, + (started) => { managedOperation = started; }, + onClose + ); + + const next = await gen.next().then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + expect(next).toEqual({ status: "rejected", reason: undefined }); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + + managedOperation.close(); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it.each([ + { label: "undefined", thrown: undefined }, + { label: "null", thrown: null }, + ])("treats cancel throwing $label as a retryable lifecycle failure", async ({ thrown }) => { + const nativeOperation = operation(new Promise(() => {})); + nativeOperation.cancel = vi.fn(() => { throw thrown; }); + const gen = streamFromNative((cb) => { + deliver(cb, Buffer.from("x"), 1n); + return nativeOperation; + }); + + await gen.next(); + const returned = await gen.return(undefined).then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + + expect(returned).toEqual({ status: "rejected", reason: thrown }); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(2); + expect(nativeOperation.close).not.toHaveBeenCalled(); + }); + + it("treats close throwing undefined as a lifecycle failure without a primary error", async () => { + const nativeOperation = operation(Promise.resolve(okMeta())); + nativeOperation.close = vi.fn(() => { throw undefined; }); + const gen = streamFromNative(() => nativeOperation); + + const next = await gen.next().then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + + expect(next).toEqual({ status: "rejected", reason: undefined }); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("preserves null native rejection when close throws undefined", async () => { + const nativeOperation = operation(Promise.reject(null)); + nativeOperation.close = vi.fn(() => { throw undefined; }); + const gen = streamFromNative(() => nativeOperation); + + const next = await gen.next().then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + + expect(next).toEqual({ status: "rejected", reason: null }); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); }); 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)); + const gen = streamFromNative(() => operation(Promise.reject(undefined))); await expect( (async () => { // Drain fully: iterate to completion so the post-drain re-throw runs. @@ -140,4 +388,263 @@ describe("streamFromNative", () => { })() ).rejects.toBeUndefined(); }); -}); \ No newline at end of file + + it("acknowledges abandoned buffered chunks, cancels, and closes on generator return", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + const gen = streamFromNative((cb) => { + deliver(cb, Buffer.from("x"), 1n); + deliver(cb, Buffer.from("yy"), 2n); + return nativeOperation; + }); + + const first = await gen.next(); + expect(first.value?.toString()).toBe("x"); + expect(nativeOperation.acknowledge).toHaveBeenCalledWith(1n, 1); + + await expect(gen.return(undefined)).resolves.toEqual({ done: true, value: undefined }); + expect(nativeOperation.acknowledge).toHaveBeenNthCalledWith(2, 2n, 2); + expect(nativeOperation.acknowledge).toHaveBeenCalledTimes(2); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("keeps cancel and close idempotent when cancellation settles native completion", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okMeta())); + const gen = streamFromNative((cb) => { + deliver(cb, Buffer.from("x"), 1n); + return nativeOperation; + }); + + await gen.next(); + await gen.return(undefined); + await gen.return(undefined); + await gen.next(); + + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("closes once when registration finalization competes with generator finalization", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + const gen = streamFromNative( + (cb) => { + deliver(cb, Buffer.from("x"), 1n); + return nativeOperation; + }, + (managedOperation) => managedOperation.cancel() + ); + + const pending = gen.next(); + completion.resolve(okMeta()); + await expect(pending).resolves.toEqual({ done: true, value: undefined }); + + expect(nativeOperation.acknowledge).toHaveBeenCalledWith(1n, 1); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("does not cancel when native completion wins before finalization", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + const gen = streamFromNative((cb) => { + deliver(cb, Buffer.from("x"), 1n); + return nativeOperation; + }); + + await gen.next(); + completion.resolve(okMeta()); + await Promise.resolve(); + await gen.return(undefined); + + expect(nativeOperation.cancel).not.toHaveBeenCalled(); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("retries cancel once without closing an operation whose cancellation failed", async () => { + const nativeOperation = operation(new Promise(() => {})); + nativeOperation.cancel = vi.fn(() => { + throw new Error("cancel boom"); + }); + const gen = streamFromNative((cb) => { + deliver(cb, Buffer.from("x"), 1n); + return nativeOperation; + }); + + await gen.next(); + await expect(gen.return(undefined)).rejects.toThrow("cancel boom"); + + expect(nativeOperation.cancel).toHaveBeenCalledTimes(2); + expect(nativeOperation.close).not.toHaveBeenCalled(); + }); + + it("preserves the first cancellation failure when the finalization retry also fails", async () => { + const nativeOperation = operation(new Promise(() => {})); + nativeOperation.cancel = vi.fn() + .mockImplementationOnce(() => { throw undefined; }) + .mockImplementationOnce(() => { throw null; }); + const gen = streamFromNative((cb) => { + deliver(cb, Buffer.from("x"), 1n); + return nativeOperation; + }); + + await gen.next(); + const returned = await gen.return(undefined).then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + + expect(returned).toEqual({ status: "rejected", reason: undefined }); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(2); + expect(nativeOperation.close).not.toHaveBeenCalled(); + }); + + it("preserves a consumer error when close also throws", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okMeta())); + nativeOperation.close = vi.fn(() => { throw new Error("close boom"); }); + const gen = streamFromNative((cb) => { + deliver(cb, Buffer.from("x"), 1n); + return nativeOperation; + }); + + await gen.next(); + await expect(gen.throw(new Error("consumer boom"))).rejects.toThrow("consumer boom"); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(2); + }); + + it("still cancels and closes when abandoned-chunk acknowledgement throws", async () => { + const nativeOperation = operation(new Promise(() => {})); + vi.mocked(nativeOperation.acknowledge) + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => { + throw new Error("ack boom"); + }); + const gen = streamFromNative((cb) => { + deliver(cb, Buffer.from("x"), 1n); + deliver(cb, Buffer.from("y"), 2n); + return nativeOperation; + }); + + await gen.next(); + await expect(gen.return(undefined)).rejects.toThrow("ack boom"); + + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("still reaches native cancellation when external cancellation credit return throws", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + vi.mocked(nativeOperation.acknowledge).mockImplementation(() => { + throw new Error("ack boom"); + }); + nativeOperation.cancel = vi.fn(() => completion.resolve(okMeta())); + let managedOperation!: NativeStreamingOperation; + let push!: (chunk: Buffer, sequence: bigint) => void; + const gen = streamFromNative( + (cb) => { + push = cb; + return nativeOperation; + }, + (started) => { managedOperation = started; } + ); + const firstPull = gen.next(); + await vi.waitFor(() => expect(managedOperation).toBeDefined()); + push(Buffer.from("x"), 1n); + + expect(() => managedOperation.cancel()).toThrow("ack boom"); + + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + await expect(firstPull).resolves.toEqual({ done: true, value: undefined }); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("cancels and closes when operation registration throws", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okMeta())); + const gen = streamFromNative( + () => nativeOperation, + () => { throw new Error("registration boom"); } + ); + + await expect(gen.next()).rejects.toThrow("registration boom"); + + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("preserves registration failure when cancel and close also throw", async () => { + const nativeOperation = operation(new Promise(() => {})); + nativeOperation.cancel = vi.fn(() => { throw new Error("cancel boom"); }); + nativeOperation.close = vi.fn(() => { throw new Error("close boom"); }); + const gen = streamFromNative( + () => nativeOperation, + () => { throw new Error("registration boom"); } + ); + + await expect(gen.next()).rejects.toThrow("registration boom"); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).not.toHaveBeenCalled(); + }); + + it("preserves registration failure when successful cancel is followed by close failure", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okMeta())); + nativeOperation.close = vi.fn(() => { throw new Error("close boom"); }); + const gen = streamFromNative( + () => nativeOperation, + () => { throw new Error("registration boom"); } + ); + + await expect(gen.next()).rejects.toThrow("registration boom"); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("preserves undefined registration failure over a null close failure", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okMeta())); + nativeOperation.close = vi.fn(() => { throw null; }); + const gen = streamFromNative( + () => nativeOperation, + () => { throw undefined; } + ); + + const next = await gen.next().then( + () => ({ status: "fulfilled" as const }), + (reason: unknown) => ({ status: "rejected" as const, reason }) + ); + + expect(next).toEqual({ status: "rejected", reason: undefined }); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(1); + }); + + it("preserves a for-await body error when close also throws", async () => { + const completion = deferred(); + const nativeOperation = operation(completion.promise); + nativeOperation.cancel = vi.fn(() => completion.resolve(okMeta())); + nativeOperation.close = vi.fn(() => { throw new Error("close boom"); }); + const gen = streamFromNative((cb) => { + deliver(cb, Buffer.from("x"), 1n); + return nativeOperation; + }); + + await expect((async () => { + for await (const _chunk of gen) { + throw new Error("body boom"); + } + })()).rejects.toThrow("body boom"); + expect(nativeOperation.cancel).toHaveBeenCalledTimes(1); + expect(nativeOperation.close).toHaveBeenCalledTimes(2); + }); +}); diff --git a/native-lib/node/vitest.config.ts b/native-lib/node/vitest.config.ts index 9d0b7fc2..34340b59 100644 --- a/native-lib/node/vitest.config.ts +++ b/native-lib/node/vitest.config.ts @@ -26,8 +26,11 @@ export default defineConfig({ 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; + // __test_resolverRefDeleteCount and detach-poison fault/counter hooks, + // including detach-publication barriers, bridge finalization counters, + // identity-boundary hooks, and the engine-record allocation failure + // hook). Set before any + // integration worker loads the addon, so its Init() getenv() sees it; // 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" }, diff --git a/native-lib/python/README.md b/native-lib/python/README.md index 3e776559..762e825b 100644 --- a/native-lib/python/README.md +++ b/native-lib/python/README.md @@ -222,6 +222,23 @@ callback until its engine is destroyed, then releases callback references during `cleanup()`. Different live instances can therefore use different resolvers. +### Callback and stream lifecycle + +Resolver, read, and write callbacks must not call DataWeave lifecycle or +execution APIs on the same thread. The binding rejects that reentry with the +public `DataWeaveError` message `DataWeave lifecycle and execution are not +allowed from a native callback on the same thread.` rather than recursively +entering the native runtime. + +Streaming and transform work captures its initialized `{handle, generation}` +operation identity. Cleanup or reinitialization before a captured operation is +consumed or registered rejects it with `DataWeaveError: DataWeave operation +belongs to a stale engine generation.`; it never runs on the replacement +engine. Cleanup differs from Node: it refuses with `DataWeaveError` while an +active streaming worker is attached. Safe worker registration validates the +captured operation while holding the worker registry lock, so cleanup cannot +admit work for an old generation. + ### Custom module resolution scope - A `resolve_module` you configure applies to `run()`. @@ -274,9 +291,9 @@ print(f"\nDone: {metadata.mime_type}, {metadata.charset}") Call `stream.close()` when stopping consumption early. `Stream` also supports a context manager, as above. Closing requests cancellation and waits only briefly -for the native worker. A native call cannot be forcibly cancelled by Python, so -an unresponsive call is left to finish in a daemon worker rather than delaying -application shutdown or raising during finalization. +for the daemon worker. A native call cannot be forcibly cancelled by Python; +`DataWeave.cleanup()` instead refuses while an active streaming worker remains +attached, preventing destruction of its engine until the worker unregisters. Or with explicit context: diff --git a/native-lib/python/src/dataweave/__init__.py b/native-lib/python/src/dataweave/__init__.py index 5168ab93..91cb802e 100644 --- a/native-lib/python/src/dataweave/__init__.py +++ b/native-lib/python/src/dataweave/__init__.py @@ -24,6 +24,7 @@ ) from .native import candidate_library_paths as _candidate_library_paths from .native import find_library as _find_library +from .native import _raise_if_native_callback_active from .resolver import ( ModuleResolver, compose_resolvers, @@ -40,6 +41,7 @@ def _get_global_instance() -> DataWeave: global _global_instance + _raise_if_native_callback_active() with _global_lock: if _global_instance is None: import atexit @@ -72,6 +74,7 @@ def run_input_output_callback(script: str, input_name: str, input_mime_type: str def cleanup() -> None: global _global_instance + _raise_if_native_callback_active() with _global_lock: if _global_instance is not None: instance, _global_instance = _global_instance, None diff --git a/native-lib/python/src/dataweave/native.py b/native-lib/python/src/dataweave/native.py index afacd88d..3c89bb63 100644 --- a/native-lib/python/src/dataweave/native.py +++ b/native-lib/python/src/dataweave/native.py @@ -1,9 +1,10 @@ import ctypes from contextlib import contextmanager +from dataclasses import dataclass import os from pathlib import Path import sys -from threading import get_ident, Lock +from threading import Condition, get_ident, local, Lock import traceback from typing import Optional @@ -12,6 +13,27 @@ _ENV_NATIVE_LIB = "DATAWEAVE_NATIVE_LIB" +_native_callback_state = local() + + +def _raise_if_native_callback_active() -> None: + if getattr(_native_callback_state, "depth", 0) > 0: + raise DataWeaveError( + "DataWeave lifecycle and execution are not allowed from a native callback on the same thread." + ) + + +@contextmanager +def _native_callback_scope(): + previous = getattr(_native_callback_state, "depth", 0) + _native_callback_state.depth = previous + 1 + try: + yield + finally: + if previous == 0: + del _native_callback_state.depth + else: + _native_callback_state.depth = previous class graal_isolate_t(ctypes.Structure): @@ -341,6 +363,12 @@ def find_library() -> str: ) +@dataclass(frozen=True) +class _EngineOperation: + handle: int + generation: int + + class NativeRuntime: """Owns the native library handle, isolate lifecycle, and ctypes ABI.""" @@ -351,6 +379,8 @@ def __init__(self, lib_path: Optional[str] = None): self.thread = None self.handle = 0 self.initialized = False + self._generation = 0 + self._engine_operation = None # Every engine supports every API now (single unified ABI). self.has_callback_streaming = True self.has_callback_input_output = True @@ -361,7 +391,8 @@ def __init__(self, lib_path: Optional[str] = None): self._resolver_buffers = [] self._resolver_active = False self._resolver_active_ident = None - self._resolver_lock = Lock() + self._operation_lock = Condition(Lock()) + self._operation_active = False self._execution_owner = None # Guards this instance's initialize()/cleanup() lifecycle transitions # (the initialized-check -> acquire -> create-engine -> publish @@ -373,35 +404,38 @@ def __init__(self, lib_path: Optional[str] = None): self._init_lock = Lock() def initialize(self) -> None: - if self.initialized: - return - 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 + _raise_if_native_callback_active() + with self._serialized_native_operation(): + with self._init_lock: + if self.initialized: + return + acquired = False + try: + self.lib, self.isolate = _acquire_isolate(self.lib_path) + acquired = True + 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.handle = handle + self._generation += 1 + self._engine_operation = _EngineOperation(handle, self._generation) + self.initialized = True def _create_engine(self) -> int: with self._current_thread_attachment(self.thread) as thread: @@ -424,6 +458,7 @@ def _create_engine(self) -> int: return handle def attach_thread(self): + _raise_if_native_callback_active() worker_thread = GraalIsolateThreadPointer() try: result = self.lib.graal_attach_thread(self.isolate, ctypes.byref(worker_thread)) @@ -434,6 +469,7 @@ def attach_thread(self): return worker_thread def detach_thread(self, thread) -> None: + _raise_if_native_callback_active() try: result = self.lib.graal_detach_thread(thread) except Exception as error: @@ -457,42 +493,57 @@ def decode_and_free(self, ptr, thread=None) -> str: except Exception: if primary_error is None: raise - - def run_engine_and_decode(self, script: bytes, inputs: bytes) -> str: - with self._serialized_native_operation(): + def run_engine_and_decode(self, script: bytes, inputs: bytes, operation: _EngineOperation) -> str: + with self._serialized_native_operation(operation) as operation_lock: with self._current_thread_attachment(self.thread) as thread: with self._resolver_scope(): + try: + operation_lock.release() + return self.decode_and_free( + self.lib.run_script_engine(thread, operation.handle, script, inputs), + thread, + ) + finally: + operation_lock.acquire() + + def run_callback_engine_and_decode( + self, thread, script: bytes, inputs: bytes, write_callback, operation: _EngineOperation, + ) -> str: + with self._serialized_native_operation(operation) as operation_lock: + with self._current_thread_attachment(thread) as current: + try: + operation_lock.release() return self.decode_and_free( - self.lib.run_script_engine(thread, self.handle, script, inputs), - thread, + self.lib.run_script_callback_engine( + current, operation.handle, script, inputs, write_callback, None + ), + current, ) - - def run_callback_engine_and_decode(self, thread, script: bytes, inputs: bytes, write_callback) -> str: - with self._serialized_native_operation(): - 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, - ) + finally: + operation_lock.acquire() 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, + input_mime_type: bytes, input_charset: Optional[bytes], read_callback, + write_callback, operation: _EngineOperation, ) -> str: - with self._serialized_native_operation(): + with self._serialized_native_operation(operation) as operation_lock: with self._current_thread_attachment(thread) as current: - return self.decode_and_free( - 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, - ) + try: + operation_lock.release() + return self.decode_and_free( + self.lib.run_script_input_output_callback_engine( + current, operation.handle, script, inputs, input_name, + input_mime_type, input_charset, read_callback, write_callback, None, + ), + current, + ) + finally: + operation_lock.acquire() def install_resolver(self, resolver: ModuleResolver) -> None: """Binds a module resolver to this engine. Must be called before initialize().""" + _raise_if_native_callback_active() if self.initialized: raise DataWeaveError("Cannot install a resolver after initialize().") self._resolver = resolver @@ -517,7 +568,8 @@ def resolve(_thread, _ctx, module_path): path = module_path.decode("utf-8") if path.startswith("/"): path = path[1:] - source = entry._resolver(path) + with _native_callback_scope(): + source = entry._resolver(path) if not isinstance(source, str): return None buffer = ctypes.create_string_buffer(source.encode("utf-8")) @@ -550,13 +602,12 @@ def _resolver_scope(self): self._resolver_buffers = [] def cleanup(self) -> None: + _raise_if_native_callback_active() with self._serialized_native_operation(): - # _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. + # _init_lock is nested INSIDE _operation_lock in both initialize() + # and cleanup(), so lifecycle transitions use one lock order. Only + # the initialized/token clearing needs _init_lock; destroy/release + # stays 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__. @@ -565,11 +616,13 @@ def cleanup(self) -> None: with self._init_lock: if not self.initialized: return + operation = self._engine_operation self.initialized = False + self._engine_operation = None try: - if self.handle: + if operation is not None: with self._current_thread_attachment(self.thread) as thread: - self.lib.destroy_engine(thread, self.handle) + self.lib.destroy_engine(thread, operation.handle) finally: # Release the isolate ref even if destroy_engine throws, so a # throwing destroy cannot strand the isolate. @@ -586,18 +639,50 @@ def cleanup(self) -> None: _release_isolate() @contextmanager - def _serialized_native_operation(self): + def _serialized_native_operation(self, expected: Optional[_EngineOperation] = None): + _raise_if_native_callback_active() owner = get_ident() if getattr(self, "_execution_owner", None) == owner: raise DataWeaveError("Reentrant DataWeave execution is not supported.") - if not hasattr(self, "_resolver_lock"): - self._resolver_lock = Lock() - with self._resolver_lock: + if not hasattr(self, "_operation_lock"): + self._operation_lock = Condition(Lock()) + self._operation_active = False + with self._operation_lock: + while getattr(self, "_operation_active", False): + self._operation_lock.wait() + if expected is not None: + self._validate_operation_locked(expected) + self._operation_active = True self._execution_owner = owner try: - yield + yield self._operation_lock finally: self._execution_owner = None + self._operation_active = False + self._operation_lock.notify_all() + + def _validate_operation_locked(self, expected: _EngineOperation) -> None: + if self._engine_operation != expected: + raise DataWeaveError("DataWeave operation belongs to a stale engine generation.") + + def validate_operation(self, expected: _EngineOperation) -> None: + _raise_if_native_callback_active() + if not hasattr(self, "_operation_lock"): + self._operation_lock = Condition(Lock()) + self._operation_active = False + with self._operation_lock: + self._validate_operation_locked(expected) + + def capture_operation(self) -> _EngineOperation: + _raise_if_native_callback_active() + if not hasattr(self, "_operation_lock"): + self._operation_lock = Condition(Lock()) + self._operation_active = False + with self._operation_lock: + operation = self._engine_operation + if not self.initialized or operation is None: + raise DataWeaveError("DataWeave runtime not initialized. Call initialize() first.") + return operation @contextmanager def _current_thread_attachment(self, thread): @@ -621,4 +706,3 @@ def _current_thread_attachment(self, thread): except Exception: if primary_error is None: raise - diff --git a/native-lib/python/src/dataweave/runtime.py b/native-lib/python/src/dataweave/runtime.py index 377c60f1..e23e5be7 100644 --- a/native-lib/python/src/dataweave/runtime.py +++ b/native-lib/python/src/dataweave/runtime.py @@ -16,7 +16,7 @@ StreamingResult, WriteCallback, ) -from .native import NativeRuntime +from .native import _native_callback_scope, _raise_if_native_callback_active, NativeRuntime from .resolver import ModuleResolver @@ -42,6 +42,7 @@ def __init__( self._lifecycle_lock = Lock() def initialize(self): + _raise_if_native_callback_active() # 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 @@ -58,6 +59,7 @@ def initialize(self): self._native.initialize() def cleanup(self): + _raise_if_native_callback_active() # Symmetric with initialize(): install_resolver() and # NativeRuntime.cleanup() both mutate the module-global resolver # registry, so cleanup() takes the same instance-level lock. @@ -84,11 +86,12 @@ def _worker_registry(self): self._stream_workers_lock = Lock() return self._stream_workers, self._stream_workers_lock - def _register_stream_worker(self, worker: Thread) -> None: + def _register_stream_worker(self, worker: Thread, operation) -> None: workers, lock = self._worker_registry() with lock: if getattr(self, "_cleaning_up", False): raise DataWeaveError("Cannot start a streaming worker while the DataWeave runtime is being cleaned up.") + self._native.validate_operation(operation) workers.add(worker) def _unregister_stream_worker(self, worker: Optional[Thread] = None) -> None: @@ -96,21 +99,22 @@ def _unregister_stream_worker(self, worker: Optional[Thread] = None) -> None: with lock: workers.discard(worker or current_thread()) - def _require_initialized(self, supported: bool, api_name: str) -> None: - if not self._native.initialized: - raise DataWeaveError("DataWeave runtime not initialized. Call initialize() first.") + def _require_initialized(self, supported: bool, api_name: str): + _raise_if_native_callback_active() + operation = self._native.capture_operation() if not supported: raise DataWeaveError(f"Native library does not support {api_name}.") + return operation @staticmethod def _inputs_json(inputs: Optional[Dict[str, Any]]) -> bytes: return json.dumps({key: normalize_input_value(value) for key, value in (inputs or {}).items()}).encode("utf-8") def run(self, script: str, inputs: Optional[Dict[str, Any]] = None, raise_on_error: bool = False) -> ExecutionResult: - self._require_initialized(True, "script execution") + operation = self._require_initialized(True, "script execution") try: raw = self._native.run_engine_and_decode( - script.encode("utf-8"), self._inputs_json(inputs) + script.encode("utf-8"), self._inputs_json(inputs), operation=operation ) result = parse_native_encoded_response(raw) except Exception as error: @@ -120,20 +124,23 @@ def run(self, script: str, inputs: Optional[Dict[str, Any]] = None, raise_on_err return result def run_callback(self, script: str, write_callback: WriteCallback, inputs: Optional[Dict[str, Any]] = None) -> StreamingResult: - self._require_initialized(self._native.has_callback_streaming, "callback streaming API (run_script_callback not found)") + operation = self._require_initialized(self._native.has_callback_streaming, "callback streaming API (run_script_callback not found)") @WRITE_CALLBACK def write_cb(_context, buffer, length): try: - return write_callback(ctypes.string_at(buffer, length)) - except Exception: + data = ctypes.string_at(buffer, length) + with _native_callback_scope(): + return ctypes.c_int(write_callback(data)).value + except BaseException: return -1 try: - raw = self._native.run_callback_engine_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, operation=operation) 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}") - def _stream_worker(self, invoke, cancelled: Event) -> Generator[bytes, None, StreamingResult]: + def _stream_worker(self, operation, invoke, cancelled: Event) -> Generator[bytes, None, StreamingResult]: + _raise_if_native_callback_active() sentinel = object() queue: Queue = Queue(maxsize=_OUTPUT_QUEUE_MAXSIZE) @@ -156,7 +163,7 @@ def write_cb(_context, buffer, length): return -1 queue.put(ctypes.string_at(buffer, length), timeout=_WORKER_TIMEOUT_SECONDS) return 0 - except Exception: + except BaseException: return -1 def worker_main(): @@ -185,7 +192,7 @@ def worker_main(): # Python cannot cancel a native call. Daemon workers keep an abandoned # call from extending interpreter lifetime after bounded cancellation. worker = Thread(target=worker_main, name="dw-streaming-worker", daemon=True) - self._register_stream_worker(worker) + self._register_stream_worker(worker, operation) try: worker.start() except Exception: @@ -214,10 +221,10 @@ def worker_main(): return parse_streaming_result(metadata or {"success": False, "error": "No metadata received from native call"}) def run_streaming(self, script: str, inputs: Optional[Dict[str, Any]] = None) -> Stream: - self._require_initialized(self._native.has_callback_streaming, "callback streaming API (run_script_callback not found)") + operation = 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_callback_engine_and_decode(thread, script.encode("utf-8"), encoded_inputs, write_cb), cancelled)) + stream = Stream(self._stream_worker(operation, lambda thread, write_cb: self._native.run_callback_engine_and_decode(thread, script.encode("utf-8"), encoded_inputs, write_cb, operation=operation), cancelled)) stream._on_close = cancelled.set stream._cancelled = cancelled return stream @@ -238,50 +245,54 @@ def read_cb(_context, buffer, buffer_size): return size if state["done"]: return 0 - chunk = next(iterator, None) + with _native_callback_scope(): + chunk = next(iterator, None) if not chunk: state["done"] = True return 0 state["chunk"] = chunk state["offset"] = 0 - except Exception: + except BaseException: return -1 return read_cb def run_transform(self, script: str, input_stream: Iterable[bytes], input_name: str = "payload", input_mime_type: str = "application/json", input_charset: Optional[str] = None, inputs: Optional[Dict[str, Any]] = None) -> Stream: - self._require_initialized(self._native.has_callback_input_output, "callback input/output API (run_script_input_output_callback not found)") + operation = self._require_initialized(self._native.has_callback_input_output, "callback input/output API (run_script_input_output_callback not found)") cancelled = Event() read_cb = self._chunk_reader(input_stream) encoded_inputs = self._inputs_json(inputs) def invoke(thread, 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)) + 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, operation=operation) + stream = Stream(self._stream_worker(operation, invoke, cancelled)) stream._on_close = cancelled.set stream._cancelled = cancelled return stream def run_input_output_callback(self, script: str, input_name: str, input_mime_type: str, read_callback: ReadCallback, write_callback: WriteCallback, input_charset: Optional[str] = None, inputs: Optional[Dict[str, Any]] = None) -> StreamingResult: - self._require_initialized(self._native.has_callback_input_output, "callback input/output API (run_script_input_output_callback not found)") + operation = self._require_initialized(self._native.has_callback_input_output, "callback input/output API (run_script_input_output_callback not found)") @READ_CALLBACK def read_cb(_context, buffer, buffer_size): try: - data = read_callback(buffer_size) + with _native_callback_scope(): + data = read_callback(buffer_size) if not data: return 0 if len(data) > buffer_size: return -1 ctypes.memmove(buffer, data, len(data)) return len(data) - except Exception: + except BaseException: return -1 @WRITE_CALLBACK def write_cb(_context, buffer, length): try: - return write_callback(ctypes.string_at(buffer, length)) - except Exception: + data = ctypes.string_at(buffer, length) + with _native_callback_scope(): + return ctypes.c_int(write_callback(data)).value + except BaseException: return -1 try: - 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) + 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, operation=operation) 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/integration/test_lifecycle.py b/native-lib/python/tests/integration/test_lifecycle.py index cfabda1a..bb31948e 100644 --- a/native-lib/python/tests/integration/test_lifecycle.py +++ b/native-lib/python/tests/integration/test_lifecycle.py @@ -1,8 +1,38 @@ +import json +import os +from pathlib import Path +import subprocess +import sys +import textwrap + import pytest import dataweave +def _run_raw_abi_child(code): + source_dir = Path(__file__).resolve().parents[2] / "src" + environment = os.environ.copy() + environment["DATAWEAVE_NATIVE_LIB"] = os.environ["DATAWEAVE_NATIVE_LIB"] + environment["PYTHONPATH"] = ( + str(source_dir) + os.pathsep + environment.get("PYTHONPATH", "") + ) + return subprocess.run( + [sys.executable, "-c", textwrap.dedent(code)], + capture_output=True, + check=False, + env=environment, + text=True, + timeout=30, + ) + + +def _raw_abi_response(completed): + assert completed.returncode == 0, completed.stderr + assert "Fatal error" not in completed.stderr + return json.loads(completed.stdout) + + @pytest.mark.integration def test_context_manager_runs_multiple_scripts(): with dataweave.DataWeave() as dw: @@ -28,3 +58,239 @@ def test_context_exit_surfaces_cleanup_failure_without_body_exception(monkeypatc with pytest.raises(dataweave.DataWeaveError, match="cleanup failed"): runtime.__exit__(None, None, None) + + +@pytest.mark.integration +def test_raw_abi_contains_null_arguments_without_terminating_process(): + completed = _run_raw_abi_child( + """ + import ctypes + import json + import os + + from dataweave.models import RESOLVE_MODULE_CALLBACK + from dataweave.native import ( + GraalIsolatePointer, + GraalIsolateThreadPointer, + _bind_abi, + ) + + lib = ctypes.CDLL(os.environ["DATAWEAVE_NATIVE_LIB"]) + _bind_abi(lib) + isolate = GraalIsolatePointer() + bootstrap = GraalIsolateThreadPointer() + assert lib.graal_create_isolate( + None, ctypes.byref(isolate), ctypes.byref(bootstrap) + ) == 0 + assert lib.graal_detach_thread(bootstrap) == 0 + + thread = GraalIsolateThreadPointer() + attached = False + null_resolver_handle = 0 + healthy_handle = 0 + null_script_result = None + try: + assert lib.graal_attach_thread(isolate, ctypes.byref(thread)) == 0 + attached = True + null_resolver = ctypes.cast(None, RESOLVE_MODULE_CALLBACK) + null_resolver_handle = lib.create_engine_with_resolver( + thread, null_resolver, None + ) + assert null_resolver_handle == 0 + healthy_handle = lib.create_engine(thread) + assert healthy_handle > 0 + result_pointer = lib.run_script_engine( + thread, healthy_handle, None, None + ) + if result_pointer: + try: + null_script_result = json.loads( + ctypes.string_at(result_pointer).decode("utf-8") + ) + finally: + lib.free_cstring(thread, result_pointer) + finally: + if attached: + if healthy_handle > 0: + lib.destroy_engine(thread, healthy_handle) + if null_resolver_handle > 0: + lib.destroy_engine(thread, null_resolver_handle) + assert lib.graal_detach_thread(thread) == 0 + + teardown_thread = GraalIsolateThreadPointer() + assert lib.graal_attach_thread( + isolate, ctypes.byref(teardown_thread) + ) == 0 + assert lib.graal_tear_down_isolate(teardown_thread) == 0 + print(json.dumps({ + "null_resolver_handle": null_resolver_handle, + "healthy_handle": healthy_handle, + "null_script_result": null_script_result, + })) + """ + ) + + response = _raw_abi_response(completed) + assert response["null_resolver_handle"] == 0 + assert response["healthy_handle"] > 0 + assert response["null_script_result"] == { + "success": False, + "error": "Script cannot be null", + } + + +@pytest.mark.integration +def test_raw_abi_destroy_waits_for_resolver_context_to_drain(): + completed = _run_raw_abi_child( + """ + import ctypes + import json + import os + from threading import Event, Thread + + from dataweave.models import RESOLVE_MODULE_CALLBACK + from dataweave.native import ( + GraalIsolatePointer, + GraalIsolateThreadPointer, + _bind_abi, + ) + + lib = ctypes.CDLL(os.environ["DATAWEAVE_NATIVE_LIB"]) + _bind_abi(lib) + isolate = GraalIsolatePointer() + bootstrap = GraalIsolateThreadPointer() + assert lib.graal_create_isolate( + None, ctypes.byref(isolate), ctypes.byref(bootstrap) + ) == 0 + assert lib.graal_detach_thread(bootstrap) == 0 + + resolver_entered = Event() + release_resolver = Event() + destroy_ready = Event() + start_destroy = Event() + destroy_call_started = Event() + destroy_returned = Event() + errors = [] + run_result = None + context_matched = False + module_source = ctypes.create_string_buffer( + b"%dw 2.0\\nfun answer() = 42" + ) + module_source_address = ctypes.addressof(module_source) + resolver_context = ctypes.c_int(157) + resolver_context_address = ctypes.addressof(resolver_context) + + @RESOLVE_MODULE_CALLBACK + def resolver(_thread, ctx, _path): + global context_matched + context_matched = ctx == resolver_context_address + resolver_entered.set() + if not release_resolver.wait(5): + return 0 + return module_source_address + + creator_thread = GraalIsolateThreadPointer() + assert lib.graal_attach_thread( + isolate, ctypes.byref(creator_thread) + ) == 0 + handle = lib.create_engine_with_resolver( + creator_thread, + resolver, + ctypes.cast(ctypes.pointer(resolver_context), ctypes.c_void_p), + ) + assert handle > 0 + assert lib.graal_detach_thread(creator_thread) == 0 + + def run_script(): + global run_result + thread = GraalIsolateThreadPointer() + attached = False + try: + assert lib.graal_attach_thread( + isolate, ctypes.byref(thread) + ) == 0 + attached = True + result_pointer = lib.run_script_engine( + thread, + handle, + b"%dw 2.0\\n" + b"import org::test::lib\\n" + b"output application/json\\n" + b"---\\n" + b"lib::answer()", + None, + ) + assert result_pointer + try: + run_result = json.loads( + ctypes.string_at(result_pointer).decode("utf-8") + ) + finally: + lib.free_cstring(thread, result_pointer) + except BaseException as error: + errors.append("run: " + repr(error)) + finally: + if attached and lib.graal_detach_thread(thread) != 0: + errors.append("run: failed to detach") + + def destroy_engine(): + thread = GraalIsolateThreadPointer() + attached = False + try: + assert lib.graal_attach_thread( + isolate, ctypes.byref(thread) + ) == 0 + attached = True + destroy_ready.set() + assert start_destroy.wait(5) + destroy_call_started.set() + lib.destroy_engine(thread, handle) + destroy_returned.set() + except BaseException as error: + errors.append("destroy: " + repr(error)) + finally: + if attached and lib.graal_detach_thread(thread) != 0: + errors.append("destroy: failed to detach") + + run_thread = Thread(target=run_script, daemon=True) + destroy_thread = Thread(target=destroy_engine, daemon=True) + run_thread.start() + try: + assert resolver_entered.wait(5) + destroy_thread.start() + assert destroy_ready.wait(5) + start_destroy.set() + assert destroy_call_started.wait(5) + destroy_blocked_before_release = not destroy_returned.wait(0.1) + finally: + release_resolver.set() + + run_thread.join(5) + destroy_thread.join(5) + assert not run_thread.is_alive() + assert not destroy_thread.is_alive() + assert destroy_returned.is_set() + + teardown_thread = GraalIsolateThreadPointer() + assert lib.graal_attach_thread( + isolate, ctypes.byref(teardown_thread) + ) == 0 + assert lib.graal_tear_down_isolate(teardown_thread) == 0 + print(json.dumps({ + "context_matched": context_matched, + "destroy_blocked_before_release": destroy_blocked_before_release, + "destroy_call_started": destroy_call_started.is_set(), + "destroy_returned": destroy_returned.is_set(), + "run_result": run_result, + "errors": errors, + })) + """ + ) + + response = _raw_abi_response(completed) + assert response["context_matched"] is True + assert response["destroy_call_started"] is True + assert response["destroy_blocked_before_release"] is True + assert response["destroy_returned"] is True + assert response["run_result"]["success"] is True + assert response["errors"] == [] diff --git a/native-lib/python/tests/integration/test_module_resolver.py b/native-lib/python/tests/integration/test_module_resolver.py index fb305b0b..b1b469fc 100644 --- a/native-lib/python/tests/integration/test_module_resolver.py +++ b/native-lib/python/tests/integration/test_module_resolver.py @@ -321,6 +321,62 @@ def run(): } +@pytest.mark.integration +def test_cross_engine_resolver_reentry_is_rejected_without_terminating_process(): + source_dir = Path(__file__).resolve().parents[2] / "src" + code = f""" +import json + +import dataweave + +script = {IMPORT_LIB_SCRIPT!r} +nested = {{}} +inner = dataweave.DataWeave() + +def resolver(_module_path): + try: + inner.run("40 + 2") + except dataweave.DataWeaveError as error: + nested["type"] = type(error).__name__ + nested["error"] = str(error) + return "%dw 2.0\\nfun answer() = 42" + +outer = dataweave.DataWeave(resolve_module=resolver) +inner.initialize() +outer.initialize() +try: + result = outer.run(script) + response = {{ + "nested_type": nested.get("type"), + "nested_error": nested.get("error"), + "outer": {{"success": result.success, "value": result.get_string()}}, + }} +finally: + outer.cleanup() + inner.cleanup() + +print(json.dumps(response)) +""" + environment = os.environ.copy() + environment["PYTHONPATH"] = str(source_dir) + os.pathsep + environment.get("PYTHONPATH", "") + + completed = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + check=False, + env=environment, + text=True, + timeout=30, + ) + + assert completed.returncode == 0, completed.stderr + response = json.loads(completed.stdout) + assert response["nested_type"] == "DataWeaveError" + assert "native callback" in response["nested_error"].lower() + assert response["outer"] == {"success": True, "value": "42"} + assert "Fatal error" not in completed.stderr + + @pytest.mark.integration def test_shared_isolate_survives_until_the_last_instance_cleans_up(): from dataweave import native diff --git a/native-lib/python/tests/integration/test_streaming.py b/native-lib/python/tests/integration/test_streaming.py index dc92c272..6a1f2406 100644 --- a/native-lib/python/tests/integration/test_streaming.py +++ b/native-lib/python/tests/integration/test_streaming.py @@ -6,6 +6,52 @@ import dataweave +def _staged_native_library() -> Path: + native_dir = Path(__file__).resolve().parents[2] / "src" / "dataweave" / "native" + return next( + path + for path in ( + native_dir / "dwlib.dylib", + native_dir / "dwlib.so", + native_dir / "dwlib.dll", + ) + if path.is_file() + ) + + +@pytest.mark.integration +@pytest.mark.parametrize( + "create_stream", + [ + lambda runtime: runtime.run_streaming("output application/json --- 1"), + lambda runtime: runtime.run_transform( + "output application/json --- payload", + [b"null"], + ), + ], + ids=["run-streaming", "run-transform"], +) +def test_real_native_precreated_stream_rejects_stale_generation(create_stream): + lib_path = str(_staged_native_library()) + keeper = dataweave.DataWeave(lib_path) + runtime = dataweave.DataWeave(lib_path) + keeper.initialize() + runtime.initialize() + stream = create_stream(runtime) + old_handle = runtime._native.handle + try: + runtime.cleanup() + runtime.initialize() + assert runtime._native.handle != old_handle + + with pytest.raises(dataweave.DataWeaveError, match="stale engine generation"): + next(stream) + finally: + stream.close() + runtime.cleanup() + keeper.cleanup() + + @pytest.mark.integration def test_run_streaming_returns_chunks_and_metadata(collect_stream): output, metadata = collect_stream(dataweave.run_streaming("output application/json --- {a: 1, b: 2}")) diff --git a/native-lib/python/tests/unit/test_facade.py b/native-lib/python/tests/unit/test_facade.py index 23ec8de6..65a73948 100644 --- a/native-lib/python/tests/unit/test_facade.py +++ b/native-lib/python/tests/unit/test_facade.py @@ -13,10 +13,16 @@ class FakeNativeRuntime: def __init__(self): self.initialized = True self.thread = "thread" + self.operation = native._EngineOperation(handle=7, generation=1) self.calls = [] - def run_engine_and_decode(self, *args): - self.calls.append(("run_engine_and_decode", args)) + def capture_operation(self): + return self.operation + + def run_engine_and_decode(self, script, inputs, *, operation): + assert operation is self.operation + assert operation.handle == 7 + self.calls.append(("run_engine_and_decode", script, inputs, operation)) return self._result() @staticmethod @@ -102,10 +108,9 @@ def test_run_uses_engine_execution_regardless_of_resolver(): assert instance._native.calls == [ ( "run_engine_and_decode", - ( - b"payload", - b'{"value": {"content": "MQ==", "mimeType": "application/json", "charset": "utf-8"}}', - ), + b"payload", + b'{"value": {"content": "MQ==", "mimeType": "application/json", "charset": "utf-8"}}', + instance._native.operation, ) ] @@ -120,7 +125,12 @@ def test_run_without_resolver_routes_through_engine(): True, "SGVsbG8=", None, False, "text/plain", "utf-8" ) assert instance._native.calls == [ - ("run_engine_and_decode", (b"payload", b"{}")) + ( + "run_engine_and_decode", + b"payload", + b"{}", + instance._native.operation, + ) ] @@ -159,6 +169,45 @@ def cleanup(self): assert registered == [dataweave.cleanup, dataweave.cleanup] +@pytest.mark.unit +def test_module_cleanup_from_native_callback_preserves_published_instance(monkeypatch): + instance = configured_runtime() + cleanup_calls = [] + instance.cleanup = lambda: cleanup_calls.append(True) + monkeypatch.setattr(dataweave, "_global_instance", instance) + + with native._native_callback_scope(), pytest.raises(dataweave.DataWeaveError, match="native callback"): + dataweave.cleanup() + + assert dataweave._global_instance is instance + assert cleanup_calls == [] + assert dataweave.run("payload").get_string() == "Hello" + + +@pytest.mark.unit +def test_module_execution_from_native_callback_rejects_before_global_lock_or_native_run(monkeypatch): + instance = configured_runtime() + monkeypatch.setattr(dataweave, "_global_instance", instance) + lock_calls = [] + + class UnexpectedLock: + def __enter__(self): + lock_calls.append("enter") + raise AssertionError("global lock acquired") + + def __exit__(self, _exc_type, _exc_value, _traceback): + pass + + monkeypatch.setattr(dataweave, "_global_lock", UnexpectedLock()) + + with native._native_callback_scope(): + with pytest.raises(dataweave.DataWeaveError, match="native callback"): + dataweave.run("payload") + + assert lock_calls == [] + assert instance._native.calls == [] + + @pytest.mark.unit def test_cleanup_is_noop_without_global_runtime(): dataweave.cleanup() diff --git a/native-lib/python/tests/unit/test_native.py b/native-lib/python/tests/unit/test_native.py index 5587b686..402e8edb 100644 --- a/native-lib/python/tests/unit/test_native.py +++ b/native-lib/python/tests/unit/test_native.py @@ -1,7 +1,7 @@ from pathlib import Path import ctypes import threading -from threading import Barrier, BrokenBarrierError, current_thread, get_ident, Thread +from threading import Barrier, BrokenBarrierError, Condition, current_thread, Event, get_ident, Lock, Thread import pytest @@ -9,6 +9,10 @@ from dataweave import native +class CallbackBaseException(BaseException): + pass + + class Function: pass @@ -79,6 +83,268 @@ def _attach_thread(self, _isolate, thread): return 0 +@pytest.mark.unit +def test_run_admission_generation_rejects_operation_after_cleanup_and_reinitialize(monkeypatch): + library = FakeLibrary() + result_buffer = ctypes.create_string_buffer( + b'{"success":true,"result":"","binary":false,"mimeType":"application/json","charset":"UTF-8"}' + ) + run_handles = [] + library.run_script_engine = CallableFunction( + lambda _thread, handle, _script, _inputs: run_handles.append(handle) + or ctypes.addressof(result_buffer) + ) + library.free_cstring = CallableFunction(lambda _thread, _ptr: None) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + wrapper = dataweave.DataWeave("/tmp/dwlib") + wrapper.initialize() + old_handle = wrapper._native.handle + captured = Event() + resume = Event() + original_require_initialized = wrapper._require_initialized + + def pause_after_capture(supported, api_name): + operation = original_require_initialized(supported, api_name) + captured.set() + assert resume.wait(1) + return operation + + monkeypatch.setattr(wrapper, "_require_initialized", pause_after_capture) + errors = [] + worker = Thread(target=lambda: _capture_error(errors, lambda: wrapper.run("1"))) + worker.start() + assert captured.wait(1) + + wrapper.cleanup() + wrapper.initialize() + replacement_handle = wrapper._native.handle + assert replacement_handle != old_handle + resume.set() + worker.join(1) + + assert not worker.is_alive() + assert len(errors) == 1 + assert "stale engine generation" in str(errors[0]) + assert run_handles == [] + wrapper.cleanup() + + +@pytest.mark.unit +def test_failed_initialize_does_not_publish_or_advance_engine_generation(monkeypatch): + library = FakeLibrary() + library.create_engine = CallableFunction(lambda _thread: 0) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + runtime = native.NativeRuntime("/tmp/dwlib") + + with pytest.raises(dataweave.DataWeaveError, match="null handle"): + runtime.initialize() + + assert runtime._generation == 0 + assert runtime._engine_operation is None + + +@pytest.mark.unit +def test_engine_operation_is_immutable_and_generation_remains_monotonic(monkeypatch): + library = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + runtime = native.NativeRuntime("/tmp/dwlib") + runtime.initialize() + first = runtime.capture_operation() + + with pytest.raises(AttributeError): + first.handle = 99 + runtime.cleanup() + runtime.initialize() + second = runtime.capture_operation() + + assert second.generation == first.generation + 1 + runtime.cleanup() + + +@pytest.mark.unit +def test_run_admission_generation_uses_captured_handle_after_mutable_handle_changes(monkeypatch): + library = FakeLibrary() + result_buffer = ctypes.create_string_buffer(b"result") + run_handles = [] + library.run_script_engine = CallableFunction( + lambda _thread, handle, _script, _inputs: run_handles.append(handle) + or ctypes.addressof(result_buffer) + ) + library.free_cstring = CallableFunction(lambda _thread, _ptr: None) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + runtime = native.NativeRuntime("/tmp/dwlib") + runtime.initialize() + operation = runtime.capture_operation() + runtime.handle = operation.handle + 100 + + assert runtime.run_engine_and_decode(b"script", b"{}", operation=operation) == "result" + assert run_handles == [operation.handle] + runtime.cleanup() + + +@pytest.mark.unit +def test_run_admission_generation_keeps_admitted_operation_on_original_engine(monkeypatch): + library = FakeLibrary() + result_buffer = ctypes.create_string_buffer( + b'{"success":true,"result":"","binary":false,"mimeType":"application/json","charset":"UTF-8"}' + ) + admitted = Event() + release_run = Event() + run_handles = [] + destroyed_handles = [] + + def run_script(_thread, handle, _script, _inputs): + run_handles.append(handle) + admitted.set() + assert release_run.wait(1) + return ctypes.addressof(result_buffer) + + library.run_script_engine = CallableFunction(run_script) + library.free_cstring = CallableFunction(lambda _thread, _ptr: None) + library.destroy_engine = CallableFunction( + lambda _thread, handle: destroyed_handles.append(handle) + ) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + wrapper = dataweave.DataWeave("/tmp/dwlib") + wrapper.initialize() + old_handle = wrapper._native.handle + run_errors = [] + cleanup_errors = [] + run_worker = Thread( + target=lambda: _capture_error(run_errors, lambda: wrapper.run("1")) + ) + run_worker.start() + assert admitted.wait(1) + + cleanup_worker = Thread( + target=lambda: _capture_error(cleanup_errors, wrapper.cleanup) + ) + cleanup_worker.start() + assert cleanup_worker.is_alive() + assert destroyed_handles == [] + + release_run.set() + run_worker.join(1) + cleanup_worker.join(1) + + assert not run_worker.is_alive() + assert not cleanup_worker.is_alive() + assert run_errors == [] + assert cleanup_errors == [] + assert run_handles == [old_handle] + assert destroyed_handles == [old_handle] + + +@pytest.mark.unit +def test_run_admission_generation_wakes_all_stale_waiters_after_cleanup(monkeypatch): + library = FakeLibrary() + result_buffer = ctypes.create_string_buffer( + b'{"success":true,"result":"","binary":false,"mimeType":"application/json","charset":"UTF-8"}' + ) + active_started = Event() + release_active = Event() + + def run_script(_thread, _handle, _script, _inputs): + active_started.set() + assert release_active.wait(1) + return ctypes.addressof(result_buffer) + + library.run_script_engine = CallableFunction(run_script) + library.free_cstring = CallableFunction(lambda _thread, _ptr: None) + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + runtime = native.NativeRuntime("/tmp/dwlib") + runtime.initialize() + operation = runtime.capture_operation() + active_errors = [] + cleanup_errors = [] + stale_errors = [] + active = Thread( + target=lambda: _capture_error( + active_errors, + lambda: runtime.run_engine_and_decode(b"active", b"{}", operation=operation), + ) + ) + active.start() + assert active_started.wait(1) + + original_wait = runtime._operation_lock.wait + cleanup_done = Event() + waiting = { + "cleanup-waiter": Event(), + "stale-waiter-0": Event(), + "stale-waiter-1": Event(), + } + + def record_wait(timeout=None): + name = current_thread().name + waiting[name].set() + result = original_wait(timeout) + if name.startswith("stale-waiter"): + runtime._operation_lock.release() + try: + assert cleanup_done.wait(1) + finally: + runtime._operation_lock.acquire() + return result + + monkeypatch.setattr(runtime._operation_lock, "wait", record_wait) + + def cleanup_runtime(): + try: + runtime.cleanup() + finally: + cleanup_done.set() + + cleanup = Thread( + target=lambda: _capture_error(cleanup_errors, cleanup_runtime), + name="cleanup-waiter", + ) + cleanup.start() + assert waiting["cleanup-waiter"].wait(1) + with runtime._operation_lock: + pass + stale_waiters = [ + Thread( + target=lambda: _capture_error( + stale_errors, + lambda: runtime.run_engine_and_decode( + b"stale", b"{}", operation=operation + ), + ), + name=f"stale-waiter-{index}", + ) + for index in range(2) + ] + for waiter in stale_waiters: + waiter.start() + assert waiting[waiter.name].wait(1) + with runtime._operation_lock: + pass + release_active.set() + active.join(1) + cleanup.join(1) + for waiter in stale_waiters: + waiter.join(1) + stranded = [waiter.name for waiter in stale_waiters if waiter.is_alive()] + if stranded: + with runtime._operation_lock: + runtime._operation_lock.notify_all() + for waiter in stale_waiters: + waiter.join(1) + + assert not active.is_alive() + assert not cleanup.is_alive() + assert stranded == [] + assert not any(waiter.is_alive() for waiter in stale_waiters) + assert active_errors == [] + assert cleanup_errors == [] + assert len(stale_errors) == 2 + assert all( + str(error) == "DataWeave operation belongs to a stale engine generation." + for error in stale_errors + ) + + @pytest.mark.unit def test_shared_isolate_is_created_once_and_torn_down_on_last_release(monkeypatch): library = FakeLibrary() @@ -232,7 +498,7 @@ def test_buffered_worker_execution_uses_one_current_thread_attachment_for_run_de worker = Thread( target=lambda: outcomes.append( - (get_ident(), runtime.run_engine_and_decode(b"script", b"{}")) + (get_ident(), runtime.run_engine_and_decode(b"script", b"{}", operation=runtime.capture_operation())) ) ) worker.start() @@ -290,7 +556,7 @@ def test_attach_on_demand_does_not_cache_by_thread_ident(monkeypatch): worker = Thread( target=lambda: ( observed_threads.append(current_thread()), - outcomes.append(runtime.run_engine_and_decode(b"script", b"{}")), + outcomes.append(runtime.run_engine_and_decode(b"script", b"{}", operation=runtime.capture_operation())), ) ) worker.start() @@ -358,7 +624,7 @@ def free_cstring(_thread, _ptr): worker = Thread( target=lambda: _capture_error( errors, - lambda: runtime.run_engine_and_decode(b"script", b"{}"), + lambda: runtime.run_engine_and_decode(b"script", b"{}", operation=runtime.capture_operation()), ) ) worker.start() @@ -381,6 +647,173 @@ def _capture_error(errors, invoke): errors.append(error) +@pytest.mark.unit +def test_native_callback_scope_rejects_lifecycle_and_execution_on_any_same_thread_instance(monkeypatch): + guarded = native.NativeRuntime.__new__(native.NativeRuntime) + guarded.initialized = True + guarded._operation_lock = Condition(Lock()) + guarded._operation_active = False + guarded._execution_owner = None + other = native.NativeRuntime.__new__(native.NativeRuntime) + other._init_lock = Lock() + other._operation_lock = Condition(Lock()) + other._operation_active = False + other._execution_owner = None + monkeypatch.setattr( + native, + "_acquire_isolate", + lambda _path: (_ for _ in ()).throw(AssertionError("native isolate acquired")), + ) + monkeypatch.setattr( + native, + "_release_isolate", + lambda: (_ for _ in ()).throw(AssertionError("native isolate released")), + ) + + with native._native_callback_scope(): + assert native._isolate_lock.acquire(blocking=False) + native._isolate_lock.release() + assert native._resolver_lock_global.acquire(blocking=False) + native._resolver_lock_global.release() + assert guarded._operation_lock.acquire(blocking=False) + guarded._operation_lock.release() + assert other._init_lock.acquire(blocking=False) + other._init_lock.release() + for invoke in ( + guarded.capture_operation, + other.initialize, + guarded.cleanup, + ): + with pytest.raises( + dataweave.DataWeaveError, + match="DataWeave lifecycle and execution are not allowed from a native callback on the same thread\\.", + ): + invoke() + + +@pytest.mark.unit +def test_native_callback_scope_is_thread_local(): + errors = [] + outcomes = [] + runtime = native.NativeRuntime.__new__(native.NativeRuntime) + runtime.initialized = True + runtime._engine_operation = native._EngineOperation(1, 1) + runtime._operation_lock = Condition(Lock()) + + with native._native_callback_scope(): + worker = Thread( + target=lambda: _capture_error( + errors, lambda: outcomes.append(runtime.capture_operation()) + ) + ) + worker.start() + worker.join(1) + + assert not worker.is_alive() + assert errors == [] + assert outcomes == [native._EngineOperation(1, 1)] + with pytest.raises(dataweave.DataWeaveError, match="native callback"): + native._raise_if_native_callback_active() + + +@pytest.mark.unit +def test_native_callback_scope_rejects_direct_thread_attachment(): + runtime = native.NativeRuntime.__new__(native.NativeRuntime) + runtime.lib = type( + "Native", + (), + { + "graal_attach_thread": lambda _self, _isolate, _thread: (_ for _ in ()).throw( + AssertionError("native attach called") + ), + "graal_detach_thread": lambda _self, _thread: (_ for _ in ()).throw( + AssertionError("native detach called") + ), + }, + )() + runtime.isolate = object() + + with native._native_callback_scope(): + for invoke in (runtime.attach_thread, lambda: runtime.detach_thread(object())): + with pytest.raises(dataweave.DataWeaveError, match="native callback"): + invoke() + + +@pytest.mark.unit +@pytest.mark.parametrize("failure_depth", [1, 2]) +def test_native_callback_scope_restores_depth_after_success_error_and_nesting(failure_depth): + assert not hasattr(native._native_callback_state, "depth") + + with pytest.raises(RuntimeError, match="callback failed"): + with native._native_callback_scope(): + assert native._native_callback_state.depth == 1 + if failure_depth == 1: + raise RuntimeError("callback failed") + with native._native_callback_scope(): + assert native._native_callback_state.depth == 2 + raise RuntimeError("callback failed") + + assert not hasattr(native._native_callback_state, "depth") + native._raise_if_native_callback_active() + + with native._native_callback_scope(): + assert native._native_callback_state.depth == 1 + assert not hasattr(native._native_callback_state, "depth") + + +@pytest.mark.unit +def test_resolver_callback_contains_base_exception_and_restores_native_callback_depth(monkeypatch, capsys): + library = FakeLibrary() + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + runtime = native.NativeRuntime("/tmp/dwlib") + runtime.install_resolver( + lambda _path: (_ for _ in ()).throw(CallbackBaseException("resolver failed")) + ) + runtime.initialize() + _handle, callback, context = library.created_engines[0] + + with runtime._resolver_scope(): + assert callback(None, context, b"org/test/lib.dwl") is None + + assert not hasattr(native._native_callback_state, "depth") + native._raise_if_native_callback_active() + assert capsys.readouterr().err == "DataWeave module resolver callback failed.\n" + runtime.cleanup() + + +@pytest.mark.unit +def test_resolver_callback_runs_without_the_instance_operation_lock(monkeypatch): + library = FakeLibrary() + result_buffer = ctypes.create_string_buffer(b"result") + lock_available = [] + monkeypatch.setattr(native.ctypes, "CDLL", lambda _path: library) + runtime = native.NativeRuntime("/tmp/dwlib") + + def resolver(_path): + acquired = runtime._operation_lock.acquire(blocking=False) + lock_available.append(acquired) + if acquired: + runtime._operation_lock.release() + return "source" + + runtime.install_resolver(resolver) + runtime.initialize() + _handle, callback, context = library.created_engines[0] + + def run_script(_thread, _handle, _script, _inputs): + source = callback(None, context, b"org/test/lib.dwl") + assert ctypes.string_at(source) == b"source" + return ctypes.addressof(result_buffer) + + library.run_script_engine = CallableFunction(run_script) + library.free_cstring = CallableFunction(lambda _thread, _ptr: None) + + assert runtime.run_engine_and_decode(b"script", b"{}", operation=runtime.capture_operation()) == "result" + assert lock_available == [True] + + runtime.cleanup() + + @pytest.mark.unit def test_cleanup_from_worker_uses_current_thread_for_isolate_teardown(monkeypatch): library = FakeLibrary() diff --git a/native-lib/python/tests/unit/test_runtime.py b/native-lib/python/tests/unit/test_runtime.py index b32ad3a0..4b46c70a 100644 --- a/native-lib/python/tests/unit/test_runtime.py +++ b/native-lib/python/tests/unit/test_runtime.py @@ -10,6 +10,7 @@ def __init__(self, lib_path=None): self.initialized = False self.handle = 0 self.thread = object() + self.operation = None self.has_callback_streaming = True self.has_callback_input_output = True self.cleaned = 0 @@ -24,14 +25,22 @@ def install_resolver(self, resolver): def initialize(self): self.initialized = True self.handle = 7 + self.operation = native._EngineOperation(handle=self.handle, generation=1) - def run_engine_and_decode(self, script, inputs): - self.runs.append((script, inputs)) + def capture_operation(self): + assert self.initialized + return self.operation + + def run_engine_and_decode(self, script, inputs, *, operation): + assert operation is self.operation + assert operation.handle == self.handle + self.runs.append((script, inputs, operation)) return '{"success":true,"result":"","binary":false,"mimeType":"application/json","charset":"UTF-8"}' def cleanup(self): self.cleaned += 1 self.initialized = False + self.operation = None @pytest.mark.unit @@ -65,5 +74,7 @@ def test_run_routes_through_engine(monkeypatch): dw = DataWeave() dw.initialize() dw.run("1 + 1") - assert dw._native.runs == [(b"1 + 1", b"{}")] + operation = dw._native.operation + assert dw._native.runs == [(b"1 + 1", b"{}", operation)] + assert operation == native._EngineOperation(handle=7, generation=1) dw.cleanup() diff --git a/native-lib/python/tests/unit/test_streaming.py b/native-lib/python/tests/unit/test_streaming.py index ee00ad78..49ace57a 100644 --- a/native-lib/python/tests/unit/test_streaming.py +++ b/native-lib/python/tests/unit/test_streaming.py @@ -1,11 +1,13 @@ import ctypes from queue import Full, Queue -from threading import Event, Lock, Thread +import sys +from threading import Condition, Event, Lock, Thread from time import sleep import pytest import dataweave +from dataweave import native as native_module from dataweave import runtime as runtime_module @@ -20,6 +22,7 @@ def __init__(self, metadata=None, attach_code=0, emit=b"", consume_input=False): self.freed = [] self._buffers = [] self.detached_event = Event() + self.callback_handles = [] def graal_attach_thread(self, _isolate, _thread): self.attach_count += 1 @@ -41,6 +44,7 @@ def _response_pointer(self): return ctypes.addressof(buffer) def run_script_callback_engine(self, _thread, _handle, _script, _inputs, write_callback, _context): + self.callback_handles.append(_handle) if self.emit: buffer = ctypes.create_string_buffer(self.emit) self.write_status = write_callback(None, ctypes.addressof(buffer), len(self.emit)) @@ -49,6 +53,7 @@ def run_script_callback_engine(self, _thread, _handle, _script, _inputs, write_c def run_script_input_output_callback_engine( self, _thread, _handle, _script, _inputs, _input_name, _mime_type, _charset, read_callback, write_callback, _context, ): + self.callback_handles.append(_handle) if self.consume_input: buffer = ctypes.create_string_buffer(3) read = [] @@ -63,13 +68,48 @@ def run_script_input_output_callback_engine( self.read_input = b"".join(read) if self.emit: buffer = ctypes.create_string_buffer(self.emit) - assert write_callback(None, ctypes.addressof(buffer), len(self.emit)) == 0 + self.write_status = write_callback(None, ctypes.addressof(buffer), len(self.emit)) + if self.write_status != 0: + return self._response_pointer() return self._response_pointer() def destroy_engine(self, _thread, _handle): self.destroyed_handle = _handle +class CallbackBaseException(BaseException): + pass + + +class UnraisableRecorder: + def __init__(self): + self.unraisable = [] + + def __call__(self, unraisable): + self.unraisable.append(unraisable) + + +class ReentrantWriteStatus: + def __init__(self, runtime): + self.runtime = runtime + self.index_calls = 0 + + def __index__(self): + self.index_calls += 1 + self.runtime.run("nested") + return 0 + + +class IndexOnlyWriteStatus: + def __init__(self, value): + self.value = value + self.index_calls = 0 + + def __index__(self): + self.index_calls += 1 + return self.value + + def configured_runtime(native): runtime = dataweave.DataWeave.__new__(dataweave.DataWeave) native_runtime = runtime_module.NativeRuntime.__new__(runtime_module.NativeRuntime) @@ -83,18 +123,48 @@ def configured_runtime(native): # graal_attach_thread/graal_detach_thread. native_runtime.thread = None native_runtime.handle = 1 + native_runtime._generation = 1 + native_runtime._engine_operation = native_module._EngineOperation(1, 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._operation_lock = Condition(Lock()) + native_runtime._operation_active = False native_runtime._execution_owner = None runtime._native = native_runtime return runtime +def initialized_runtime(monkeypatch, *, reuse_handle): + native = FakeNative('{"success": true}') + handles = [] + + def acquire(_path): + return native, object() + + def create_engine(_thread): + handle = 1 if reuse_handle else len(handles) + 1 + handles.append(handle) + return handle + + native.create_engine = create_engine + native.create_engine_with_resolver = lambda _thread, _callback, _context: create_engine(_thread) + monkeypatch.setattr(native_module, "_acquire_isolate", acquire) + monkeypatch.setattr(native_module, "_release_isolate", lambda: None) + runtime = dataweave.DataWeave.__new__(dataweave.DataWeave) + runtime._native = runtime_module.NativeRuntime("/tmp/dwlib") + runtime._resolve_module = None + runtime._stream_workers = set() + runtime._stream_workers_lock = Lock() + runtime._cleaning_up = False + runtime._lifecycle_lock = Lock() + runtime.initialize() + return runtime, native + + @pytest.mark.unit def test_run_callback_converts_write_callback_exception_to_abort_result(): native = FakeNative('{"success": false, "error": "callback aborted"}', emit=b"chunk") @@ -120,58 +190,401 @@ def test_run_input_output_callback_converts_read_exception_to_abort_result(): @pytest.mark.unit -def test_write_callback_reentry_is_translated_to_abort_without_deadlocking(): - completed = Event() - outcomes = [] +def test_run_callback_contains_write_callback_base_exception(monkeypatch): native = FakeNative('{"success": false, "error": "write aborted"}', emit=b"chunk") runtime = configured_runtime(native) + recorder = UnraisableRecorder() + monkeypatch.setattr(sys, "unraisablehook", recorder) - worker = Thread( - target=lambda: ( - outcomes.append( - runtime.run_callback( - "outer", - lambda _chunk: runtime.run_callback("nested", lambda _data: 0), - ) - ), - completed.set(), - ), - daemon=True, + result = runtime.run_callback( + "script", + lambda _chunk: (_ for _ in ()).throw(CallbackBaseException("stop")), ) - worker.start() - assert completed.wait(1), "write callback re-entry deadlocked" assert native.write_status == -1 - assert outcomes == [dataweave.StreamingResult(False, "write aborted", None, None, False)] + assert recorder.unraisable == [] + assert result == dataweave.StreamingResult(False, "write aborted", None, None, False) @pytest.mark.unit -def test_read_callback_reentry_is_translated_to_abort_without_deadlocking(): - completed = Event() - outcomes = [] - native = FakeNative('{"success": false, "error": "read aborted"}', consume_input=True) +@pytest.mark.parametrize( + "invoke", + [ + lambda runtime, write_callback: runtime.run_callback("script", write_callback), + lambda runtime, write_callback: runtime.run_input_output_callback( + "script", "payload", "application/json", lambda _size: b"", write_callback, + ), + ], +) +def test_public_write_callback_normalizes_invalid_status_to_abort_without_unraisable(monkeypatch, invoke): + native = FakeNative('{"success": false, "error": "write aborted"}', emit=b"chunk") runtime = configured_runtime(native) + recorder = UnraisableRecorder() + monkeypatch.setattr(sys, "unraisablehook", recorder) + + result = invoke(runtime, lambda _data: None) + + assert native.write_status == -1 + assert recorder.unraisable == [] + assert result == dataweave.StreamingResult(False, "write aborted", None, None, False) - worker = Thread( - target=lambda: ( - outcomes.append( - runtime.run_input_output_callback( - "outer", - "payload", - "application/json", - lambda _size: runtime.run("nested").get_bytes(), - lambda _data: 0, - ) - ), - completed.set(), + +@pytest.mark.unit +@pytest.mark.parametrize( + "invoke", + [ + lambda runtime, write_callback: runtime.run_callback("script", write_callback), + lambda runtime, write_callback: runtime.run_input_output_callback( + "script", "payload", "application/json", lambda _size: b"", write_callback, ), - daemon=True, + ], +) +def test_public_write_callback_preserves_integer_status(invoke): + native = FakeNative('{"success": false, "error": "write aborted"}', emit=b"chunk") + runtime = configured_runtime(native) + + result = invoke(runtime, lambda _data: -7) + + assert native.write_status == -7 + assert result == dataweave.StreamingResult(False, "write aborted", None, None, False) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "status, expected", + [ + ((1 << 200) + 23, 23), + (-((1 << 200) + 23), -23), + (IndexOnlyWriteStatus((1 << 80) + 23), 23), + ], + ids=["large-positive", "large-negative", "index-only"], +) +@pytest.mark.parametrize( + "invoke", + [ + lambda runtime, write_callback: runtime.run_callback("script", write_callback), + lambda runtime, write_callback: runtime.run_input_output_callback( + "script", "payload", "application/json", lambda _size: b"", write_callback, + ), + ], +) +def test_public_write_callback_uses_c_int_status_semantics_without_unraisable(monkeypatch, invoke, status, expected): + native = FakeNative('{"success": false, "error": "write aborted"}', emit=b"chunk") + runtime = configured_runtime(native) + recorder = UnraisableRecorder() + monkeypatch.setattr(sys, "unraisablehook", recorder) + + result = invoke(runtime, lambda _data: status) + + assert native.write_status == expected + assert recorder.unraisable == [] + assert result == dataweave.StreamingResult(False, "write aborted", None, None, False) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "status, expected", + [ + ((1 << 200) + 23, 23), + (-((1 << 200) + 23), -23), + (IndexOnlyWriteStatus((1 << 80) + 23), 23), + ], + ids=["large-positive", "large-negative", "index-only"], +) +@pytest.mark.parametrize( + "invoke", + [ + lambda runtime, write_callback: runtime.run_callback("script", write_callback), + lambda runtime, write_callback: runtime.run_input_output_callback( + "script", "payload", "application/json", lambda _size: b"", write_callback, + ), + ], +) +def test_public_write_callback_normalizes_status_before_ctypes_return(monkeypatch, invoke, status, expected): + native = FakeNative('{"success": false, "error": "write aborted"}', emit=b"chunk") + runtime = configured_runtime(native) + recorder = UnraisableRecorder() + monkeypatch.setattr(sys, "unraisablehook", recorder) + monkeypatch.setattr(runtime_module, "WRITE_CALLBACK", lambda callback: callback) + + result = invoke(runtime, lambda _data: status) + + assert native.write_status == expected + assert recorder.unraisable == [] + assert result == dataweave.StreamingResult(False, "write aborted", None, None, False) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "invoke", + [ + lambda runtime, write_callback: runtime.run_callback("script", write_callback), + lambda runtime, write_callback: runtime.run_input_output_callback( + "script", "payload", "application/json", lambda _size: b"", write_callback, + ), + ], +) +def test_public_write_callback_converts_custom_status_inside_native_callback_scope(monkeypatch, invoke): + native = FakeNative('{"success": false, "error": "write aborted"}', emit=b"chunk") + runtime = configured_runtime(native) + status = ReentrantWriteStatus(runtime) + recorder = UnraisableRecorder() + monkeypatch.setattr(sys, "unraisablehook", recorder) + + result = invoke(runtime, lambda _data: status) + + assert native.write_status == -1 + assert status.index_calls == 1 + assert native.attach_count == 1 # Only the outer callback invocation attached. + assert recorder.unraisable == [] + assert result == dataweave.StreamingResult(False, "write aborted", None, None, False) + + +@pytest.mark.unit +def test_run_input_output_callback_contains_read_callback_base_exception(monkeypatch): + native = FakeNative('{"success": false, "error": "read aborted"}', consume_input=True) + runtime = configured_runtime(native) + recorder = UnraisableRecorder() + monkeypatch.setattr(sys, "unraisablehook", recorder) + + result = runtime.run_input_output_callback( + "script", + "payload", + "application/json", + lambda _size: (_ for _ in ()).throw(CallbackBaseException("stop")), + lambda _data: 0, ) - worker.start() - assert completed.wait(1), "read callback re-entry deadlocked" assert native.read_status == -1 - assert outcomes == [dataweave.StreamingResult(False, "read aborted", None, None, False)] + assert recorder.unraisable == [] + assert result == dataweave.StreamingResult(False, "read aborted", None, None, False) + + +@pytest.mark.unit +def test_run_input_output_callback_contains_write_callback_base_exception(monkeypatch): + native = FakeNative('{"success": false, "error": "write aborted"}', emit=b"chunk", consume_input=True) + runtime = configured_runtime(native) + recorder = UnraisableRecorder() + monkeypatch.setattr(sys, "unraisablehook", recorder) + + result = runtime.run_input_output_callback( + "script", + "payload", + "application/json", + lambda _size: b"", + lambda _data: (_ for _ in ()).throw(CallbackBaseException("stop")), + ) + + assert native.write_status == -1 + assert recorder.unraisable == [] + assert result == dataweave.StreamingResult(False, "write aborted", None, None, False) + + +@pytest.mark.unit +def test_transform_contains_input_iterator_base_exception(monkeypatch): + native = FakeNative('{"success": false, "error": "read aborted"}', consume_input=True) + runtime = configured_runtime(native) + recorder = UnraisableRecorder() + monkeypatch.setattr(sys, "unraisablehook", recorder) + + def input_stream(): + raise CallbackBaseException("stop") + yield b"" # pragma: no cover + + stream = runtime.run_transform("script", input_stream()) + + assert list(stream) == [] + assert native.read_status == -1 + assert recorder.unraisable == [] + assert stream.metadata == dataweave.StreamingResult(False, "read aborted", None, None, False) + + +@pytest.mark.unit +def test_write_callback_reentry_is_translated_to_abort_without_deadlocking(): + outer_native = FakeNative('{"success": false, "error": "write aborted"}', emit=b"chunk") + inner_native = FakeNative('{"success": true}') + outer = configured_runtime(outer_native) + inner = configured_runtime(inner_native) + + lock_available = [] + + def reenter(_chunk): + acquired = outer._native._operation_lock.acquire(blocking=False) + lock_available.append(acquired) + if acquired: + outer._native._operation_lock.release() + return inner.run_callback("nested", lambda _data: 0) + + result = outer.run_callback("outer", reenter) + + assert outer_native.write_status == -1 + assert inner_native.attach_count == 0 + assert lock_available == [True] + assert result == dataweave.StreamingResult(False, "write aborted", None, None, False) + + +@pytest.mark.unit +def test_read_callback_reentry_is_translated_to_abort_without_deadlocking(): + outer_native = FakeNative('{"success": false, "error": "read aborted"}', consume_input=True) + inner_native = FakeNative('{"success": true}') + outer = configured_runtime(outer_native) + inner = configured_runtime(inner_native) + + lock_available = [] + + def reenter(_size): + acquired = outer._native._operation_lock.acquire(blocking=False) + lock_available.append(acquired) + if acquired: + outer._native._operation_lock.release() + return inner.run("nested").get_bytes() + + result = outer.run_input_output_callback( + "outer", + "payload", + "application/json", + reenter, + lambda _data: 0, + ) + + assert outer_native.read_status == -1 + assert inner_native.attach_count == 0 + assert lock_available == [True] + assert result == dataweave.StreamingResult(False, "read aborted", None, None, False) + + +@pytest.mark.unit +def test_transform_input_iterator_reentry_is_translated_to_abort_without_native_attach(): + outer_native = FakeNative('{"success": false, "error": "read aborted"}', consume_input=True) + inner_native = FakeNative('{"success": true}') + outer = configured_runtime(outer_native) + inner = configured_runtime(inner_native) + lock_available = [] + + def input_stream(): + acquired = outer._native._operation_lock.acquire(blocking=False) + lock_available.append(acquired) + if acquired: + outer._native._operation_lock.release() + yield inner.run("nested").get_bytes() + + stream = outer.run_transform("outer", input_stream()) + + assert list(stream) == [] + assert outer_native.read_status == -1 + assert inner_native.attach_count == 0 + assert lock_available == [True] + assert stream.metadata == dataweave.StreamingResult(False, "read aborted", None, None, False) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "create_stream", + [ + lambda runtime: runtime.run_streaming("script"), + lambda runtime: runtime.run_transform("script", [b"null"]), + ], +) +def test_precreated_stream_rejects_callback_time_first_consumption_before_worker_admission(monkeypatch, create_stream): + native = FakeNative('{"success": true}') + runtime = configured_runtime(native) + stream = create_stream(runtime) + registrations = [] + starts = [] + original_register = runtime._register_stream_worker + + def record_registration(worker, operation): + registrations.append(worker) + return original_register(worker, operation) + + class UnexpectedThread: + def __init__(self, *_args, **_kwargs): + starts.append("constructed") + + monkeypatch.setattr(runtime, "_register_stream_worker", record_registration) + monkeypatch.setattr(runtime_module, "Thread", UnexpectedThread) + + with runtime_module._native_callback_scope(), pytest.raises(dataweave.DataWeaveError, match="native callback"): + next(stream) + + assert registrations == [] + assert starts == [] + assert native.attach_count == 0 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "create_stream", + [ + lambda runtime: runtime.run_streaming("script"), + lambda runtime: runtime.run_transform("script", [b"null"]), + ], + ids=["run-streaming", "run-transform"], +) +@pytest.mark.parametrize("reuse_handle", [False, True], ids=["distinct-handle", "reused-handle"]) +def test_precreated_stream_rejects_stale_generation_before_registration_attach_or_callback( + monkeypatch, create_stream, reuse_handle, +): + runtime, native = initialized_runtime(monkeypatch, reuse_handle=reuse_handle) + stream = create_stream(runtime) + old_handle = runtime._native.handle + runtime.cleanup() + runtime.initialize() + replacement_handle = runtime._native.handle + if reuse_handle: + assert replacement_handle == old_handle + else: + assert replacement_handle != old_handle + registered = [] + original_register = runtime._register_stream_worker + + def record_registration(*args): + result = original_register(*args) + registered.append(args[0]) + return result + + monkeypatch.setattr(runtime, "_register_stream_worker", record_registration) + attach_count = native.attach_count + native.callback_handles.clear() + try: + with pytest.raises(dataweave.DataWeaveError, match="stale engine generation"): + next(stream) + + assert registered == [] + assert runtime._stream_workers == set() + assert native.attach_count == attach_count + assert native.callback_handles == [] + finally: + stream.close() + runtime.cleanup() + + +@pytest.mark.unit +def test_internal_streaming_write_trampoline_contains_base_exception(monkeypatch): + recorder = UnraisableRecorder() + monkeypatch.setattr(sys, "unraisablehook", recorder) + + class RaisingCancel(Event): + def __init__(self): + super().__init__() + self.raise_once = True + + def is_set(self): + if self.raise_once: + self.raise_once = False + raise CallbackBaseException("cancel check failed") + return super().is_set() + + native = FakeNative('{"success": false, "error": "write aborted"}', emit=b"chunk") + runtime = configured_runtime(native) + monkeypatch.setattr(runtime_module, "Event", RaisingCancel) + stream = runtime.run_streaming("script") + + assert list(stream) == [] + + assert native.write_status == -1 + assert recorder.unraisable == [] + assert stream.metadata == dataweave.StreamingResult(False, "write aborted", None, None, False) @pytest.mark.unit @@ -452,8 +865,9 @@ def destroy_engine(self, _thread, _handle): cleanup.start() assert native.cleanup_started.wait(timeout=1) + operation = runtime._native._engine_operation with pytest.raises(dataweave.DataWeaveError, match="being cleaned up"): - runtime._register_stream_worker(Thread()) + runtime._register_stream_worker(Thread(), operation) native.release_cleanup.set() cleanup.join(timeout=1) diff --git a/native-lib/src/main/java/org/mule/weave/lib/CEntryPointExceptionHandlers.java b/native-lib/src/main/java/org/mule/weave/lib/CEntryPointExceptionHandlers.java new file mode 100644 index 00000000..412d49e7 --- /dev/null +++ b/native-lib/src/main/java/org/mule/weave/lib/CEntryPointExceptionHandlers.java @@ -0,0 +1,31 @@ +package org.mule.weave.lib; + +import com.oracle.svm.core.Uninterruptible; +import org.graalvm.nativeimage.c.function.CEntryPoint; +import org.graalvm.nativeimage.c.type.CCharPointer; +import org.graalvm.word.WordFactory; + +final class CEntryPointExceptionHandlers { + private CEntryPointExceptionHandlers() { + } + + static final class ReturnZero implements CEntryPoint.ExceptionHandler { + @Uninterruptible(reason = "Return an ABI sentinel after an entrypoint exception") + static long handle(Throwable ignored) { + return 0L; + } + } + + static final class ReturnNullPointer implements CEntryPoint.ExceptionHandler { + @Uninterruptible(reason = "Return an ABI sentinel after an entrypoint exception") + static CCharPointer handle(Throwable ignored) { + return WordFactory.nullPointer(); + } + } + + static final class ReturnVoid implements CEntryPoint.ExceptionHandler { + @Uninterruptible(reason = "Contain an exception at the C ABI boundary") + static void handle(Throwable ignored) { + } + } +} 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 0770d087..4fd8e8c1 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 @@ -36,7 +36,8 @@ public class NativeLib { * @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 */ - @CEntryPoint(name = "free_cstring") + @CEntryPoint(name = "free_cstring", + exceptionHandler = CEntryPointExceptionHandlers.ReturnVoid.class) public static void freeCString(IsolateThread thread, CCharPointer pointer) { if (pointer.isNull()) { return; @@ -537,7 +538,8 @@ private static CCharPointer toUnmanagedCString(String value) { * @param thread the isolate thread * @return a non-zero handle identifying the new engine */ - @CEntryPoint(name = "create_engine") + @CEntryPoint(name = "create_engine", + exceptionHandler = CEntryPointExceptionHandlers.ReturnZero.class) public static long createEngine(IsolateThread thread) { return ScriptRuntime.register(new ScriptRuntime()); } @@ -551,11 +553,15 @@ public static long createEngine(IsolateThread thread) { * @param ctx opaque context pointer forwarded to every resolver invocation * @return a non-zero handle identifying the new engine */ - @CEntryPoint(name = "create_engine_with_resolver") + @CEntryPoint(name = "create_engine_with_resolver", + exceptionHandler = CEntryPointExceptionHandlers.ReturnZero.class) public static long createEngineWithResolver( IsolateThread thread, NativeCallbacks.ResolveModuleCallback resolverCallback, PointerBase ctx) { + if (resolverCallback.isNull()) { + return 0L; + } CallbackWeaveResourceResolver resolver = new CallbackWeaveResourceResolver(resolverCallback, ctx); return ScriptRuntime.register(new ScriptRuntime(resolver)); @@ -568,7 +574,8 @@ public static long createEngineWithResolver( * @param thread the isolate thread * @param handle the engine handle to remove */ - @CEntryPoint(name = "destroy_engine") + @CEntryPoint(name = "destroy_engine", + exceptionHandler = CEntryPointExceptionHandlers.ReturnVoid.class) public static void destroyEngine(IsolateThread thread, long handle) { ScriptRuntime.destroy(handle); } @@ -585,16 +592,21 @@ public static void destroyEngine(IsolateThread thread, long handle) { * @param inputsJson JSON-encoded inputs map (C string), may be null * @return the script execution result (unmanaged C string, must be freed) */ - @CEntryPoint(name = "run_script_engine") + @CEntryPoint(name = "run_script_engine", + exceptionHandler = CEntryPointExceptionHandlers.ReturnNullPointer.class) public static CCharPointer runScriptEngine( IsolateThread thread, long handle, CCharPointer script, CCharPointer inputsJson) { - ScriptRuntime runtime = ScriptRuntime.get(handle); - if (runtime == null) { - return toUnmanagedCString(UNKNOWN_ENGINE_HANDLE_JSON); + try (ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(handle)) { + if (lease == null) { + return toUnmanagedCString(UNKNOWN_ENGINE_HANDLE_JSON); + } + if (script.isNull()) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Script cannot be null\"}"); + } + String dwScript = CTypeConversion.toJavaString(script); + String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); + return toUnmanagedCString(lease.runtime().run(dwScript, inputs)); } - String dwScript = CTypeConversion.toJavaString(script); - String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); - return toUnmanagedCString(runtime.run(dwScript, inputs)); } /** @@ -612,17 +624,25 @@ public static CCharPointer runScriptEngine( * @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") + @CEntryPoint(name = "run_script_callback_engine", + exceptionHandler = CEntryPointExceptionHandlers.ReturnNullPointer.class) 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(UNKNOWN_ENGINE_HANDLE_JSON); + try (ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(handle)) { + if (lease == null) { + return toUnmanagedCString(UNKNOWN_ENGINE_HANDLE_JSON); + } + if (script.isNull()) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Script cannot be null\"}"); + } + if (writeCallback.isNull()) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Write callback cannot be null\"}"); + } + String dwScript = CTypeConversion.toJavaString(script); + String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); + return streamToWriteCallback(lease.runtime(), dwScript, inputs, writeCallback, ctx); } - String dwScript = CTypeConversion.toJavaString(script); - String inputs = inputsJson.isNull() ? null : CTypeConversion.toJavaString(inputsJson); - return streamToWriteCallback(runtime, dwScript, inputs, writeCallback, ctx); } /** @@ -645,23 +665,40 @@ public static CCharPointer runScriptCallbackEngine( * @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_engine") + @CEntryPoint(name = "run_script_input_output_callback_engine", + exceptionHandler = CEntryPointExceptionHandlers.ReturnNullPointer.class) 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(UNKNOWN_ENGINE_HANDLE_JSON); + try (ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(handle)) { + if (lease == null) { + return toUnmanagedCString(UNKNOWN_ENGINE_HANDLE_JSON); + } + if (script.isNull()) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Script cannot be null\"}"); + } + if (inputName.isNull()) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Input name cannot be null\"}"); + } + if (inputMimeType.isNull()) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Input MIME type cannot be null\"}"); + } + if (readCallback.isNull()) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Read callback cannot be null\"}"); + } + if (writeCallback.isNull()) { + return toUnmanagedCString("{\"success\":false,\"error\":\"Write callback cannot be null\"}"); + } + 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(lease.runtime(), dwScript, inputs, inName, inMime, inCharset, + readCallback, writeCallback, 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); - 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 b8857313..ff703d3c 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,6 +20,7 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.util.Base64; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -38,24 +39,113 @@ public class ScriptRuntime { // ── Handle registry ────────────────────────────────────────────────── - private static final ConcurrentHashMap REGISTRY = new ConcurrentHashMap<>(); + 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) { + Objects.requireNonNull(runtime, "runtime"); long handle = NEXT_HANDLE.getAndIncrement(); - REGISTRY.put(handle, runtime); + if (handle <= 0) { + throw new IllegalStateException("Engine handle space exhausted"); + } + REGISTRY.put(handle, new EngineRecord(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); + /** Acquires a lease for a live runtime, or returns {@code null} if admission is closed. */ + public static EngineLease acquire(long handle) { + EngineRecord record = REGISTRY.get(handle); + return record == null ? null : record.tryAcquire(); } - /** Removes a runtime; returns {@code true} if one was present. */ + /** Closes admission, drains active leases, and removes the runtime. */ public static boolean destroy(long handle) { - return REGISTRY.remove(handle) != null; + EngineRecord record = REGISTRY.get(handle); + if (record == null) { + return false; + } + record.closeAndAwait(); + REGISTRY.remove(handle, record); + return true; + } + + public static final class EngineLease implements AutoCloseable { + private final EngineRecord record; + private final ScriptRuntime runtime; + private boolean closed; + + private EngineLease(EngineRecord record, ScriptRuntime runtime) { + this.record = record; + this.runtime = runtime; + } + + public ScriptRuntime runtime() { + return runtime; + } + + @Override + public void close() { + synchronized (this) { + if (closed) { + return; + } + closed = true; + } + record.release(); + } + } + + private static final class EngineRecord { + private enum State { + LIVE, + CLOSING, + DESTROYED + } + + private final ScriptRuntime runtime; + private State state = State.LIVE; + private int activeLeases; + + private EngineRecord(ScriptRuntime runtime) { + this.runtime = runtime; + } + + private synchronized EngineLease tryAcquire() { + if (state != State.LIVE) { + return null; + } + activeLeases++; + return new EngineLease(this, runtime); + } + + private void closeAndAwait() { + boolean interrupted = false; + synchronized (this) { + if (state == State.LIVE) { + state = State.CLOSING; + } + while (state != State.DESTROYED && activeLeases > 0) { + try { + wait(); + } catch (InterruptedException e) { + interrupted = true; + } + } + state = State.DESTROYED; + notifyAll(); + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + private synchronized void release() { + activeLeases--; + if (activeLeases == 0) { + notifyAll(); + } + } } // ── Per-instance engine ─────────────────────────────────────────────── diff --git a/native-lib/src/test/java/org/mule/weave/lib/NativeLibEntryPointContractTest.java b/native-lib/src/test/java/org/mule/weave/lib/NativeLibEntryPointContractTest.java new file mode 100644 index 00000000..7d3ae496 --- /dev/null +++ b/native-lib/src/test/java/org/mule/weave/lib/NativeLibEntryPointContractTest.java @@ -0,0 +1,50 @@ +package org.mule.weave.lib; + +import org.graalvm.nativeimage.IsolateThread; +import org.graalvm.nativeimage.c.function.CEntryPoint; +import org.graalvm.nativeimage.c.type.CCharPointer; +import org.graalvm.word.PointerBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NativeLibEntryPointContractTest { + + @Test + void allExportsDeclareExplicitExceptionHandlers() throws NoSuchMethodException { + assertEquals( + CEntryPointExceptionHandlers.ReturnVoid.class, + annotation("freeCString", IsolateThread.class, CCharPointer.class).exceptionHandler()); + assertEquals( + CEntryPointExceptionHandlers.ReturnZero.class, + annotation("createEngine", IsolateThread.class).exceptionHandler()); + assertEquals( + CEntryPointExceptionHandlers.ReturnZero.class, + annotation("createEngineWithResolver", IsolateThread.class, + NativeCallbacks.ResolveModuleCallback.class, PointerBase.class).exceptionHandler()); + assertEquals( + CEntryPointExceptionHandlers.ReturnVoid.class, + annotation("destroyEngine", IsolateThread.class, long.class).exceptionHandler()); + assertEquals( + CEntryPointExceptionHandlers.ReturnNullPointer.class, + annotation("runScriptEngine", IsolateThread.class, long.class, + CCharPointer.class, CCharPointer.class).exceptionHandler()); + assertEquals( + CEntryPointExceptionHandlers.ReturnNullPointer.class, + annotation("runScriptCallbackEngine", IsolateThread.class, long.class, + CCharPointer.class, CCharPointer.class, + NativeCallbacks.WriteCallback.class, PointerBase.class).exceptionHandler()); + assertEquals( + CEntryPointExceptionHandlers.ReturnNullPointer.class, + annotation("runScriptInputOutputCallbackEngine", IsolateThread.class, long.class, + CCharPointer.class, CCharPointer.class, CCharPointer.class, + CCharPointer.class, CCharPointer.class, NativeCallbacks.ReadCallback.class, + NativeCallbacks.WriteCallback.class, PointerBase.class).exceptionHandler()); + } + + private static CEntryPoint annotation(String methodName, Class... parameterTypes) + throws NoSuchMethodException { + return NativeLib.class.getDeclaredMethod(methodName, parameterTypes) + .getAnnotation(CEntryPoint.class); + } +} diff --git a/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeLifecycleTest.java b/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeLifecycleTest.java new file mode 100644 index 00000000..7953c391 --- /dev/null +++ b/native-lib/src/test/java/org/mule/weave/lib/ScriptRuntimeLifecycleTest.java @@ -0,0 +1,310 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; + +class ScriptRuntimeLifecycleTest { + + @Test + void destroyWaitsForAnAdmittedLeaseAndRejectsNewAdmission() throws Exception { + long handle = ScriptRuntime.register(new ScriptRuntime()); + ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(handle); + CountDownLatch destroyStarted = new CountDownLatch(1); + AtomicBoolean destroyReturned = new AtomicBoolean(false); + Thread destroyer = daemonThread(() -> { + destroyStarted.countDown(); + ScriptRuntime.destroy(handle); + destroyReturned.set(true); + }); + + try { + assertNotNull(lease); + destroyer.start(); + assertTrue(destroyStarted.await(1, TimeUnit.SECONDS)); + awaitCondition(() -> admissionIsClosed(handle)); + assertFalse(destroyReturned.get()); + } finally { + if (lease != null) { + lease.close(); + } + if (destroyer.getState() != Thread.State.NEW) { + destroyer.join(1_000); + } + ScriptRuntime.destroy(handle); + } + + assertFalse(destroyer.isAlive()); + assertTrue(destroyReturned.get()); + assertCannotAcquire(handle); + } + + @Test + void multipleLeasesMustAllDrainBeforeDestroyReturns() throws Exception { + long handle = ScriptRuntime.register(new ScriptRuntime()); + ScriptRuntime.EngineLease firstLease = ScriptRuntime.acquire(handle); + ScriptRuntime.EngineLease secondLease = ScriptRuntime.acquire(handle); + + Thread destroyer = null; + AtomicBoolean destroyReturned = new AtomicBoolean(false); + try { + assertNotNull(firstLease); + assertNotNull(secondLease); + CountDownLatch destroyStarted = new CountDownLatch(1); + destroyer = daemonThread(() -> { + destroyStarted.countDown(); + ScriptRuntime.destroy(handle); + destroyReturned.set(true); + }); + destroyer.start(); + assertTrue(destroyStarted.await(1, TimeUnit.SECONDS)); + awaitCondition(() -> admissionIsClosed(handle)); + firstLease.close(); + assertFalse(destroyReturned.get()); + secondLease.close(); + destroyer.join(1_000); + } finally { + if (firstLease != null) { + firstLease.close(); + } + if (secondLease != null) { + secondLease.close(); + } + if (destroyer != null) { + destroyer.join(1_000); + } + ScriptRuntime.destroy(handle); + } + + assertFalse(destroyer.isAlive()); + assertTrue(destroyReturned.get()); + } + + @Test + void concurrentDestroyCallsCoordinateAndComplete() throws Exception { + long handle = ScriptRuntime.register(new ScriptRuntime()); + ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(handle); + CountDownLatch firstDestroyStarted = new CountDownLatch(1); + CountDownLatch secondDestroyStarted = new CountDownLatch(1); + AtomicBoolean firstResult = new AtomicBoolean(false); + AtomicBoolean secondResult = new AtomicBoolean(false); + Thread firstDestroyer = destroyThread(handle, firstDestroyStarted, firstResult); + Thread secondDestroyer = destroyThread(handle, secondDestroyStarted, secondResult); + + try { + assertNotNull(lease); + firstDestroyer.start(); + assertTrue(firstDestroyStarted.await(1, TimeUnit.SECONDS)); + awaitCondition(() -> admissionIsClosed(handle)); + awaitCondition(() -> isWaiting(firstDestroyer)); + secondDestroyer.start(); + assertTrue(secondDestroyStarted.await(1, TimeUnit.SECONDS)); + awaitCondition(() -> isWaiting(secondDestroyer)); + } finally { + if (lease != null) { + lease.close(); + } + if (firstDestroyer.getState() != Thread.State.NEW) { + firstDestroyer.join(1_000); + } + if (secondDestroyer.getState() != Thread.State.NEW) { + secondDestroyer.join(1_000); + } + ScriptRuntime.destroy(handle); + } + + assertFalse(firstDestroyer.isAlive()); + assertFalse(secondDestroyer.isAlive()); + assertTrue(firstResult.get()); + assertTrue(secondResult.get()); + assertCannotAcquire(handle); + } + + @Test + void closingAnEngineLeaseTwiceIsHarmless() throws Exception { + long handle = ScriptRuntime.register(new ScriptRuntime()); + ScriptRuntime.EngineLease firstLease = ScriptRuntime.acquire(handle); + ScriptRuntime.EngineLease secondLease = null; + AtomicBoolean destroyReturned = new AtomicBoolean(false); + Thread destroyer = null; + + try { + assertNotNull(firstLease); + firstLease.close(); + firstLease.close(); + secondLease = ScriptRuntime.acquire(handle); + assertNotNull(secondLease); + destroyer = daemonThread(() -> { + ScriptRuntime.destroy(handle); + destroyReturned.set(true); + }); + destroyer.start(); + awaitCondition(() -> admissionIsClosed(handle)); + assertFalse(destroyReturned.get()); + } finally { + if (firstLease != null) { + firstLease.close(); + } + if (secondLease != null) { + secondLease.close(); + } + if (destroyer != null) { + destroyer.join(1_000); + } + ScriptRuntime.destroy(handle); + } + + assertFalse(destroyer.isAlive()); + assertTrue(destroyReturned.get()); + } + + @Test + void interruptedDestroyRestoresInterruptAfterTheLeaseDrains() throws Exception { + long handle = ScriptRuntime.register(new ScriptRuntime()); + ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(handle); + CountDownLatch destroyStarted = new CountDownLatch(1); + AtomicBoolean destroyResult = new AtomicBoolean(false); + AtomicBoolean interruptedOnReturn = new AtomicBoolean(false); + Thread destroyer = daemonThread(() -> { + destroyStarted.countDown(); + destroyResult.set(ScriptRuntime.destroy(handle)); + interruptedOnReturn.set(Thread.currentThread().isInterrupted()); + }); + + try { + assertNotNull(lease); + destroyer.start(); + assertTrue(destroyStarted.await(1, TimeUnit.SECONDS)); + awaitCondition(() -> admissionIsClosed(handle)); + awaitCondition(() -> isWaiting(destroyer)); + destroyer.interrupt(); + awaitCondition(() -> isWaiting(destroyer) && !destroyer.isInterrupted()); + assertFalse(interruptedOnReturn.get()); + } finally { + if (lease != null) { + lease.close(); + } + if (destroyer.getState() != Thread.State.NEW) { + destroyer.join(1_000); + } + ScriptRuntime.destroy(handle); + } + + assertFalse(destroyer.isAlive()); + assertTrue(destroyResult.get()); + assertTrue(interruptedOnReturn.get()); + } + + @Test + void unknownHandleCannotAcquireALease() { + assertCannotAcquire(Long.MAX_VALUE); + } + + @Test + void leaseExposesTheRegisteredRuntime() { + ScriptRuntime runtime = new ScriptRuntime(); + long handle = ScriptRuntime.register(runtime); + ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(handle); + + try { + assertNotNull(lease); + assertSame(runtime, lease.runtime()); + } finally { + if (lease != null) { + lease.close(); + } + ScriptRuntime.destroy(handle); + } + } + + @Test + void registerRejectsNullRuntime() { + assertThrows(NullPointerException.class, () -> ScriptRuntime.register(null)); + } + + @Test + void registerDoesNotPublishANonPositiveHandleAfterOverflow() throws Exception { + Field nextHandleField = ScriptRuntime.class.getDeclaredField("NEXT_HANDLE"); + nextHandleField.setAccessible(true); + AtomicLong nextHandle = (AtomicLong) nextHandleField.get(null); + long previousHandle = nextHandle.getAndSet(Long.MAX_VALUE); + long lastPositiveHandle = 0; + + try { + lastPositiveHandle = ScriptRuntime.register(new ScriptRuntime()); + assertEquals(Long.MAX_VALUE, lastPositiveHandle); + assertThrows(IllegalStateException.class, + () -> ScriptRuntime.register(new ScriptRuntime())); + assertCannotAcquire(Long.MIN_VALUE); + } finally { + if (lastPositiveHandle > 0) { + ScriptRuntime.destroy(lastPositiveHandle); + } + nextHandle.set(previousHandle); + } + } + + private static Thread destroyThread(long handle, CountDownLatch started, AtomicBoolean result) { + return daemonThread(() -> { + started.countDown(); + result.set(ScriptRuntime.destroy(handle)); + }); + } + + private static Thread daemonThread(Runnable action) { + Thread thread = new Thread(action); + thread.setDaemon(true); + return thread; + } + + private static boolean admissionIsClosed(long handle) { + ScriptRuntime.EngineLease probe = ScriptRuntime.acquire(handle); + if (probe == null) { + return true; + } + try { + return false; + } finally { + probe.close(); + } + } + + private static void assertCannotAcquire(long handle) { + ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(handle); + try { + assertNull(lease); + } finally { + if (lease != null) { + lease.close(); + } + } + } + + private static boolean isWaiting(Thread thread) { + return thread.getState() == Thread.State.WAITING; + } + + private static void awaitCondition(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return; + } + Thread.onSpinWait(); + } + assertTrue(condition.getAsBoolean(), "Condition was not met within one second"); + } +} 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 77fa2a43..fd439052 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 @@ -635,34 +635,48 @@ void twoEnginesResolveOnlyTheirOwnModule() { 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)); + try { + try (ScriptRuntime.EngineLease leaseA = ScriptRuntime.acquire(hA); + ScriptRuntime.EngineLease leaseB = ScriptRuntime.acquire(hB)) { + assertNotNull(leaseA); + assertNotNull(leaseB); + + // Each engine resolves its own module... + assertEquals("\"A:X\"", Result.parse(leaseA.runtime().run(IMPORT_A)).result); + assertEquals("\"B:X\"", Result.parse(leaseB.runtime().run(IMPORT_B)).result); + + // ...and NOT the other's (no cross-talk). + assertNotNull(Result.parse(leaseA.runtime().run(IMPORT_B)).error); + assertNotNull(Result.parse(leaseB.runtime().run(IMPORT_A)).error); + } - ScriptRuntime.destroy(hB); + // destroy removes it; a fresh handle is distinct. + assertTrue(ScriptRuntime.destroy(hA)); + assertCannotAcquire(hA); + assertFalse(ScriptRuntime.destroy(hA)); // already gone + try (ScriptRuntime.EngineLease leaseB = ScriptRuntime.acquire(hB)) { + assertNotNull(leaseB); + } + } finally { + ScriptRuntime.destroy(hA); + ScriptRuntime.destroy(hB); + } } @Test void engineWithoutResolverStillRunsBuiltins() { 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\")"); - assertEquals("\"Hello\"", Result.parse(r).result); - ScriptRuntime.destroy(h); + try { + try (ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(h)) { + assertNotNull(lease); + String r = lease.runtime().run( + "%dw 2.0\nimport dw::core::Strings\noutput application/json\n---\nStrings::capitalize(\"hello\")"); + assertEquals("\"Hello\"", Result.parse(r).result); + } + } finally { + ScriptRuntime.destroy(h); + } } /** @@ -671,25 +685,26 @@ void engineWithoutResolverStillRunsBuiltins() { * {@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)); + assertCannotAcquire(unregisteredHandle); assertEquals("{\"success\":false,\"error\":\"Unknown engine handle\"}", NativeLib.UNKNOWN_ENGINE_HANDLE_JSON); } + private static void assertCannotAcquire(long handle) { + ScriptRuntime.EngineLease lease = ScriptRuntime.acquire(handle); + try { + assertNull(lease); + } finally { + if (lease != null) { + lease.close(); + } + } + } + // ── Fail-closed input parsing (review #11 #5) ────────────────────────── /** (a) Malformed inputs JSON must fail closed, not silently run on empty bindings. */