Skip to content

@W-23692110: Multiple isolated DataWeave engines per process (Node + Python) - #157

Open
mlischetti wants to merge 216 commits into
masterfrom
w-23692110-multi-engine-design
Open

@W-23692110: Multiple isolated DataWeave engines per process (Node + Python)#157
mlischetti wants to merge 216 commits into
masterfrom
w-23692110-multi-engine-design

Conversation

@mlischetti

@mlischetti mlischetti commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Replace native-lib's process-wide ScriptRuntime singleton (one engine, write-once resolver, first-caller-wins) with a handle-keyed registry of per-engine ScriptRuntime objects living in one shared GraalVM isolate — closing W-23692110 for both the Node and Python bindings, which now drive the same model through the identical *_engine C ABI.

  • Each DataWeave instance (Node or Python) owns an independent native engine — its own module resolver and script cache — addressed by an opaque handle, so multiple instances with different resolvers coexist in one process with no cross-talk.
  • The ScriptRuntime singleton (getInstance()/defaultInstance) and the three legacy singleton C entrypoints (run_script, run_script_callback, run_script_input_output_callback) are removed. All execution is handle-addressed. This is an intentional pre-GA C-ABI break with no shims; dwlib is consumed only by this repo's own bindings, in lockstep.
  • The resolver callback is now the 3-arg ResolveModuleCallback(thread, ctx, modulePath); resolver dispatch is per-engine via the opaque ctx.

Two bindings, one model

  • Node (original PR scope): object-level engines behind opaque handles; per-handle resolver bridge; the concurrency/lifecycle state machine in the C addon (addon.c) hardened across many review rounds.
  • Python (unification, this update): migrated off its former isolate-per-instance model and off the legacy singleton onto the shared-isolate + handle-addressed-engine model. native.py implements a module-level, reference-counted isolate with one engine handle per DataWeave instance; the public Python API is unchanged.

Design

Consolidated design (both bindings, final state as shipped): docs/superpowers/specs/2026-08-07-native-lib-multi-engine-design.md. Each Node hardening round and the Python unification are folded into that single doc (provenance map in its appendix).

Post-review hardening

The C addon's lifecycle/concurrency and out-of-memory paths were hardened across many Node-binding review rounds — thread-spawn and thread-safe-function failure handling, N-API thread-affinity discipline for resolver bridges, coalesced cleanup(), a re-init-during-pending-teardown deadlock fix (TEARDOWN_* state machine + live-isolate adoption), atomic g_mutex admission for all run/stream/transform entrypoints, exhaustive napi_get_value_*/napi_create_* status checks, OOM-safe setup and worker/callback allocations, and deferral of engine-registry removal until an engine's admitted ops drain.

The Python unification then went through the same task-by-task + final-whole-branch review discipline. The final review caught and fixed a cross-thread isolate-teardown hang: the Python glue kept the isolate's bootstrap thread attached for the isolate's life, so a last-release graal_tear_down_isolate running on a different OS thread (e.g. the atexit path) would block forever. The fix mirrors the Node/Go bindings — detach the bootstrap thread immediately after graal_create_isolate and attach a fresh thread on demand for every native call — plus unregistering a resolver token on a failed initialize().

Test plan

  • ./gradlew native-lib:test — Java registry isolation / cross-talk / built-ins-only engines pass; singleton (getInstance()) removed. BUILD SUCCESSFUL.
  • ./gradlew native-lib:nativeCompile — new create_engine*/*_engine symbols exported; legacy singleton entrypoints removed.
  • native-lib Node vitest suite — 952 passed / 32 skipped / 0 failed, incl. independent-engines, teardown-deadlock, and admission regressions plus TCK conformance (676 passed / 0 failed).
  • native-lib Python — unit 105 passed; integration 33 passed (incl. multi-instance refcount teardown, per-engine resolver isolation, and a foreign-thread no-hang regression); TCK 729 selected / 0 failed.
  • Implemented and reviewed task-by-task, with a final whole-branch review on the most capable model per binding.

🤖 Generated with Claude Code

@mlischetti
mlischetti requested a review from a team as a code owner August 10, 2026 14:28
@mlischetti

Copy link
Copy Markdown
Contributor Author

Pushed remediation for the two code reviews (docs/reviews/pr-157-code-review-andy.md, docs/reviews/pr-157-code-review.md, both now removed from the repo). All 7 findings (F1–F7) were validated against source before fixing:

  • F1/F2 (High): bridge use-after-free during in-flight streaming/transform + cross-Worker N-API misuse in cleanup. Added in_flight/destroy_pending accounting on engine_bridge_t, deferred free until the owner thread's completion sentinel drains, and per-env napi_add_env_cleanup_hook so each Worker disposes only its own refs.
  • F3/F4 (Low/Medium): resolver-buffer OOM leak on tracking-node allocation failure, and a handle <= 0 construction failure silently accepted into the registry. Both now fail closed.
  • F5 (Medium): per-engine ABI symbols are now required at load time with a clear compatibility error, instead of failing later per-call.
  • F6 (Medium): added throwing-resolver, resolver-backed reinit, cleanup-during-streaming (F1 regression guard), and destroyed-handle tests.
  • F7 (Minor): Java-side resolver-exception logging now matches the C-side's DATAWEAVE_RESOLVER_DEBUG-gated, content-free-by-default policy.

A final whole-branch review across all 14 commits came back clean (no Critical/Important findings); the two Minor findings it raised (native-level test for the "Unknown engine handle" JSON contract, and stray review-notes files) are fixed in the last two commits.

mlischetti added a commit that referenced this pull request Aug 14, 2026
Root-cause fix for the three findings in the sixth PR #157 follow-up review:
model the DataWeave instance lifecycle explicitly (uninitialized/ready/
cleaning-up) instead of a single boolean, make C-side stream/transform
admission atomic under g_mutex, and validate napi_get_value_int64 at the
handle-read sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mlischetti added a commit that referenced this pull request Aug 19, 2026
Root-cause fix for the three findings in the sixth PR #157 follow-up review:
model the DataWeave instance lifecycle explicitly (uninitialized/ready/
cleaning-up) instead of a single boolean, make C-side stream/transform
admission atomic under g_mutex, and validate napi_get_value_int64 at the
handle-read sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mlischetti
mlischetti force-pushed the w-23692110-multi-engine-design branch from 2a038f2 to d504c0f Compare August 19, 2026 13:26
@mlischetti

Copy link
Copy Markdown
Contributor Author

Round 11 hardening pushed (50b2930..6865803)

Addresses the 11th "andy" review + a second general code review — 6 findings (1 documented, not code):

# Finding Commit
Extract bridge_begin_op_locked (atomic admission-time pin) 7f24fc7
#2 Pin engine at admission for streaming + transform (in_flight++ in the same locked section as g_active_ops++; released exactly once on every early-return) 27cc714
#3 Pin engine for the synchronous runScriptEngine 4950380
#1 Register an env cleanup hook for every engine (not just resolver-backed); extend the owner-thread destroyEngine guard to fire for any record fd49ad1
#5 Register process beforeExit/exit hooks once per module (never-reset guard) instead of per-singleton 58c2690
#6 Real-addon integration tests for the *_engine unknown/destroyed-handle envelope + best-effort run-vs-destroy guard cacb41b, 6865803

Full Node suite: 885 passed / 59 skipped / 0 failed. Passed a whole-branch review (correctness of the pin across all three run paths, exactly-once release on every early-return, lifecycle-invariant preservation).

Two notes for reviewers

  1. Contract change (finding Create LICENSE #1): the owner-thread destroyEngine guard now keys on "a record exists," not on resolver napi_ref state — because every engine now carries an env cleanup hook (env-affine state), so a resolver-less engine is also only destroyable from its creating thread. This supersedes the old "resolver-less engines are destroyable from any thread" invariant described in some earlier design docs (docs/superpowers/specs/2026-08-19-worker-teardown-dangling-resolver-ctx-design.md and the two 2026-08-18-* docs). bridge_finalize's napi_ref deletion stays resolver-gated.

  2. dwlib C ABI break (finding Set in/out mimetypes as input parameters, env vars #4) is intentional and documented — no compat shims. The branch replaces the run_script_*_with_resolver exports with the *_engine multi-engine entrypoints (legacy singletons run_script/run_script_callback/run_script_input_output_callback preserved) and adds a ctx parameter to ResolveModuleCallback. dwlib is consumed by this repo's own Python/Node bindings in lockstep.

Follow-up not in this round: the runTransform TOCTOU (general review #1) is made memory-safe by the C-side admission pin (worst case is a resolved Unknown engine handle envelope, not a UAF). The additional JS-side recheck/lease the reviewer suggested was deliberately not added — the authoritative pin lives in C. Can add the JS belt-and-suspenders as a separate change if desired.

@mlischetti

Copy link
Copy Markdown
Contributor Author

Round 12 — worker ref-leak & teardown-race hardening

Pushed 6865803..765c273 (14 commits). Remediates the findings from the latest follow-up review of the multi-engine Node binding. Node-binding-only (addon.c + dataweave.ts + tests); the Java/native-image layer is untouched, so the existing dwlib stays valid.

# Finding Fix
#2 Abandoned-Worker init-ref leak (env-cleanup path never released the isolate ref) isolate_ref_release_core_locked + deferred_ref_release (1abe0a8, prep 2cbe637, doc c55f4b7)
#3 Registry-attach vs isolate-teardown race in bridge_finalize split into bridge_finalize_registry (transient g_active_ops reservation, check+increment in one g_mutex hold) + bridge_finalize_free (81ad661)
#4 runTransform could use a stale handle after async input pre-buffering ensureReady() re-check after createChunkReader await (c81f38f)
#6 Engine creation ignored napi_add_env_cleanup_hook failure all-or-nothing rollback + throw (b6ab957); fix round dropped a double-release of the init ref (41794ec)
#5 Module-level cleanup() had no coalescing for overlapping calls module-scoped cleanupPromise + per-instance cleaningInstance guard (82fd69c, + final-review fix in 765c273)
#7 README cleanup() doc bugs doc fix (e1b9ee0)
#8 Weak run-vs-destroy admitted-ordering test require success + full chunks, drop the "or Unknown handle" tolerance (94cb479)
#9 No real worker_threads coverage new worker-lifecycle.test.ts, 5 tests (5173a6f, hardened in 765c273)

Concurrency invariants (all re-derived and confirmed in a whole-branch review): exactly-one g_ref_count release per initialize(); g_active_ops (isolate teardown gate) vs per-engine in_flight (registry-removal gate) kept distinct; teardown state machine transitions in one g_mutex critical section; napi_env/napi_ref/napi_deferred/napi_threadsafe_function thread-affinity preserved; fn_destroy_engine called exactly once per handle.

Tests: 895 passed / 59 skipped / 0 failed.

@mlischetti

Copy link
Copy Markdown
Contributor Author

Round 13 — per-env init-reference ownership (fixes review #5)

Pushed 765c273..bd68c70 (8 commits: design spec + 7 implementation commits).

What changed. Replaced the process-global init-reference model — which assumed a strict 1 initialize() ↔ 1 engine ↔ 1 cleanup() pairing — with per-napi_env init-reference ownership, establishing the invariant g_ref_count == Σ (per-env init_refs). This closes finding #5 of the previous review: a raw consumer doing initialize() once + createEngine() N times, then abandoning the env, previously fired N per-engine references against a count of 1 and could tear the shared GraalVM isolate down under a still-live env (UAF / premature teardown). The symmetric hole — an over-cleanup() from one env stealing another env's reference — is closed too.

Mechanism.

  • A per-napi_env record (env_init_rec_t in a g_mutex-guarded list) tracks each env's net initialize()-minus-cleanup() balance.
  • All three napi_initialize sites acquire the env's reference and register an env-death hook on the env's first initialize() (so Node's LIFO ordering runs it after every per-engine bridge finalizes on a live isolate).
  • The per-engine finalize path (bridge_env_cleanup/bridge_end_op) no longer mutates g_ref_count — the core fix.
  • cleanup() now decrements only when the calling env owns a reference (double-cleanup / cleanup-without-initialize is an explicit no-op).
  • Bounded isolate_ref_release_n_locked(n) releases a dead env's whole balance and makes the reached-zero teardown decision at most once. Both spawn-failure rollbacks now restore g_ref_count to the true remaining Σ init_refs (not a hardcoded 1).

Review. Each task passed a spec + quality gate; the whole-branch final review (on the most capable model) independently re-derived the g_ref_count == Σ init_refs invariant and confirmed it holds at every g_mutex release, with the per-engine path provably no longer touching the count. Final review surfaced one Important regression — a create-path acquire failure (OOM/hook-registration) after the isolate was built but before g_initialized=1 could orphan the isolate and hang the next initialize(); fixed by tearing the just-built isolate back down before throwing, and re-reviewed clean.

Tests. Full Node suite 897 passed / 59 skipped / 0 failed (added 2 integration tests). The Java layer and dwlib are unchanged this round (addon.c + tests + spec only).

Known coverage gap (documented in the test file). The two new env-init-ownership.test.ts cases are single-env smoke tests, relabeled honestly — a review confirmed they pass unchanged on the pre-fix addon, because #5 is a cross-env bug that a single-env test cannot distinguish. Cross-env behavior is exercised indirectly by worker-lifecycle.test.ts; a dedicated cross-env regression test that goes RED on the pre-fix addon remains a follow-up. The fix's correctness rests on the invariant re-derivation, which is stronger evidence than a smoke test.

@mlischetti

Copy link
Copy Markdown
Contributor Author

Round 14 — review #5 remediation (all 7 findings)

Addresses every finding from follow-up code review #5 (reviewed head bd68c70). Range e8a0f7f..7017ded (design spec + 7 fix commits). Full Node suite green on HEAD: 899 passed / 59 skipped / 0 failed (+1 unit, +1 integration vs. the pre-round baseline).

# Sev Finding Fix Commit
1 High engine creation could attach to an isolate being torn down gate both create functions on per-env ownership + teardown-state + a g_active_ops reservation in one g_mutex critical section 3073cce
2/3 Med a failed last-reference teardown could strand a live owner-less isolate g_teardown_needed retry signal (not a reference), armed in 5 failure branches when the isolate is left live with 0 owners, retried at the streaming/transform op-drain points, cleared on all 3 initialize() success paths ecadc85, 3b206a2
6 Med DataWeave.cleanup() leaked its init reference if destroyEngine() threw doCleanup() now always runs ffi.cleanup() even when destroyEngine() throws, preserving/re-throwing the primary error eda947a
5 Med Worker test helper hid a nonzero exit runWorker rejects every nonzero exit and a zero-exit-without-message distinctly a462bfa
4 Med round-13 tests didn't pin the round-12 cross-env over-release real-addon cross-env regression: a Worker inits once, creates N=3 engines, exits without cleanup; asserts the live main engine survives, then balances to exactly zero. Goes RED on round-12, green on round-13+ 97128e3
7 Low resolver quick-start docs omitted cleanup both examples wrapped in try/finally with await dw.cleanup() 7017ded

Invariant preserved throughout: g_ref_count == Σ per-env init_refs at every g_mutex release; g_teardown_needed is a retry signal, never a reference (set only when g_ref_count == 0). Round 14 introduces no new g_ref_count mutation; the admission block takes g_active_ops, balanced on every post-reservation exit.

Java side and the legacy singleton entrypoints are untouched this round.

🤖 Generated with Claude Code

@mlischetti

Copy link
Copy Markdown
Contributor Author

Round 15 — external review #6 remediation

Addressed all 8 code findings from the latest follow-up review (pr-157-follow-up-code-review-6.md), validated against 7017ded. Six commits (ca135a9..aaeafb9), each mapped to a finding. Full Node suite green throughout: 902 passed / 59 skipped / 0 failed.

# Sev Fix Commit
1 High getGlobalInstance() builds+inits a local candidate and publishes the singleton only on success — a failed first init no longer leaves a poisoned, uninitialized singleton. Regression added. ca135a9
2 High streamFromNative now handles the rejected-start() branch: wakes parked consumers, drains buffered chunks, then throws — no more hang / unhandled rejection. 2 regressions added. 092c665
3 Med cleanup_thread_fn / teardown_waiter_thread_fn set torn_down only when graal_tear_down_isolate returns 0 — a nonzero teardown no longer clears globals and orphans a live isolate. 73de951
4 Med The async teardown-waiter's last-release path arms g_teardown_needed when teardown didn't happen and the isolate is stranded with zero owners (mirrors the sync twin). 73de951
5 Med napi_initialize drives a pending stranded teardown to completion via retry_stranded_teardown_locked() before adopting/creating, so a zero-op strand is reclaimed instead of silently discarded. Chosen approach: init-driven completion, no new async infrastructure. 62868f8
6 Med Worker cleanup:true path surfaces destroyEngine errors (folded into the posted message) instead of swallowing them; cleanup() stays in finally. 2ef9272
7 Med Cross-env regression test wrapped in try/finally so a mid-test failure can't strand a live isolate + held reference for sibling tests. Survival assertions stay inside try (RED-on-round-12 property preserved). 2ef9272
8 Low Reinitialization unit test tightened (mockClear() + toHaveBeenCalledTimes(1)) so it can't false-pass when re-init is a no-op. aaeafb9

On #5's residual (accepted, documented in-code): if a teardown fails and no later initialize() or streaming/transform op ever runs, the isolate lingers until process exit where the OS reclaims it — benign (single process-lifetime isolate, no ref-count violation). This is the deliberate tradeoff for not adding event-loop-affine async retry infrastructure to this concurrency-sensitive path.

#9 (Python-binding scope) — intentionally not split. The review noted the PR bundles Python-binding modernization alongside the Node multi-engine work. That bundling is intentional for this PR and will not be split out in this round; the Python work is being tracked as part of the same effort. Happy to revisit if a reviewer feels strongly, but flagging it here so the decision is explicit.

A whole-branch review of the combined round-15 changes traced the full #3 → #4 → #5 arm-and-consume loop and confirmed the g_ref_count == Σ init_refs invariant holds at every g_mutex release, with no orphan-flag state, no premature clear, and no new deadlock (join-under-lock matches the existing Case-4 pattern).

mlischetti added a commit that referenced this pull request Aug 24, 2026
Root-cause fix for the three findings in the sixth PR #157 follow-up review:
model the DataWeave instance lifecycle explicitly (uninitialized/ready/
cleaning-up) instead of a single boolean, make C-side stream/transform
admission atomic under g_mutex, and validate napi_get_value_int64 at the
handle-read sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mlischetti
mlischetti force-pushed the w-23692110-multi-engine-design branch from aaeafb9 to 6fb5603 Compare August 24, 2026 15:45
@mlischetti

Copy link
Copy Markdown
Contributor Author

Review #7 remediation pushed (6fb5603..c54ff65)

All seven code/doc findings (#1#7) are addressed, each fixed on its own commit, task-reviewed, and covered by a final whole-branch review. Full Node suite green throughout: 946 passed / 32 skipped / 0 failed (TCK conformance 0-failed); tsc + addon build clean.

# Sev Fix Commit
#1 High Detach the phantom GraalVM thread on a failed (!=0) graal_tear_down_isolate, in both cleanup_thread_fn and teardown_waiter_thread_fn (detach only on the failure branch — never on success/UAF or attach-failure) 55b2418
#2 Med Arm the stranded-teardown retry after a failed init-hook rollback so the next initialize() reclaims the isolate instead of wedging ff3b6bb
#3 Med Model initialize()'s ffi.cleanup() rollback as pending state — observe it (no unhandledRejection) and gate a concurrent initialize() with "cleanup is in progress"; still throws the original error synchronously ae35089
#6 Low Track native stream rejection by settlement state (startRejected) instead of the startError !== undefined value sentinel, so Promise.reject(undefined) propagates eed6ee4
#7 Low Fail the worker-lifecycle test when balancing cleanup fails on a passing body (re-throw only when bodySucceeded) c258463
#5 Med State the final-reference condition in the instance cleanup() docs (resolves after full isolate teardown only when releasing the last instance) 6462d96
#4 Med Root README: await cleanup in the lifecycle example; document the real beforeExit+exit hook pair and signal caveat 6911de9

The final review also tightened the #2 inline comment (c54ff65): that acquire-failure path leaves g_initialized==0, so the retry recovers a transient teardown failure but a persistent graal_tear_down_isolate failure strands the isolate to process exit (pre-existing best-effort degradation) — the prior "adopted by the fast path" wording overstated it.

On finding #8 (Med) — Python-binding modernization scope

Acknowledged, and we agree the Python pytest setup / module extraction / TCK / CI / docs are logically a separate concern from the Node engine-lifecycle work. We're deferring the split to its own follow-up PR rather than doing git surgery on this branch now: the Node lifecycle changes have been through seven review rounds against this exact history, and re-splitting risks disturbing that reviewed state for a packaging reorg. The follow-up PR will carry the Python modernization on its own so its CI/rollout/rollback can be reasoned about independently. This branch's remaining diff remains the Node engine/lifecycle work plus these remediations.

@mlischetti

Copy link
Copy Markdown
Contributor Author

Review #8 remediation — all 7 findings addressed (pushed c54ff65..8378029)

Remediated the 7 findings from pr-157-follow-up-code-review-8.md. Each fix was implemented and independently reviewed; a whole-branch final review then ruled SHIP. Full Node suite green throughout: 947 passed / 32 skipped / 0 failed (+1 new regression test).

# Finding Fix Commit
1 Highnapi_initialize deadlocks after a repeated stranded-teardown failure (blocks on a uv_cond_wait nobody signals) After the retry, detect the unrecoverable stranded state (g_isolate != NULL && !g_initialized && TEARDOWN_NONE) and throw a deterministic error instead of entering the wait loop. g_teardown_needed stays armed and g_ref_count is untouched. 9fc728f
2 Med — a synchronous rollback ffi.cleanup() throw strands the instance in "cleaning-up" Route the release through a promise boundary so a synchronous throw is normalized to a rejection that still settles the .finally state reset; the caller still synchronously sees the original engine-creation error. 85d06ae
3 Med — worker-lifecycle balancing cleanup skips ffi.cleanup() when destroyEngine throws Capture the destroy error but always run ffi.cleanup(); surface a balancing failure only when the test body succeeded. eccc9d9 (+ 1831ccd)
4 Med — README teaches invalid terminal-metadata retrieval (generator.return() after for await) Document manual next() iteration capturing the terminal { done: true, value: StreamingResult }. faf0e78
5 Med — README overstates runTransform async input as constant-memory Switch large-file examples to a synchronous generator, add the sync-vs-async pre-buffering note, and qualify the Performance claims. c7799e2 (+ 8378029)
6 Med — external-modules.md examples omit required cleanup Wrap the 5 complete examples in try/finally { await dw.cleanup(); }; label the 2 genuine fragments. 9eec6b2
7 Med — root README overstates cleanup as an unconditional process-wide drain Qualify the §4 and §9 comments: the drain/teardown happens only when the final shared reference is released. 8347516

Notes

  • On finding Create LICENSE #1 and my review Update .travis.yml #7 sign-off: my review Update .travis.yml #7 final review wrongly cleared this as a benign "lingers-to-process-exit" degradation — I conflated the napi_initialize wait path with the isolate_ref_release_n_locked release-path twin (which is benign and adoptable). Finding Create LICENSE #1 was a real hard deadlock (uv_cond_wait holding g_mutex with no remaining signaller), and it is now closed. The final review traced the new guard against every state reachable at the wait-loop entry to confirm it fires only in the stranded case and never on a cold start, a healthy re-init, or a legitimate in-flight teardown.
  • Finding Unable to resolve reference to payload error #2 fix detail: the review's suggested Promise.resolve().then(() => ffi.cleanup()) defers ffi.cleanup() into a microtask, which breaks two existing tests that assert synchronous invocation. The shipped fix uses an equivalent try/catchPromise.reject normalization that preserves the synchronous invocation timing while achieving the same intent.
  • Two Minor accuracy nits raised by the final review (test double-fault error-masking; a readFileSync bounded-memory caveat) are folded into 1831ccd / 8378029.
  • Scope unchanged: Node-binding-only; no Python, Java, or legacy-singleton entrypoints touched; handle width stays long long.

mlischetti added a commit that referenced this pull request Aug 25, 2026
Root-cause fix for the three findings in the sixth PR #157 follow-up review:
model the DataWeave instance lifecycle explicitly (uninitialized/ready/
cleaning-up) instead of a single boolean, make C-side stream/transform
admission atomic under g_mutex, and validate napi_get_value_int64 at the
handle-read sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mlischetti
mlischetti force-pushed the w-23692110-multi-engine-design branch from 8378029 to 136e914 Compare August 25, 2026 18:41
@mlischetti mlischetti changed the title W-23692110: Multiple isolated DataWeave engines per process (Node) W-23692110: Multiple isolated DataWeave engines per process (Node + Python) Aug 26, 2026

@svacas svacas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

P1: Failed engine destruction can leave Java holding a freed resolver context

bridge_finalize_registry() (

static void bridge_finalize_registry(engine_bridge_t* b) {
if (b == NULL || fn_destroy_engine == NULL) return;
uv_mutex_lock(&g_mutex);
// If the waiter already committed to physical teardown (TEARING_DOWN) or the
// isolate is already gone, the Java registry died/dies with it -- nothing to
// remove, and attaching would race graal_tear_down_isolate. Skip. Because
// the waiter publishes TEARING_DOWN (and Case 4 holds g_mutex across its
// g_active_ops==0 check + teardown) under this same lock, this check plus the
// increment below cannot be split by a teardown.
if (g_teardown_state == TEARDOWN_TEARING_DOWN || g_isolate == NULL) {
uv_mutex_unlock(&g_mutex);
return;
}
g_active_ops++; // pins the live isolate against teardown for this attach
uv_mutex_unlock(&g_mutex);
void* thread = NULL;
if (fn_attach_thread(g_isolate, &thread) == 0) {
fn_destroy_engine(thread, b->handle);
fn_detach_thread(thread);
}
// Verbatim g_active_ops release pattern.
uv_mutex_lock(&g_mutex);
g_active_ops--;
uv_cond_broadcast(&g_teardown_cond);
uv_mutex_unlock(&g_mutex);
}
// The non-isolate finalize phase: delete the resolver napi_ref (owner JS thread
// only, and only while its env is alive -- resolver-gated), free tracked result
// buffers, free the record. Touches no GraalVM isolate state, so it is safe to
// run after the g_active_ops reservation above is released.
static void bridge_finalize_free(engine_bridge_t* b, bool env_still_alive) {
if (b == NULL) return;
if (env_still_alive && b->resolver_js != NULL && b->env != NULL) {
napi_delete_reference(b->env, b->resolver_js);
}
resolver_results_free_all(b);
free(b);
}
// Thin wrapper preserving the original signature and every call site. Registry
// removal (if requested) runs first under its transient reservation, then the
// record is freed.
static void bridge_finalize(engine_bridge_t* b, bool env_still_alive, bool do_registry_remove) {
if (b == NULL) return;
if (do_registry_remove) bridge_finalize_registry(b);
bridge_finalize_free(b, env_still_alive);
) silently skips fn_destroy_engine when fn_attach_thread
fails, but bridge_finalize() still frees the bridge immediately afterward.

The Java registry then retains a CallbackWeaveResourceResolver whose opaque ctx points to freed memory. A later raw-FFI invocation of that handle can dereference the freed bridge in resolve_module_callback, causing
a use-after-free or crash. The resolver-less case also leaves a supposedly destroyed engine registered.

Do not free the bridge unless registry removal succeeds. Preserve it for retry or surface the destruction failure while retaining valid ownership state.

P2: Completion-sentinel OOM leaves streaming operations permanently pending

Both the streaming (

// Round-9 (#2): if even the sentinel struct cannot be allocated, we cannot
// enqueue a completion -- run the SAME native finalize the env-dead
// (napi_closing) branch below runs, so g_active_ops (already decremented
// above) plus the bridge in-flight hold and w are released and nothing is
// stranded. This is the "sentinel malloc NULL -> skip enqueue + unwind like
// the env-dead sentinel branch" path.
struct chunk_data* sentinel = malloc(sizeof(struct chunk_data));
if (sentinel == NULL) {
if (meta_result != OOM_JSON) free(meta_result);
free(w->script);
free(w->inputs_json);
bridge_end_op(w->bridge, /*env_still_alive=*/false);
free(w);
return;
) and transform
(
// Round-9 (#2): sentinel malloc NULL -> skip enqueue and run the same native
// finalize as the env-dead branch below (release the bridge hold + free w and
// all fields), so g_active_ops (already decremented above) and the in-flight
// hold are released. No self-join, no env-affine napi call, no tsfn release
// (see the env-dead branch's citation for why releasing the tsfns here is
// unsafe).
struct chunk_data* sentinel = malloc(sizeof(struct chunk_data));
if (sentinel == NULL) {
if (meta_result != OOM_JSON) free(meta_result);
free(w->script);
free(w->inputs_json);
free(w->input_name);
free(w->input_mime_type);
free(w->input_charset);
bridge_end_op(w->bridge, /*env_still_alive=*/false);
free(w);
return;
) workers handle completion-sentinel allocation failure by freeing the work record and
returning.

No completion is delivered, so:

  • The JavaScript promise remains pending forever.
  • The thread-safe function is not released.
  • Its context points to the freed work record.
  • A deferred bridge destruction may finalize from the wrong path while its environment cleanup hook remains registered.

Allocate the completion record before starting the worker, where allocation failure can be reported synchronously, or otherwise ensure the promise and thread-safe-function lifecycle are completed safely.

P2: Resolver tokens leak when isolate acquisition fails

NativeRuntime.initialize() (

def initialize(self) -> None:
if self.initialized:
return
self.lib, self.isolate = _acquire_isolate(self.lib_path)
try:
self.handle = self._create_engine()
except Exception:
# Roll back the ref we just took so a failed init leaks nothing.
self.lib = self.isolate = None
# Finding #2: install_resolver() registered a token BEFORE this call.
# A failed init must unregister it, or it leaks: self.initialized stays
# False, so a later cleanup() returns early and never reaches the pop.
if self._resolver_token:
with _resolver_lock_global:
_resolver_registry.pop(self._resolver_token, None)
self._resolver_token = 0
_release_isolate()
raise
) calls _acquire_isolate() outside the rollback
try block. Resolver tokens are therefore removed only when engine creation fails, not when library loading, ABI binding, isolate creation, or bootstrap detachment fails.

I reproduced this with a library-load failure: the token remained in _resolver_registry, retaining the runtime and resolver indefinitely. Retrying initialization registers another token, accumulating leaked entries.

Include isolate acquisition in the rollback scope and unregister the resolver token on every failed initialization path.

P2: Resolver-backed Python initialization is no longer idempotent

DataWeave.initialize() (

def initialize(self):
if self._resolve_module is not None:
self._native.install_resolver(self._resolve_module)
self._native.initialize()
) always calls install_resolver() before delegating
to the idempotent native initialization method. A second call on an initialized resolver-backed instance therefore raises Cannot install a resolver after initialize().

This regresses the previous behavior, where repeated initialize() calls were harmless. Return early when _native.initialized is already true and add resolver-backed double-initialization coverage.

P2: Raw Node initialization uses an unchecked non-string library path

napi_initialize() (

static napi_value napi_initialize(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
if (argc < 1) {
napi_throw_error(env, NULL, "initialize requires a library path argument");
return NULL;
}
char lib_path[4096];
size_t len;
napi_get_value_string_utf8(env, argv[0], lib_path, sizeof(lib_path), &len);
) ignores the status from both napi_get_cb_info and
napi_get_value_string_utf8. Passing a non-string through the exported raw FFI leaves lib_path unspecified before uv_dlopen uses it.

Other raw entrypoints explicitly validate their conversions, and the PR documents that guarantee. Validate this argument and add it to malformed-inputs.test.ts.

Status

Reviewed head 1ca9891. All macOS, Ubuntu, Windows, SAST, and credential-scanning checks pass. The C source also passes a syntax-only compile; git diff --check reports only a trailing blank line in native.py. I would
request changes based on the findings above.

@mlischetti

Copy link
Copy Markdown
Contributor Author

Review #10 remediation — all findings addressed (pushed 1ca9891..bb91394, 18 commits)

Remediates both the follow-up code review (docs/pr-157-follow-up-code-review-10.md, 9 findings) and @svacas's review (5 findings). Each fix is on its own commit, task-reviewed, and covered by a regression test; a broad whole-branch review (0 Critical/High/Medium findings) closed the round. Two findings were resolved deliberately differently from the literal suggestion — flagged ⚠︎ deviation below with reasoning, so you can push back if you disagree.

Follow-up review findings

  1. Critical — Java feeder may call a released callbackb3f0bc5. InputCallbackFeeder now carries a volatile cancelled flag checked after cb.invoke returns and before the next invoke; cleanupFeeder() sets cancel then joins with no finite timeout (interrupt-reassert loop), so the native call cannot return while the feeder can still invoke freed callback state. Regression: NativeLibFeederTest (500ms-blocking callback; pre-fix revert is red at ~5s with isAlive()==true).
  2. High — Python concurrent init leaks engines/isolatea01cfc2. initialize()/cleanup() are serialized per instance (_init_lock); two-thread regression test asserts a single engine + _isolate_ref_count==1.
  3. High — Python isolate failures strand isolatesa8b5632, 98571f1, a13d989 (+ test fast-follow e5200d5). Failed teardown now retains the live isolate and arms a retry (_teardown_needed, checked under _isolate_lock) instead of nulling globals; bootstrap-detach failure tears down the just-created isolate before any retry can create a second one.
  4. Medium — Node resolver inconsistent across APIs68d670d. ⚠︎ deviation: you offered "make them equivalent or make the limitation explicit and tested" — I took the latter. Custom-module resolution off the owner JS thread is a real feature and out of scope here; the streaming/transform fail-closed guard is deliberate (08-07 design §3/§7.4). Documented the exact rule (custom modules resolve in run() only; built-ins everywhere; streaming/transform fail closed to "not found") in the Node + Python READMEs, with a Node integration test (runStreaming fails cleanly for a custom module) and a Python analog asserting it.
  5. Medium — cleanup contracts/races040acc5 (+ 2306534, 94e51bf). Unknown-handle cleanup no longer attaches a possibly-torn-down isolate (g_mutex-guarded state check + g_active_ops++ pin in one critical section); lifecycle admission is mutex-protected. ⚠︎ deviation: you asked teardown failure to reject the cleanup() promise. I kept it resolving — rejecting breaks the documented coalescing/adoption contract and existing passing tests (multiple concurrent cleanup()s adopt one teardown; a newcomer initialize() can adopt a still-live isolate). Instead the failure is made observable and retryable: armed retry (g_teardown_needed) + stderr diagnostic, with Node now matching Python's long-standing resolve-and-retry behavior. If you'd still prefer a reject contract, it's a deliberate cross-binding semantics change I'd want to align on before making.
  6. Medium — xfail conceals regressions942e241, ba7a05a (+ bb91394). Only the output-equality assertion is expected-fail now; compile/execute must succeed for every case (fails loudly otherwise). Narrowing this surfaced that 6 of the 21 "accepted mismatches" actually fail at execution, not output (3 multipart → "Multipart Object has empty parts"; access_raw_value/read-concat → "Cannot coerce Null to String"; update-op → "Cannot coerce Null to Number"). Those 6 were recategorized from output-mismatch xfails to documented skips (they were already in the legacy ignore list with accurate reasons). TCK accounting is unchanged in spirit: selected=729, passed=676, failed=0, skipped=38, xfailed=15. I verified the multipart reclassification empirically (forcing them back to xfail fails the success===true assertion) and recorded that evidence in the reason strings — worth a look given it's a judgment call.
  7. Medium — standalone TCK passes without corpus10139fa, 2bb72ea. With DATAWEAVE_TCK_REQUIRE_CORPUS=1 (set on the dedicated master TCK lane) a missing/empty staged corpus now fails loudly instead of skipping; local dev without the flag still skips. The CI action stages the corpus explicitly before the lane.
  8. Medium — Python resolver init not idempotent4e897f2. DataWeave.initialize() returns early when already initialized, so a second call on a resolver-backed instance no longer raises; double-init coverage added. (Same as @svacas's idempotency finding.)
  9. Low — scope too broad; split Python testing modernizationnot actioned (process recommendation, not a code defect). Noted for the maintainer's call on whether to carve the Python pytest/TCK modernization into its already-planned follow-up PR. CI: the multi-OS build/SAST/credential checks are green on this branch.

@svacas review findings

  • P1 — freed resolver context on failed engine destroy2306534. bridge_finalize_registry() returns a status; the bridge is freed only when registry removal succeeds, otherwise retained via a stranded-bridge list drained under g_mutex. Fixed the same latent UAF at the two createEngine hook-failure sites.
  • P2 — completion-sentinel OOM leaves streaming pending94e51bf. The completion sentinel is pre-allocated in the synchronous setup of both streaming and transform; the worker terminal path is allocation-free, so OOM fails synchronously (full unwind: tsfn release, bridge unpin, g_active_ops--, throw) and the promise can never hang.
  • P2 — resolver tokens leak when isolate acquisition failsa8b5632. _acquire_isolate() moved inside the rollback scope with an acquired guard; the resolver token is unregistered on every failed init path (verified with a library-load failure).
  • P2 — resolver-backed init not idempotent4e897f2 (see Windows version #8).
  • P2 — unchecked non-string lib path in raw napi_initialize410b013. Now validates napi_get_cb_info argc + napi_typeof == string + napi_get_value_string_utf8 status, throwing synchronously before lib_path reaches uv_dlopen; added to malformed-inputs.test.ts.

Verification (reviewed head bb91394)

  • Node: 950 passed / 38 skipped; TCK selected=729, passed=676, failed=0, skipped=38, xfailed=15, accounted=729, unaccounted=0. No napi_reject_deferred introduced.
  • Python: 146 passed (unit lane).
  • Java: native-lib:test green (NativeLibFeederTest, ScriptRuntimeTest).
  • git diff --check clean. native-cli (Scala CLI) untouched this round.

@mlischetti

Copy link
Copy Markdown
Contributor Author

Review #11 remediation — 7 of 8 findings addressed (pushed bb91394..3a6daff, 8 commits)

Remediates docs/pr-157-follow-up-code-review-11.md. Each fix is on its own commit, task-reviewed, and covered by a regression test; a broad whole-branch review (0 Critical/High/Medium findings) closed the round. Finding #6 is a deliberate decline (CI-cost policy, not a code defect) — flagged below with reasoning so you can push back.

Findings

  1. High — transformViaCallbacks leak + exception escape + unsafe interpolation32e37aa. Input-session setup is extracted into a package-private setUpInputSession(...) that closes the handle on malformed input; the merged input entry is now built structurally (a JSONObject with streamHandle/mimeType/charset keys — no string interpolation); everything after register() runs under one top-level try { … } catch (Exception) { return errorEnvelope } finally { cleanupFeeder(…) }, so the session is always closed exactly once and no Java exception can escape the @CEntryPoint. Regression: NativeLibFeederTest (malformed input → success:false and handle closed).
  2. High — Python failed-teardown leaves the worker attached1fb6e02. _release_isolate and _retry_pending_teardown_locked now graal_detach_thread(worker) on the teardown-failure branch only (best-effort, tolerating detach failure) before arming _teardown_needed/raising; the success path never detaches (the thread pointer into a torn-down isolate is invalid — this is the highest-risk line and is deliberately left alone). Fault-injected regression: fail graal_tear_down_isolate once, assert the worker was detached, then a retry on another thread succeeds (red pre-fix). One residual documented: the _acquire_isolate double-failure branch (detach and teardown both fail) can leave the bootstrap thread attached — unfixable within the detach strategy (the failed thread's pointer isn't stored) and extremely rare; kept as the least-bad option.
  3. Medium — Python module-level convenience API race on first useba0c20f. A module-level _global_lock now guards candidate build + initialize() + publish + atexit.register as one critical section (the losing racer is structurally impossible); cleanup() nulls the global under the lock, then runs instance.cleanup() outside the lock (lock order: _global_lock outermost, released before the isolate work — no deadlock). Regression: 8 threads on a Barrier assert exactly one engine created + initialized. Behavior change: a failed module cleanup no longer retains the instance — the isolate-teardown retry is preserved independently via native.py's module-scoped _teardown_needed.
  4. Medium — raw input-callback length trusted without bounds checke80ea82. A shared rejectOutOfRange(n, max) helper records a volatile String feederError and returns a break-as-error sentinel; it is called from both readChunk (production) and a defensive guard in run() (the guard is what makes the check meaningful — tests override readChunk, and it prevents an out-of-range n reaching inputSession.write). transformViaCallbacks reads getError() after the output loop and returns success:false before success:true. Regression: readChunk returning max+1/-5 sets getError() with no OOB escaping run(); clean EOF leaves getError()==null.
  5. Medium — malformed input JSON executes with partial bindings67480ac. Removed the swallowing try/catch in ScriptRuntime.parseJsonInputsToBindings (kept the null/empty short-circuit) and moved the parse + bindingNames() inside the existing try in both run and runStreaming, so a malformed entry now fails closed (success:false envelope / StreamSession.ofError) instead of executing on partial, order-dependent inputs. Regression: malformed JSON, malformed second entry, and a valid-single-entry guard (no regression for good input) + a runStreaming error-session test.
  6. Medium — TCK skipped in PR CIdeclined. This is a CI-cost decision, not a code defect: the full TCK corpus stays a master-only lane. The round-10 DATAWEAVE_TCK_REQUIRE_CORPUS=1 gate still makes a missing corpus fail loudly on that lane, so behavior/classification changes are corpus-gated at merge to master. Happy to revisit if you'd rather pay for a path-filtered PR lane, but as a policy call I've left it master-only.
  7. Medium — known execution failures are unconditional skips31d3df9. Round 10 parked 6 execution-failure cases in CAPABILITY_EXCLUSIONS (skips never run, so a recovery/regression stays green forever). Added a third category EXPECTED_EXECUTION_FAILURES (6 entries with verified error discriminators); the harness now runs these cases and asserts result.success === false and result.error contains the discriminator — unexpected success or a changed message fails the test with a "remove/update it" hint. CAPABILITY_EXCLUSIONS is derived to exclude these keys (preserving the either/or invariant); validateReconciledPolicy gained 4-bucket cross-checks. One correction surfaced during implementation: update-op's actual runtime error is Cannot coerce Null (null) to Number (the (null) matters for toContain), not the review's Cannot coerce Null to Number.
  8. Low — policy tests only check aggregate countsc27deb6. Table-driven it.each over the 6 exec-failure scenarios asserts each is present in EXPECTED_EXECUTION_FAILURES with the correct errorMatch, absent from CAPABILITY_EXCLUSIONS, and absent from ACCEPTED_BASELINE_MISMATCHES, plus an exact-keys guard — so a wrong recategorization that preserves totals no longer passes.

3a6daff is a follow-on comment-accuracy fix from the final review (the feeder-error read comment now states the volatile-read guarantee rather than overstating feeder-EOF ordering).

Verification (reviewed head 3a6daff)

  • Node TCK: full corpus ran locally — selected=729, passed=676, failed=0, skipped=32, xfailed=21 (15 output-mismatch + 6 execution-failure). Accounting invariant holds: 676 + 32 + 15 + 6 = 729 (structural skips 193 counted separately). tck-policy.test.ts 14/14, tsc clean.
  • Java: native-lib:test green (NativeLibFeederTest, ScriptRuntimeTest) — 988 tests, 0 failures.
  • Python: unit lane green (119 facade + 112 native).
  • git diff --check clean. native-cli (Scala CLI) untouched this round.

@mlischetti mlischetti changed the title W-23692110: Multiple isolated DataWeave engines per process (Node + Python) @W-23692110: Multiple isolated DataWeave engines per process (Node + Python) Aug 28, 2026
mlischetti added a commit that referenced this pull request Aug 31, 2026
…review #19 #3)

Twin of the Python resolver design doc that review #17 banner-superseded.
Flips Status to Superseded and adds a banner linking the shipped
2026-08-07 multi-engine design, naming the three assertions PR #157 made
stale: (a) resolver scope -- custom resolvers apply to run() only, not
runStreaming/runTransform (built-ins resolve everywhere); (b) resolvers
are per-engine and handle-addressed, not one-per-process, with multiple
independent engines per process; (c) the old process-wide resolver ABI
is replaced by handle-based create_engine_with_resolver + run_script_engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mlischetti and others added 8 commits September 1, 2026 14:52
Addresses GUS W-23692110, discovered while implementing Node.js external
module support (#154). native-lib's ScriptRuntime is a static singleton
with a write-once resolver, so a second DataWeave instance in one Node
process silently reuses the first instance's resolver instead of getting
its own. Design: turn ScriptRuntime into a handle-addressable registry of
per-instance engines (one shared GraalVM isolate, following the pattern
native-cli's NativeRuntime already uses), with a per-handle resolver
bridge in the Node C addon. Python is out of scope here (tracked as a
follow-up) since it already gets isolation via one isolate per instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…egression test

Rewires ffi.ts and dataweave.ts to call the new handle-based N-API
methods (createEngine/createEngineWithResolver/destroyEngine/
runScriptEngine/runScriptStreamingEngine/runScriptTransformEngine)
added in Task 3, removing runWithResolver. Each DataWeave instance now
owns its own engineHandle, created on initialize() and destroyed on
cleanup(), so multiple instances with different resolvers no longer
cross-talk in the same process.

Adds independent-engines.test.ts proving two resolver-backed instances
resolve only their own modules, that a genuine script error on the new
handle-based run() path surfaces as success:false rather than an
unhandled throw (runScriptEngine now returns "" instead of throwing on
a NULL native result), and that runStreaming/runTransform correctly
thread the handle through addon.c's argument-shifted N-API wiring.
Deletes the now-obsolete first-resolver-wins regression test and
fixture, and rewrites dataweave-resolver.test.ts so each test builds
its own minimal resolver map instead of sharing a process-wide
"first resolver wins" module map.
…itialize() failure

If ffi.initialize() succeeded but engine creation (createEngine/
createEngineWithResolver) then threw, this.initialized stayed false,
so cleanup()'s early-return guard meant ffi.cleanup() was never called
-- permanently leaking that instance's increment of the native
library's ref-counted handle. initialize()'s catch block now releases
that ref-count itself (ffi.cleanup()) when ffi.initialize() already
succeeded, before wrapping and re-throwing.

Adds tests/unit/dataweave-initialize.test.ts, a new unit-lane test
(mocked ffi module, no dwlib required) exercising this exact
sequencing bug plus the surrounding invariants: no cleanup() call when
ffi.initialize() itself fails, no residual state after a failed
attempt, and no spurious cleanup() call on the successful path.
…ps (F1, F2)

Resolver-backed engine bridges could be freed while a background streaming/
transform uv_thread still dereferenced them via resolve_module_callback (F1),
and napi_cleanup deleted thread-affine napi_refs from whatever thread made the
last release (F2, undefined behavior across Workers).

F1: add in_flight/destroy_pending accounting (under g_mutex). Streaming/transform
setup pins the bridge via bridge_begin_op before spawning the worker thread; the
completion sentinel releases it via bridge_end_op on the owner thread. destroyEngine
unlinks immediately but defers the free (napi_ref delete + struct free) to the last
draining op when in_flight > 0.

F2: register a per-env cleanup hook (napi_add_env_cleanup_hook) per bridge at
creation so each Worker/main env disposes its own napi_ref on its own thread;
destroyEngine removes the hook before an early free. napi_cleanup no longer touches
g_bridges and only performs the process-global GraalVM isolate teardown once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…k (F3, F4)

create_engine/create_engine_with_resolver are GraalVM @CEntryPoints; if Java
construction throws, the entrypoint returns the long long default value (0)
instead of propagating. Treat any handle <= 0 as invalid: throw an N-API
error and unwind the bridge (delete napi_ref, free struct) before it's ever
linked into g_bridges or given a cleanup hook, instead of returning/inserting
a bogus handle.

Also fix a resolver-source buffer leak: if the malloc for the tracking node
itself fails, the buffer was previously left untracked and unfreeable.
resolver_results_track now reports tracking failure so
resolve_module_callback can free the buffer and report "unresolved" instead
of leaking it.
mlischetti and others added 23 commits September 1, 2026 14:56
…ts worker (review #16 #2)

Both teardown-failure branches (_release_isolate and _retry_pending_teardown_locked)
detached the just-attached worker with `try: graal_detach_thread(worker) except
Exception: pass`, discarding the nonzero STATUS graal_detach_thread returns on
failure. A failed detach left the worker attached while _teardown_needed stayed
armed, so the next retry attached ANOTHER worker on top of it -- and
graal_tear_down_isolate, needing the sole attached thread, was then permanently
blocked. Inspect the detach status; on a teardown-plus-detach double failure,
transition to the same explicit unrecoverable-leak state as the bootstrap double
failure: null the globals, do NOT arm a retry, retain no thread. A later
initialize() builds a fresh isolate. The detach-succeeds branch (retain live +
arm retry) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…leak window (review #16 #3)

The spec claimed the isolate exists iff the refcount > 0, but both bindings retain
a live isolate at zero refs after a failed teardown, and Python leaks one at zero
refs on a bootstrap or (review #16 #2) release double failure. Redefine the count
as outstanding ownership/init references: positive requires a live isolate; zero
may temporarily retain one pending retry or leave one leaked after the unrecoverable
path. Document the new release double-failure leak in §7.2 and §10. Verified the
public READMEs and test_ci_structure.py never made the iff claim (no change needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…py's header (review #16 #3)

Finding #16 #3's remediation redefined the "isolate exists iff refcount > 0"
invariant in the design spec, READMEs, and test_ci_structure but missed the
source-of-truth file's own header comment, which still asserted "_isolate is
not None iff the count > 0". That contradicts the retention/leak behavior
implemented in the same file (a detach-succeeds teardown failure leaves
_isolate non-None at refcount 0 with _teardown_needed armed; a double failure
leaks it). Align the comment with §7.1: the count is outstanding ownership/init
references -- positive requires a live isolate; zero may temporarily retain one
pending retry or leak one after an unrecoverable teardown. Comment-only; unit
suite unchanged (117 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…re (review #17 #1)

cleanup_thread_fn and teardown_waiter_thread_fn ignored fn_detach_thread's
return after a failed graal_tear_down_isolate. On a double failure the exiting
worker stayed attached while g_teardown_needed was armed, so future retries
attached more workers and teardown became permanently impossible.

Add a third teardown outcome (CLEANUP_UNRECOVERABLE) threaded through the shared
helper, its four synchronous callers, and the async waiter. On the double
failure, abandon_unrecoverable_isolate_locked() clears the published globals so a
later initialize() builds a fresh isolate, does NOT arm the retry, emits a
stderr diagnostic, and leaks the old isolate for the process lifetime -- the
Node twin of the Python policy shipped in review #16 #2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…RETAIN rename (review #17 #1)

The C4 comment still said "torn_down stays 0" after the local was renamed to
`result` initialized to CLEANUP_RETAIN. Comment-only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…etry machinery (review #17 #2)

§5 and §7 intro claimed Python needs "none of Node's retry machinery," contradicting
§7.1/§7.2 and shipped native.py, which implement _teardown_needed + a synchronous
teardown retry. Clarify: Python needs none of Node's ASYNCHRONOUS waiter/PENDING_WAIT/
adoption machinery, but does implement a simpler SYNCHRONOUS retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k contract (review #17 #3)

Both READMEs described teardown failure as flatly retryable, omitting the
intentional unrecoverable double-failure branch (teardown+detach, or the
bootstrap double failure) that resets published state, leaks the isolate for the
process lifetime, and lets a future initialization build a fresh one. Document
both contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iew #17 #4)

The 2026-08-24 Python-only resolver design predates the handle-based
shared-isolate model. Its run_script_with_resolver ABI, "no Java/Node changes,"
dedicated-isolate, and resolve-on-first-run claims are all stale. Add a
superseded banner pointing at 2026-08-07-native-lib-multi-engine-design.md and
listing the invalidated assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…6 (review #17 final)

§6.1/§6.2 (Node) described only the two-outcome teardown model and claimed the
retry signal is armed on any teardown failure, contradicting the new Node
leak-and-continue on the teardown-plus-detach double failure shipped in 9efb0d1.
Add the unrecoverable-leak branch to §6.2 and the zero-count leak clause to the
§6.1 invariant, mirroring the Python §7.1/§7.2/§10 treatment already present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nding-specific (review #18 #1)

The shared root README said ordinary failures retry "on the next initialization
or engine release" -- inaccurate for both bindings. Split by binding: Node
retries at the next initialization or async op-completion drain; Python retries
synchronously at the next initialization. Also attribute the bootstrap
double-failure to Python only (Node has no bootstrap-detach retry path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ysical reclamation (review #18 #2)

The Node package README said a final-reference cleanup() resolves once teardown
"has actually finished." Both failure paths still resolve the promise (ordinary
retryable failure, or unrecoverable double-failure leak). Document that cleanup()
guarantees logical release and completion of the teardown attempt (after draining
in-flight ops), not necessarily physical reclamation; ordinary failures retry
where safe, double failures intentionally leak with a diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…physical reclamation (review #19 #1 #2)

The root README code-example comment, the exported TypeScript cleanup()
TSDoc, its coalescing comment, and the unit-test rationale all promised
that a final-reference cleanup() resolves only once the isolate 'has
actually finished tearing down'. The shipped contract resolves once the
teardown ATTEMPT completes: it guarantees logical release, not physical
reclamation. An ordinary failure retains the live isolate and retries
where safe (later init or async op-completion drain); an unrecoverable
teardown-plus-detach double failure leaks the isolate until process exit
with a stderr diagnostic. Reworded all four spots to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…review #19 #3)

Twin of the Python resolver design doc that review #17 banner-superseded.
Flips Status to Superseded and adds a banner linking the shipped
2026-08-07 multi-engine design, naming the three assertions PR #157 made
stale: (a) resolver scope -- custom resolvers apply to run() only, not
runStreaming/runTransform (built-ins resolve everywhere); (b) resolvers
are per-engine and handle-addressed, not one-per-process, with multiple
independent engines per process; (c) the old process-wide resolver ABI
is replaced by handle-based create_engine_with_resolver + run_script_engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…20 low-pri)

- dataweave.ts: module-level coalescing comment quoted the old
  'resolves once native teardown has finished' contract; now
  'resolves once the native teardown attempt has completed', matching
  the instance-level twin corrected in review #19.
- dataweave-initialize.test.ts: drop the reference to the absent
  task-4-report.md planning artifact, and reword comments/test title
  that named a removed boolean 'initialized' field and the guard
  'if (this.initialized) return;' -- the class now uses the string
  state machine ('uninitialized' | 'ready' | 'cleaning-up').
- engine-handle-contract.test.ts: drop the reference to the absent
  task-6-report.md planning artifact.

Comment/test-name only; no behavioral change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…a poisoned isolate (review #20 #1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…laimed (review #20 #2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ach guard cannot short-circuit (review #20 final)

Final whole-branch review noted that graal_detach_thread and
graal_tear_down_isolate were dlsym'd but not in the required-symbol
gate. The review #20 #1 bootstrap-detach failure path guards on those
pointers (if (fn_detach_thread && ...) / fn_tear_down_isolate ? ...),
so a NULL fn_detach_thread would short-circuit and fall through to a
successful publish -- re-opening the exact phantom-attached-bootstrap-
thread wedge #1 closes. Add both to the required-symbol check so init
fails fast with a clear message and the guard's guarantee is
unconditional. Practically unreachable (GraalVM co-exports these with
graal_create_isolate) but makes the invariant explicit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e (review #21 #3 #4)

#3: create_engine_with_resolver ABI post-isolate order is (resolverCallback,
ctx) -- the spec had (ctx, trampoline) reversed at three sites, which would
lead a C/FFI consumer to pass the context where a function pointer is expected.
Show the correct order plus the full (isolateThread, resolverCallback, ctx)
signature.

#4: module-level cleanup() resolves once the teardown ATTEMPT completes
(logical release), not once physical teardown finishes -- align 6.4 with the
6.2 retry-on-ordinary-failure / leak-on-unrecoverable-failure model, matching
the wording already corrected in README.md and dataweave.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ls init instead of crashing createEngine (review #21 #2)

fn_attach_thread is called unconditionally on the engine-creation, execution,
and teardown paths (e.g. createEngine's fn_attach_thread(g_isolate, &thread)
has no NULL guard), but the required-symbol gate checked only create_isolate/
free_cstring/detach/tear_down. A dwlib missing graal_attach_thread passed init
and then invoked a NULL function pointer on the first createEngine(). Add it to
the gate so init fails fast with a clear missing-symbol error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wn leaks instead of hanging (review #21 #1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p without CPU-time support (review #21 #5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#21 #5 follow-up)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o (review #21 #1 completeness)

Final whole-branch review found one ordinary detach still unchecked: the
engine_bridge_t calloc-failure rollback in napi_create_engine. A detach
failure there strands a phantom thread that would wedge a later teardown --
the exact hang #1 eliminates everywhere else. Fold the capture+poison into
the existing g_active_ops-- critical section, matching the other sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mlischetti
mlischetti force-pushed the w-23692110-multi-engine-design branch from 595f22e to 566c701 Compare September 1, 2026 18:03
@svacas
svacas self-requested a review September 1, 2026 18:14
svacas
svacas previously approved these changes Sep 1, 2026
…ine surface

The dataweave-addon-path.test.ts added by master's ca09d69 mocks the legacy
singleton ffi surface (runScript/runWithResolver), which this branch removed.
The multi-engine DataWeave.initialize() now calls ffi.createEngine()/
createEngineWithResolver(), so the mock threw "No createEngine export" and
failed nodeTest after the rebase. Mock the current engine-handle surface
instead (matching dataweave-initialize.test.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants