Skip to content

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

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

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

Conversation

@mlischetti

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 the Node binding.
  • Each DataWeave Node instance now 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.
  • Legacy singleton entrypoints (run_script, run_script_callback, run_script_input_output_callback) and ScriptRuntime.getInstance() are unchanged, so the Python binding is unaffected. Python's own migration to per-instance engines is a separate follow-up.

Design

See docs/superpowers/specs/2026-08-07-native-lib-multi-engine-design.md (commit 729ed19) for the full design.

Test plan

  • ./gradlew native-lib:test --tests "org.mule.weave.lib.ScriptRuntimeTest" — registry isolation, cross-talk, and built-ins-only engines pass; legacy getInstance() tests unaffected.
  • ./gradlew native-lib:nativeCompile — new create_engine*/*_engine symbols exported, old *_with_resolver symbols removed.
  • ./gradlew native-lib:nodeTest — 854 passed / 59 skipped, including the new independent-engines regression and TCK conformance.
  • ./gradlew native-lib:pythonTest — 17/17 passed, confirming the legacy Python-facing surface is untouched.
  • Implemented and reviewed task-by-task (5 tasks) plus a final whole-branch review — APPROVE WITH MINOR FINDINGS (one non-blocking gap: no Node-layer test directly exercises the unknown/destroyed-handle error contract, though it's covered at the Java layer and by code inspection).

🤖 Generated with Claude Code

mlischetti and others added 6 commits August 7, 2026 18:05
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.
@mlischetti
mlischetti requested a review from a team as a code owner August 10, 2026 14:28
mlischetti and others added 8 commits August 10, 2026 17:38
…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.
… path

The handle <= 0 rejection path did manual napi_delete_reference + free(bridge)
instead of bridge_finalize, so any resolver-callback buffers already tracked
via resolver_results_track (if resolve_module_callback ran during a failed
eager module setup before construction was reported as failed) were leaked.
bridge_finalize already frees tracked buffers before freeing the struct and
is a safe drop-in here since the bridge was never linked into g_bridges or
given a cleanup hook at this point.
Node addon.c: fail initialize() with a clear message when dwlib lacks the
per-engine symbols (create_engine, create_engine_with_resolver,
destroy_engine, run_script_engine, run_script_callback_engine,
run_script_input_output_callback_engine) instead of deferring to a confusing
per-call error, since every initialize() now creates an engine.

CallbackWeaveResourceResolver.resolve(): suppress exception detail by
default and only log e.getMessage() when DATAWEAVE_RESOLVER_DEBUG=1, matching
the C-side resolve_module_callback policy in addon.c.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…free

The default (non-debug) branch of CallbackWeaveResourceResolver.resolve()'s
catch block still logged the module path unconditionally, which is dynamic,
resolver-controlled content. Drop path too in the default branch so the log
line is fully static, matching the C-side resolve_module_callback's actual
default behavior in addon.c.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds four resolver-backed integration tests to dataweave-resolver.test.ts
that exercise paths untested by prior remediation commits:

- a throwing resolveModule() causes run() to fail cleanly (success:false)
  rather than crash, exercising resolve_module_callback's exception
  catch/clear/log-gated-by-DATAWEAVE_RESOLVER_DEBUG path.
- a resolver-backed instance's initialize -> cleanup -> initialize cycle
  still resolves a custom module afterwards (fresh engine_bridge_t).
- cleanup() raced against an in-flight resolver-backed runStreaming() does
  not crash -- the regression test for the F1 in-flight-refcount fix,
  started deterministically by calling gen.next() without awaiting it
  before calling cleanup(), so the native call is already handed to the
  libuv worker thread when cleanup() runs on the JS thread.
- run() after cleanup() throws DataWeaveError via dataweave.ts's
  ensureInitialized() guard (the TS-level half of the destroyed/unknown
  engine handle contract).
Extracts the "Unknown engine handle" JSON literal shared by
run_script_engine, run_script_callback_engine, and
run_script_input_output_callback_engine into a single package-visible
constant (NativeLib.UNKNOWN_ENGINE_HANDLE_JSON), so the exact error
contract can be asserted from a plain JVM unit test. The @centrypoint
methods themselves can't be exercised directly from a JVM test since
their GraalVM word-type parameters (IsolateThread, CCharPointer) only
resolve inside a compiled native image.

Adds ScriptRuntimeTest#unknownEngineHandleProducesExactErrorJson,
which combines that constant assertion with the existing proof that
ScriptRuntime.get() returns null for an unregistered handle.
These were internal review artifacts incidentally committed during
remediation work (one references a local temp worktree path); they
aren't product documentation and shouldn't ship in the repo.
@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 and others added 14 commits August 11, 2026 10:01
The follow-up PR-157 review found that DataWeave.cleanup() can deadlock
the process when called while a runStreaming()/runTransform() operation
is still in flight: isolate teardown blocks the JS thread that a
mid-delivery worker's threadsafe-function call depends on. This design
makes teardown async and wait for active ops to drain via a dedicated
waiter thread, instead of blocking inline.
…completion wakeups

Changed uv_cond_signal to uv_cond_broadcast in the op-completion sentinels
(call_js_write and call_js_transform_write) to prevent the signal from being
stolen by a concurrent initialize() waiter, which would cause a deadlock where
teardown_waiter_thread_fn never receives the wakeup it needs to detect
g_active_ops reached 0.
…n failure

If uv_thread_create_ex() fails for the streaming or transform background
worker, nothing ever ran to decrement g_active_ops or release the resolver
bridge hold, permanently wedging cleanup(). Capture the spawn return value
and, on failure, unwind everything committed since the promise was created
(g_active_ops decrement, bridge_end_op, threadsafe function release, deferred
resolution with an error sentinel, and frees) in the same order as the
existing completion branches, minus the thread join.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
napi_cleanup case 5 ignored uv_thread_create_ex's return value when
spawning the teardown waiter thread. If the spawn fails, g_teardown_pending
would stay true forever, permanently blocking every future initialize()
and cleanup() call. Capture the spawn result and, on failure, roll back
g_teardown_pending, detach the enqueued waiter, resolve its promise inline,
release its threadsafe function, and restore g_ref_count to 1 so the
isolate is correctly treated as still live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
call_js_read early-returned without signaling req->cond when N-API invokes
it with env == NULL during environment teardown (e.g. a Worker terminating
mid-transform) while data is non-NULL. transform_read_cb blocks
synchronously on that same condition variable, so the early return left it
hung forever, stranding the worker thread's isolate detach.

Restructure to treat env == NULL (with live data) as a terminal read error:
set bytes_read = -1 and fall through to the existing signal block, so the
blocked waiter always wakes exactly once. The data == NULL branch (nothing
to signal) is untouched.

Also added confirming comments on call_js_write and call_js_transform_write
noting their env == NULL early-returns are not the same bug: their
completion path is driven by a separately-enqueued sentinel chunk, not a
synchronously-blocked waiter.
Node's exit hook runs synchronously, so an in-flight streaming/transform
operation gets abandoned if the process exits normally while cleanup()'s
drain hasn't finished. Add a beforeExit handler that awaits cleanup()
for the graceful common case, keeping exit as a synchronous last-ditch
fallback for process.exit()/signals where beforeExit never fires. A
cleanupStarted guard prevents the two hooks from double-driving cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mlischetti and others added 6 commits August 11, 2026 17:02
Previously the guard latched true on the first beforeExit and was never
reset, so a singleton revived after a beforeExit-driven cleanup would
register a new hook pair that could never fire cleanup() at the real
exit, silently defeating the graceful-drain guarantee. Resetting the
flag as the last step of cleanup() (after the drain finishes) fixes
this without affecting the exit handler's own guard check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…(F1)

Capture the return value of uv_thread_create_ex() at three sites
(napi_initialize, napi_cleanup case 4, and dw_napi_run_script) and only
call uv_thread_join() if the spawn succeeded. Fixes undefined behavior on
thread/resource exhaustion when joining an uninitialized thread handle.

Site A (napi_initialize): fail early with explicit error.
Site B (napi_cleanup case 4): best-effort degradation, clear global state
unconditionally (isolate teardown is a best-effort concern here).
Site C (dw_napi_run_script): fail fast with explicit error; same pattern as
Site A since runScript has no valid degraded fallback and no deferred result.
Free script/inputs buffers on error path to avoid leak.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
call_js_write and call_js_transform_write early-returned on env == NULL,
skipping all native cleanup (worker thread join, threadsafe function
release, bridge_end_op, and every heap free) for the completion sentinel
when Node invokes the tsfn callback during env/Worker teardown. This
could leak the work struct and strand a bridge marked for deferred
destruction indefinitely.

Restructure both callbacks so env == NULL still performs full native
finalization on the sentinel path (join, tsfn release(s), bridge_end_op,
frees), skipping only the napi-value/JS-calling calls
(napi_create_string_utf8/napi_resolve_deferred) that require a live env.
A non-sentinel data chunk arriving with env == NULL now frees chunk->buf/
chunk instead of leaking them, without touching the work struct.

Confirmed via the N-API docs that napi_release_threadsafe_function (whose
signature takes no env and is documented as callable from any thread) and
uv_thread_join are legal to call during this env == NULL invocation; only
JS-calling/napi-value-producing APIs are restricted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
teardown_waiter_thread_fn unconditionally cleared g_thread, g_isolate,
g_initialized, and g_ref_count after attempting to attach a waiter
thread and tear down the isolate, even if the attach step failed. When
attach fails, the underlying isolate is still alive but becomes
unreachable through the addon's globals, so a later initialize() would
create a second isolate and the original could never be torn down.

Track whether teardown actually happened (or whether there was nothing
to tear down in the first place) and only clear those four globals in
that case. g_teardown_pending still clears unconditionally, since
leaving it set would permanently wedge future initialize()/cleanup()
calls; on the attach-failure path g_initialized stays 1 and g_isolate
stays non-NULL, so napi_initialize's wait guard passes and ref-counts
the existing isolate instead of building a second one, and the failed
teardown is retried on the next last-release cleanup().
…t strand DataWeave

If ffi.cleanup() rejects, the previous code left `initialized` stuck true
even though engineHandle was already nulled, permanently short-circuiting a
later initialize() via its no-op guard. Wrap the body in try/finally so
`initialized` is always cleared, letting the instance be re-initialized
after a failed cleanup.

Adds a regression test that stubs ffi.cleanup() to reject once, awaits the
rejection, then asserts a subsequent initialize() actually calls
ffi.initialize()/createEngine() again rather than no-op'ing.
Two Important findings from the final whole-branch review of this
remediation round:

1. bridge_finalize deleted a bridge's resolver napi_ref based only on
   b->env being non-NULL, which stays true even after the owning env
   dies. The env==NULL sentinel path in call_js_write/
   call_js_transform_write can reach bridge_finalize via bridge_end_op
   while that same env is tearing down, violating N-API's env-liveness
   contract. Thread an explicit env_still_alive flag through
   bridge_end_op/bridge_finalize from every call site so the ref
   deletion is skipped whenever the owning env is known dead; Node
   auto-reclaims the ref in that case, so nothing leaks.

2. napi_cleanup's case 4 fast path unconditionally cleared
   g_thread/g_isolate/g_initialized/g_ref_count after spawning
   cleanup_thread_fn, even though that thread can silently return
   without tearing down the isolate (attach failure). Mirror the
   torn_down out-param pattern already used by
   teardown_waiter_thread_fn: cleanup_thread_fn now reports whether it
   actually tore down (or had nothing to tear down) via an int*
   out-param, and case 4 only clears the globals when torn_down is
   true -- otherwise the isolate stays reachable for a future
   initialize() instead of being orphaned.

Verified: npm run build:addon succeeds; npm test passes 864/59
skipped/0 failed, matching baseline.
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.

1 participant