From 20f805df2f9e276fddca0998c153d78e9efaf90c Mon Sep 17 00:00:00 2001 From: Will Killian Date: Thu, 30 Jul 2026 18:14:57 -0400 Subject: [PATCH 1/5] fix(node): preserve async callback context Signed-off-by: Will Killian --- .../core/tests/integration/pipeline_tests.rs | 28 ++++--- crates/node/src/api/mod.rs | 17 +++-- crates/node/src/callback_factory.rs | 53 ++++++++++--- crates/node/src/promise_call.rs | 11 ++- crates/node/tests/context_tests.mjs | 23 ++++++ crates/node/tests/llm_tests.mjs | 75 +++++++++++++++++++ crates/node/tests/tools_tests.mjs | 59 +++++++++++++-- docs/about-nemo-relay/concepts/scopes.mdx | 16 +++- python/tests/test_llm.py | 24 +++++- 9 files changed, 267 insertions(+), 39 deletions(-) diff --git a/crates/core/tests/integration/pipeline_tests.rs b/crates/core/tests/integration/pipeline_tests.rs index 9cb7e67d6..f14d66c1f 100644 --- a/crates/core/tests/integration/pipeline_tests.rs +++ b/crates/core/tests/integration/pipeline_tests.rs @@ -2086,22 +2086,28 @@ async fn test_dropped_stream_end_keeps_fifo_position_before_later_mark() { ) .unwrap(); - let (release_started_tx, release_started_rx) = std::sync::mpsc::channel(); - let release_thread = std::thread::spawn(move || { - release_started_tx.send(()).unwrap(); - std::thread::sleep(std::time::Duration::from_millis(50)); - sanitizer_release.notify_one(); + let (flush_started_tx, flush_started_rx) = std::sync::mpsc::channel(); + let (flush_done_tx, flush_done_rx) = std::sync::mpsc::channel(); + let flush_thread = std::thread::spawn(move || { + flush_started_tx.send(()).unwrap(); + flush_done_tx.send(flush_subscribers()).unwrap(); }); - release_started_rx + flush_started_rx .recv_timeout(std::time::Duration::from_secs(2)) - .expect("sanitizer release thread did not start"); - let flush_started = std::time::Instant::now(); - flush_subscribers().unwrap(); + .expect("subscriber flush thread did not start"); assert!( - flush_started.elapsed() >= std::time::Duration::from_millis(50), + matches!( + flush_done_rx.recv_timeout(std::time::Duration::from_millis(50)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ), "flush must wait for the pending stream END" ); - release_thread.join().unwrap(); + sanitizer_release.notify_one(); + flush_done_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("subscriber flush did not complete after sanitizer release") + .unwrap(); + flush_thread.join().unwrap(); let events = events.lock().unwrap(); let end_index = events diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index 12aff6e7f..f0721f70b 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -485,9 +485,10 @@ pub fn push_stream_chunk(stream_id: f64, chunk: Json) -> bool { /// Signal that a stream is complete. Drops the sender so the Rust /// receiver sees the channel as closed. #[napi] -pub fn end_stream(stream_id: f64) { +pub fn end_stream(env: Env, stream_id: f64) -> napi::Result<()> { let id = stream_id as u64; finish_stream_channel(id, Ok(())); + callback_factory::expire_callback_context(&env) } /// # Safety @@ -1720,10 +1721,13 @@ pub fn create_scope_stack_from_propagation( .map_err(|error| napi::Error::from_reason(error.to_string())) } -/// Run a synchronous callback with an isolated scope stack installed. +/// Run a callback with an isolated scope stack installed. /// -/// The stack is restored before this function returns. Asynchronous callbacks -/// must not rely on this installation after their first `await`. +/// The caller's stack is restored immediately after callback invocation. When +/// the callback returns a Promise, the requested stack remains active for that +/// Promise until it settles and is then expired for inherited detached work. +/// Use this helper instead of `setThreadScopeStack` to isolate concurrent async +/// branches. #[napi] pub fn with_scope_stack( env: Env, @@ -1752,7 +1756,10 @@ pub fn current_scope_stack(env: Env) -> napi::Result { Ok(ScopeStack::from(current_scope_stack_handle())) } -/// Binds a scope stack to the current thread. +/// Binds a scope stack to the current thread or async resource. +/// +/// This mutates the current execution resource. Use `withScopeStack` when +/// concurrent asynchronous branches need isolated stack replacements. #[napi] pub fn set_thread_scope_stack(env: Env, stack: &ScopeStack) -> napi::Result<()> { if callback_factory::set_callback_scope_stack(&env, stack)? { diff --git a/crates/node/src/callback_factory.rs b/crates/node/src/callback_factory.rs index 8bf1acca3..3c6d82d29 100644 --- a/crates/node/src/callback_factory.rs +++ b/crates/node/src/callback_factory.rs @@ -10,7 +10,7 @@ use nemo_relay::api::runtime::subscriber_dispatcher::PublicationBuffer; use crate::types::ScopeStack; -const CALLBACK_FACTORIES_PROPERTY: &str = "__nemo_relay_callback_factories_v9"; +const CALLBACK_FACTORIES_PROPERTY: &str = "__nemo_relay_callback_factories_v10"; const CALLBACK_FACTORIES_SOURCE: &str = r#"(() => { const { AsyncLocalStorage } = process.getBuiltinModule('node:async_hooks'); @@ -69,6 +69,7 @@ const CALLBACK_FACTORIES_SOURCE: &str = r#"(() => { publication, publicationContextId, scopeStack, + propagationParentUuid, registerAbort, ) { const controller = new AbortController(); @@ -97,6 +98,7 @@ const CALLBACK_FACTORIES_SOURCE: &str = r#"(() => { publicationState, publicationContextId, scopeStack, + propagationParentUuid, }; const settlePublication = () => { if (ownsPublicationState) { @@ -104,6 +106,7 @@ const CALLBACK_FACTORIES_SOURCE: &str = r#"(() => { publicationStates.delete(publicationContextId); } token.scopeStack = null; + token.propagationParentUuid = undefined; }; const safeNext = next === undefined ? undefined @@ -164,6 +167,7 @@ const CALLBACK_FACTORIES_SOURCE: &str = r#"(() => { publication, publicationContextId, scopeStack, + propagationParentUuid, registerAbort, ) { if (error != null) { @@ -186,6 +190,7 @@ const CALLBACK_FACTORIES_SOURCE: &str = r#"(() => { publication, publicationContextId, scopeStack, + propagationParentUuid, registerAbort, ); }; @@ -225,20 +230,32 @@ const CALLBACK_FACTORIES_SOURCE: &str = r#"(() => { withCallbackScopeStack(scopeStack, fn) { const current = eventSanitizerContext.getStore(); - if (current === undefined) { - return { active: false }; - } const token = { - publicationState: current.publicationState, - publicationContextId: current.publicationContextId, + publicationState: current?.publicationState ?? { active: false }, + publicationContextId: current?.publicationContextId, scopeStack, - propagationParentUuid: current.propagationParentUuid, + propagationParentUuid: current?.propagationParentUuid, }; - try { - return { active: true, value: eventSanitizerContext.run(token, fn) }; - } finally { + const expire = () => { token.scopeStack = null; + token.propagationParentUuid = undefined; + }; + let value; + try { + value = eventSanitizerContext.run(token, fn); + } catch (error) { + expire(); + throw error; } + if ( + value !== null + && (typeof value === 'object' || typeof value === 'function') + && typeof value.then === 'function' + ) { + return { active: true, value: Promise.resolve(value).finally(expire) }; + } + expire(); + return { active: true, value }; }, setCallbackScopeStack(scopeStack) { @@ -254,6 +271,15 @@ const CALLBACK_FACTORIES_SOURCE: &str = r#"(() => { }); return true; }, + + expireCallbackContext() { + const current = eventSanitizerContext.getStore(); + if (current === undefined) { + return; + } + current.scopeStack = null; + current.propagationParentUuid = undefined; + }, }; })()"#; @@ -361,6 +387,13 @@ pub(crate) fn callback_propagation_parent_uuid(env: &Env) -> napi::Result