From 3fdc1a64733f57c76acc3cf90d62c6bbbdda01fa Mon Sep 17 00:00:00 2001 From: Sree Narayanan Date: Wed, 21 Oct 2026 13:49:18 +0400 Subject: [PATCH] fix(runtime): complete a context call when its result settles A context call or JavaScript/TypeScript evaluation now completes when its top-level evaluation settles and its in-flight bridge calls finish, instead of waiting for the guest event loop to drain. Leftover timers and immediates are cancelled and ref'd sockets, servers, dgram sockets, and child processes are closed, with a summary on stderr. Context globals survive. One-shot execute keeps Node's wait-for-ref'd-handles behavior. Previously a leftover setInterval or listening server turned a finished call into timed_out and killed the context. The evaluation wrapper no longer needs a keep-alive interval. Fixes #1991 --- crates/execution/src/javascript.rs | 18 ++- .../native-sidecar/src/language_execution.rs | 40 +++--- .../tests/language_execution.rs | 85 +++++++++++++ crates/v8-runtime/CLAUDE.md | 1 + crates/v8-runtime/src/embedded_runtime.rs | 3 +- crates/v8-runtime/src/execution.rs | 23 ++++ crates/v8-runtime/src/runtime_protocol.rs | 4 +- crates/v8-runtime/src/session.rs | 117 ++++++++++++++---- docs/content/docs/javascript.mdx | 22 +++- .../bridge-src/builtins/active-handles.ts | 35 +++++- .../bridge-src/builtins/child-process.ts | 4 +- .../build-tools/bridge-src/builtins/dgram.ts | 2 +- .../build-tools/bridge-src/builtins/net.ts | 4 +- .../build-tools/bridge-src/global-exposure.ts | 6 + .../build-tools/scripts/build-v8-bridge.mjs | 1 + 15 files changed, 310 insertions(+), 55 deletions(-) diff --git a/crates/execution/src/javascript.rs b/crates/execution/src/javascript.rs index 8303f62037..4faaee424c 100644 --- a/crates/execution/src/javascript.rs +++ b/crates/execution/src/javascript.rs @@ -47,6 +47,7 @@ const NODE_KEEP_STDIN_OPEN_ENV: &str = "AGENTOS_KEEP_STDIN_OPEN"; const NODE_GUEST_ENTRYPOINT_ENV: &str = "AGENTOS_GUEST_ENTRYPOINT"; const NODE_GUEST_ENTRYPOINT_MODULE_MODE_ENV: &str = "AGENTOS_GUEST_ENTRYPOINT_MODULE_MODE"; const NODE_RETAIN_CONTEXT_ENV: &str = "AGENTOS_RETAIN_LANGUAGE_CONTEXT"; +const NODE_COMPLETE_ON_SETTLE_ENV: &str = "AGENTOS_COMPLETE_ON_SETTLE"; const NODE_INLINE_FILE_PATH_ENV: &str = "AGENTOS_INLINE_FILE_PATH"; const NODE_USE_BUNDLED_TYPESCRIPT_ENV: &str = "AGENTOS_USE_BUNDLED_TYPESCRIPT"; const NODE_TYPESCRIPT_COMPILER_PATH_ENV: &str = "AGENTOS_TYPESCRIPT_COMPILER_PATH"; @@ -280,6 +281,7 @@ const RESERVED_NODE_ENV_KEYS: &[&str] = &[ NODE_IMPORT_CACHE_PATH_ENV, NODE_KEEP_STDIN_OPEN_ENV, NODE_RETAIN_CONTEXT_ENV, + NODE_COMPLETE_ON_SETTLE_ENV, NODE_USE_BUNDLED_TYPESCRIPT_ENV, NODE_TYPESCRIPT_COMPILER_PATH_ENV, NODE_ALLOWED_BUILTINS_ENV, @@ -2952,12 +2954,18 @@ impl JavascriptExecutionEngine { record_js_start_phase("js_start_install_module_reader", phase_start.elapsed()); let phase_start = Instant::now(); - let retain_context = request - .env - .get(NODE_RETAIN_CONTEXT_ENV) - .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); + let env_flag = |key: &str| { + request + .env + .get(key) + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) + }; + let retain_context = env_flag(NODE_RETAIN_CONTEXT_ENV); + let complete_on_settle = env_flag(NODE_COMPLETE_ON_SETTLE_ENV); let prepared_execute = PreparedJavascriptExecute { - mode: u8::from(use_module_mode) | if retain_context { 2 } else { 0 }, + mode: u8::from(use_module_mode) + | if retain_context { 2 } else { 0 } + | if complete_on_settle { 4 } else { 0 }, file_path: execution_file_path, bridge_code: V8RuntimeHost::bridge_code().to_owned(), post_restore_script: request diff --git a/crates/native-sidecar/src/language_execution.rs b/crates/native-sidecar/src/language_execution.rs index 3e91972dca..62055b2e80 100644 --- a/crates/native-sidecar/src/language_execution.rs +++ b/crates/native-sidecar/src/language_execution.rs @@ -36,6 +36,7 @@ const TTY_ENV: &str = "AGENTOS_EXEC_TTY"; const TTY_COLS_ENV: &str = "AGENTOS_EXEC_TTY_COLS"; const TTY_ROWS_ENV: &str = "AGENTOS_EXEC_TTY_ROWS"; const RETAIN_LANGUAGE_CONTEXT_ENV: &str = "AGENTOS_RETAIN_LANGUAGE_CONTEXT"; +const COMPLETE_ON_SETTLE_ENV: &str = "AGENTOS_COMPLETE_ON_SETTLE"; const GUEST_ENTRYPOINT_MODULE_MODE_ENV: &str = "AGENTOS_GUEST_ENTRYPOINT_MODULE_MODE"; const INLINE_FILE_PATH_ENV: &str = "AGENTOS_INLINE_FILE_PATH"; const USE_BUNDLED_TYPESCRIPT_ENV: &str = "AGENTOS_USE_BUNDLED_TYPESCRIPT"; @@ -115,6 +116,17 @@ fn inline_inputs_prefix(inputs: Option, python: bool) -> String { } } +/// Wrap an evaluation so its result promise is the entry evaluation the +/// runtime waits for: the completion value of a script, or a top-level +/// `await` in a module. The call completes when that promise settles. +fn evaluation_source(expression: &str, result_path: &str, module: bool) -> String { + format!( + "{{ {}Promise.resolve((async () => ({expression}))()).then((value) => {{ let result; try {{ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') throw new TypeError('undefined, functions, and symbols are not supported'); result = JSON.stringify({{ __agentosEvaluation: true, ok: true, value }}); }} catch (error) {{ result = JSON.stringify({{ __agentosEvaluation: true, ok: false, error: `AgentOS evaluation result must be JSON-serializable: ${{error instanceof Error ? error.message : String(error)}}` }}); }} require('node:fs').writeFileSync({}, result); }}); }}", + if module { "await " } else { "" }, + serde_json::to_string(result_path).expect("semantic result path serialization cannot fail") + ) +} + fn semantic_result_path() -> String { static NEXT_RESULT_ID: AtomicU64 = AtomicU64::new(1); let nonce = NEXT_RESULT_ID.fetch_add(1, Ordering::Relaxed); @@ -547,14 +559,10 @@ fn lower_operation(payload: RequestPayload) -> Result {{}}, 2147483647); Promise.resolve((async () => ({}))()).then((value) => {{ let result; try {{ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') throw new TypeError('undefined, functions, and symbols are not supported'); result = JSON.stringify({{ __agentosEvaluation: true, ok: true, value }}); }} catch (error) {{ result = JSON.stringify({{ __agentosEvaluation: true, ok: false, error: `AgentOS evaluation result must be JSON-serializable: ${{error instanceof Error ? error.message : String(error)}}` }}); }} require('node:fs').writeFileSync({}, result); }}).finally(() => clearInterval(keepAlive)); }}", - payload.expression, - serde_json::to_string(&result_path) - .expect("semantic result path serialization cannot fail") + source.push_str(&evaluation_source( + &payload.expression, + &result_path, + module, )); let mut operation = lowered_process(payload.process, "node", vec![String::from("-e"), source]); @@ -571,6 +579,9 @@ fn lower_operation(payload: RequestPayload) -> Result Result {{}}, 2147483647); Promise.resolve((async () => ({}))()).then((value) => {{ let result; try {{ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') throw new TypeError('undefined, functions, and symbols are not supported'); result = JSON.stringify({{ __agentosEvaluation: true, ok: true, value }}); }} catch (error) {{ result = JSON.stringify({{ __agentosEvaluation: true, ok: false, error: `AgentOS evaluation result must be JSON-serializable: ${{error instanceof Error ? error.message : String(error)}}` }}); }} require('node:fs').writeFileSync({}, result); }}).finally(() => clearInterval(keepAlive)); }}", - payload.expression, - serde_json::to_string(&result_path) - .expect("semantic result path serialization cannot fail") - )); + source.push_str(&evaluation_source(&payload.expression, &result_path, false)); let source = transform_retained_typescript_module(&source, &file_path)?; let mut operation = lowered_process(payload.process, "node", vec![String::from("-e"), source]); @@ -619,6 +622,9 @@ fn lower_operation(payload: RequestPayload) -> Result, + context_id: &str, + format: wire::JavaScriptModuleFormat, + expression: &str| { + let mut process = context_process_options(context_id); + process.timeout_ms = Some(5_000); + request_id += 1; + let started = sidecar + .dispatch_wire_blocking(wire_request( + request_id, + wire_vm(&connection_id, &session_id, &vm_id), + wire::RequestPayload::JavaScriptEvaluationRequest( + wire::JavaScriptEvaluationRequest { + process, + expression: expression.to_owned(), + format: Some(format), + file_path: None, + inputs: None, + }, + ), + )) + .expect("start context evaluation"); + let execution_id = accepted_execution_id(started); + wait_for_execution(sidecar, &connection_id, &session_id, &vm_id, &execution_id) + }; + + for (context_id, format) in [ + ("leftover-module", wire::JavaScriptModuleFormat::Module), + ("leftover-commonjs", wire::JavaScriptModuleFormat::CommonJs), + ] { + create_context( + &mut sidecar, + &connection_id, + &session_id, + &vm_id, + context_id, + ); + let first = evaluate( + &mut sidecar, + context_id, + format.clone(), + "(setInterval(() => {}, 50), globalThis.answer = 41)", + ); + assert_eq!( + first.outcome, + wire::ExecutionOutcome::Succeeded, + "{context_id}: a leftover interval must not fail the call: {:?}", + first.error + ); + assert_eq!(first.evaluation_value.as_deref(), Some("41")); + + let second = evaluate(&mut sidecar, context_id, format, "globalThis.answer + 1"); + assert_eq!( + second.outcome, + wire::ExecutionOutcome::Succeeded, + "{context_id}: {:?}", + second.error + ); + assert_eq!( + second.evaluation_value.as_deref(), + Some("42"), + "{context_id}: the context must keep state set by the first call" + ); + } + + dispose_vm_and_close_session_wire(&mut sidecar, &connection_id, &session_id, &vm_id); +} + #[test] fn evaluation_rejects_non_json_values_with_a_structured_result() { let mut sidecar = new_sidecar("language-execution-json-evaluation"); diff --git a/crates/v8-runtime/CLAUDE.md b/crates/v8-runtime/CLAUDE.md index 9afca1a2d7..15109b31a1 100644 --- a/crates/v8-runtime/CLAUDE.md +++ b/crates/v8-runtime/CLAUDE.md @@ -8,6 +8,7 @@ - In `src/session.rs`, top-level `SessionMessage` handling must never silently drop late `BridgeResponse`, `StreamEvent`, or `TerminateExecution` frames after an execution finishes. If there is no active event loop to consume them, emit a structured `RuntimeEvent::Log` warning with an explicit `ERR_LATE_*` code so host-side diagnostics can see the loss. - In `src/session.rs`, sync bridge waits need the same per-execution abort path for both CPU timeouts and explicit `TerminateExecution` requests. If a terminate signal only hits `isolate_handle.terminate_execution()` and does not also close the active bridge-wait receiver, a guest blocked inside a sync host call will hang until the host replies. - In `src/session.rs`, `run_event_loop()` must consult `_getPendingTimerCount()` directly in its exit checks, not only pending promises or `_waitForActiveHandles()`. Some callers enter the loop without first registering the keepalive promise, and otherwise a lone ref'd `setInterval()` can let the session exit between ticks. +- In `src/session.rs`, `Execute` mode bit `2` (retained context) or bit `4` (semantic evaluation) means complete-on-settle: the loop exits when the entry evaluation settles (`EventLoopExit::EvaluationSettled`), then `_releaseLeftoverHandles()` cancels leftover timers and closes ref'd handles, then the loop runs until no async bridge call is in flight (`EventLoopExit::BridgeIdle`). Never apply `_waitForActiveHandles()` to these calls; the idle session loop does not run guest callbacks, so anything left would freeze or keep the call open until its timeout. - Session-quota regressions now live in `src/embedded_runtime.rs`, not a removed daemon `main.rs`: keep one `SessionManager` per `EmbeddedV8Runtime`, share it across every runtime handle/connection to that instance, and cover quota behavior in `tests/embedded_runtime_session.rs`. - In `tests/embedded_runtime_session.rs`, shared-quota assertions must saturate the runtime slots before registering any session that is supposed to stay queued. `SessionManager` acquires slots during `CreateSession`, so relying on creation order alone makes queued-session tests race with the sessions meant to hold the slots. - Guest `process.memoryUsage()`, `process.cpuUsage()`, `process.resourceUsage()`, and live `process.versions.v8` should be resolved locally on the V8 session thread in `src/bridge.rs`, using `v8::HeapStatistics`, `getrusage(RUSAGE_THREAD)`, and the bundled library version APIs. Do not reintroduce stale JS literals or route these per-isolate values back through sidecar RPC. diff --git a/crates/v8-runtime/src/embedded_runtime.rs b/crates/v8-runtime/src/embedded_runtime.rs index 919177c9ed..2d3a02a08e 100644 --- a/crates/v8-runtime/src/embedded_runtime.rs +++ b/crates/v8-runtime/src/embedded_runtime.rs @@ -593,8 +593,9 @@ impl EmbeddedV8SessionHandle { } } +// Mode bits: 1 = module, 2 = retained context, 4 = complete on settle. fn validate_execute_mode(mode: u8) -> io::Result<()> { - if mode > 3 { + if mode > 7 { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("unknown Execute mode: {mode}"), diff --git a/crates/v8-runtime/src/execution.rs b/crates/v8-runtime/src/execution.rs index 299d72129a..6acf3b284d 100644 --- a/crates/v8-runtime/src/execution.rs +++ b/crates/v8-runtime/src/execution.rs @@ -1121,6 +1121,29 @@ pub fn finalize_pending_script_evaluation( } } +/// Cancel the timers and close the handles a settle-complete call left behind. +/// The bridge owns both, so a context without the bridge has nothing to release. +pub fn release_leftover_handles(scope: &mut v8::HandleScope) -> Result<(), ExecutionError> { + let tc = &mut v8::TryCatch::new(scope); + let context = tc.get_current_context(); + let global = context.global(tc); + let Some(key) = v8::String::new(tc, "_releaseLeftoverHandles") else { + return Ok(()); + }; + let Some(release) = global + .get(tc, key.into()) + .and_then(|value| v8::Local::::try_from(value).ok()) + else { + return Ok(()); + }; + release.call(tc, global.into(), &[]); + tc.perform_microtask_checkpoint(); + match tc.exception() { + Some(exception) => Err(exception_to_result(tc, exception).1), + None => Ok(()), + } +} + fn serialize_module_exports( scope: &mut v8::HandleScope, module: v8::Local, diff --git a/crates/v8-runtime/src/runtime_protocol.rs b/crates/v8-runtime/src/runtime_protocol.rs index 0b1377db7c..17d4f47e31 100644 --- a/crates/v8-runtime/src/runtime_protocol.rs +++ b/crates/v8-runtime/src/runtime_protocol.rs @@ -209,7 +209,7 @@ impl TryFrom for RuntimeCommand { high_resolution_time, user_code, } => { - if mode > 3 { + if mode > 7 { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("unknown Execute mode: {mode}"), @@ -355,7 +355,7 @@ mod tests { fn rejects_unknown_execute_mode() { let err = RuntimeCommand::try_from(BinaryFrame::Execute { session_id: "s".into(), - mode: 4, + mode: 8, file_path: "/app/main.mjs".into(), bridge_code: String::new(), post_restore_script: String::new(), diff --git a/crates/v8-runtime/src/session.rs b/crates/v8-runtime/src/session.rs index 497ef500cf..062283e103 100644 --- a/crates/v8-runtime/src/session.rs +++ b/crates/v8-runtime/src/session.rs @@ -2539,6 +2539,10 @@ fn session_thread( // receiving a fresh context for every Execute message. let retain_context = mode & 2 != 0; let module_mode = mode & 1 != 0; + // Context calls and semantic evaluations complete when + // their entry evaluation settles, not when the event + // loop drains; leftover handles are released instead. + let complete_on_settle = mode & 6 != 0; let exec_context = if retain_context { _v8_context .as_ref() @@ -2907,7 +2911,7 @@ fn session_thread( // are visible yet — the module body may have registered // timers, stdin listeners, or child_process handles that // need event loop pumping to deliver their callbacks. - let should_enter_event_loop = !pending.is_empty() + let should_enter_event_loop = (!complete_on_settle && !pending.is_empty()) || execution::has_pending_module_evaluation() || execution::has_pending_script_evaluation(); let event_loop_status = if should_enter_event_loop { @@ -2923,6 +2927,11 @@ fn session_thread( bridge_responses: Some(&async_response_rx), abort: Some(&abort_rx), pause: Some(&pause_control), + exit: if complete_on_settle { + EventLoopExit::EvaluationSettled + } else { + EventLoopExit::Drained + }, }, &pending, ) @@ -2955,13 +2964,59 @@ fn session_thread( // Keep the session alive while handles (timers, child // processes, stdin listeners) are active. Long-lived // Persistent services often run as plain scripts, so this - // cannot be limited to ESM entrypoints. - if !terminated && error.is_none() { + // cannot be limited to ESM entrypoints. Settle-complete + // calls release leftovers even after a guest error. + if !terminated && (error.is_none() || complete_on_settle) { // Destruction can race with the short gap before the // active-handle pass. Observe its durable abort before // calling into an isolate another thread terminated. if execution_abort_requested(&abort_rx) { terminated = true; + } else if complete_on_settle { + // Cancel what the call left behind, then let its + // in-flight bridge calls (including the closes) + // finish so no response outlives this execution. + { + let scope = &mut v8::HandleScope::new(iso); + let ctx = v8::Local::new(scope, &exec_context); + let scope = &mut v8::ContextScope::new(scope, ctx); + if let Err(release_error) = + execution::release_leftover_handles(scope) + { + if error.is_none() { + code = 1; + error = Some(release_error); + } + } + } + if !pending.is_empty() { + let scope = &mut v8::HandleScope::new(iso); + let ctx = v8::Local::new(scope, &exec_context); + let scope = &mut v8::ContextScope::new(scope, ctx); + let event_loop_status = run_event_loop_with_readiness( + scope, + EventLoopSources { + commands: &rx, + readiness: &ready_broker, + readiness_wakes: &ready_rx, + bridge_responses: Some(&async_response_rx), + abort: Some(&abort_rx), + pause: Some(&pause_control), + exit: EventLoopExit::BridgeIdle, + }, + &pending, + ); + match event_loop_status { + EventLoopStatus::Completed => {} + EventLoopStatus::Terminated => terminated = true, + EventLoopStatus::Failed(next_code, next_error) => { + if error.is_none() { + code = next_code; + error = Some(next_error); + } + } + } + } } else { // Phase 1: call _waitForActiveHandles() once. Repeating // this after it resolves can re-capture an idle HTTP @@ -3011,6 +3066,7 @@ fn session_thread( bridge_responses: Some(&async_response_rx), abort: Some(&abort_rx), pause: Some(&pause_control), + exit: EventLoopExit::Drained, }, &pending, ); @@ -3329,6 +3385,7 @@ pub fn run_event_loop( bridge_responses: bridge_rx, abort: abort_rx, pause: pause_control, + exit: EventLoopExit::Drained, }, pending, ) @@ -3341,6 +3398,38 @@ struct EventLoopSources<'a> { bridge_responses: Option<&'a Receiver>, abort: Option<&'a Receiver<()>>, pause: Option<&'a SessionPauseControl>, + exit: EventLoopExit, +} + +/// When an event-loop pass may return. +#[derive(Clone, Copy, PartialEq, Eq)] +#[cfg_attr(test, allow(dead_code))] +enum EventLoopExit { + /// Node process semantics: nothing is pending, including ref'd timers. + Drained, + /// The entry evaluation's promise has settled; other work may remain. + EvaluationSettled, + /// No async bridge call is in flight. + BridgeIdle, +} + +fn event_loop_can_exit( + scope: &mut v8::HandleScope, + pending: &crate::bridge::PendingPromises, + exit: EventLoopExit, +) -> bool { + let evaluation_settled = !execution::pending_module_evaluation_needs_wait(scope) + && !execution::pending_script_evaluation_needs_wait(scope); + match exit { + EventLoopExit::Drained => { + pending.is_empty() + && evaluation_settled + && pending_guest_timer_count(scope) == 0 + && pending_guest_immediate_count(scope) == 0 + } + EventLoopExit::EvaluationSettled => evaluation_settled, + EventLoopExit::BridgeIdle => pending.is_empty(), + } } fn run_event_loop_with_readiness( @@ -3355,6 +3444,7 @@ fn run_event_loop_with_readiness( bridge_responses: bridge_rx, abort: abort_rx, pause: pause_control, + exit, } = sources; let mut bridge_lane_open = bridge_rx.is_some(); loop { @@ -3366,12 +3456,7 @@ fn run_event_loop_with_readiness( scope.terminate_execution(); return EventLoopStatus::Terminated; } - if pending.is_empty() - && !execution::pending_module_evaluation_needs_wait(scope) - && !execution::pending_script_evaluation_needs_wait(scope) - && pending_guest_timer_count(scope) == 0 - && pending_guest_immediate_count(scope) == 0 - { + if event_loop_can_exit(scope, pending, exit) { break; } if let Some(control) = pause_control { @@ -3433,12 +3518,7 @@ fn run_event_loop_with_readiness( // Re-check exit conditions after microtask flush — the microtask may // have resolved all pending promises or registered new handles. - if pending.is_empty() - && !execution::pending_module_evaluation_needs_wait(scope) - && !execution::pending_script_evaluation_needs_wait(scope) - && pending_guest_timer_count(scope) == 0 - && pending_guest_immediate_count(scope) == 0 - { + if event_loop_can_exit(scope, pending, exit) { break; } @@ -3570,12 +3650,7 @@ fn run_event_loop_with_readiness( scope.perform_microtask_checkpoint(); pump_v8_message_loop(scope); // Check if we should exit - if pending.is_empty() - && !execution::pending_module_evaluation_needs_wait(scope) - && !execution::pending_script_evaluation_needs_wait(scope) - && pending_guest_timer_count(scope) == 0 - && pending_guest_immediate_count(scope) == 0 - { + if event_loop_can_exit(scope, pending, exit) { return EventLoopStatus::Completed; } }; diff --git a/docs/content/docs/javascript.mdx b/docs/content/docs/javascript.mdx index aac660617f..cc1ef32d72 100644 --- a/docs/content/docs/javascript.mdx +++ b/docs/content/docs/javascript.mdx @@ -39,6 +39,10 @@ console.log(sum.value); // 3 Returning `undefined`, a function, a symbol, or a circular value fails with `evaluation_serialization_failed` rather than silently losing the value. +`evaluate()` returns as soon as the expression's promise settles and its +in-flight host calls finish. It then cancels any timers left behind, closes +any ref'd sockets, servers, and child processes, and writes a summary to stderr. + ## Execute code `execute()` runs source and captures its output instead of returning a value. @@ -65,6 +69,11 @@ Executions are ephemeral, so capture stdio only when you want it: `"stderr"` for diagnostics, `"all"` for both streams. `onStdout`/`onStderr` stream live and work independently of capture. +Without a `contextId`, `execute()` behaves like `node -e`: it finishes when the +event loop is empty, so a ref'd timer or a listening server keeps it running +until `timeoutMs`. Call `unref()` on handles that must not hold it open, or use +`spawn` for long-lived work. + ## Pass data into code In the library, `inputs` hands host values to the guest as real objects, so data never gets @@ -79,9 +88,16 @@ calls. -A context runs one operation at a time, so reusing a busy `contextId` fails -immediately. Files, npm, Bash, and type checks may pass the same id, but they run -in fresh processes and never touch retained memory. +A call in a context completes when its top-level code, including any promise it +awaits, settles. It does not wait for work it leaves behind. agentOS then +cancels the timers that are left and closes the ref'd sockets, servers, and +child processes, and reports them on stderr. Globals stay, so the next call sees the +state. Run long-lived servers with `spawn` instead of a context. + +A context runs one call at a time. A call made while another call in the same +context is still running fails immediately with `execution_busy`. Files, npm, +Bash, and type checks may pass the same id, but they run in fresh processes and +never touch retained memory. JavaScript defaults to `format: "module"`. Each call is a separate root ES module, so its imports, top-level declarations, and exports are scoped to that diff --git a/packages/build-tools/bridge-src/builtins/active-handles.ts b/packages/build-tools/bridge-src/builtins/active-handles.ts index e79b6e86ee..9b53471e52 100644 --- a/packages/build-tools/bridge-src/builtins/active-handles.ts +++ b/packages/build-tools/bridge-src/builtins/active-handles.ts @@ -1,6 +1,7 @@ import { _exited } from "./process.js"; import { exposeCustomGlobal } from "../global-exposure.js"; import { bridgeDispatchSync } from "../transport.js"; +import { _immediateEntries, _timerEntries, clearImmediate, clearTimeout2 } from "./timers.js"; var HANDLE_DISPATCH = { register: "kernelHandleRegister", @@ -8,6 +9,8 @@ var HANDLE_DISPATCH = { list: "kernelHandleList" }; var _activeHandles = /* @__PURE__ */ new Map(); +// Optional close callbacks, keyed like _activeHandles. +var _handleClosers = /* @__PURE__ */ new Map(); var _waitResolvers = []; var _activeHandleDrainScheduled = false; @@ -28,7 +31,7 @@ function scheduleActiveHandleDrain() { }); } -function _registerHandle2(id, description) { +function _registerHandle2(id, description, close) { try { bridgeDispatchSync(HANDLE_DISPATCH.register, id, description); } catch (error) { @@ -40,9 +43,11 @@ function _registerHandle2(id, description) { throw error; } _activeHandles.set(id, description); + if (typeof close === "function") _handleClosers.set(id, close); } function _unregisterHandle2(id) { _activeHandles.delete(id); + _handleClosers.delete(id); const remaining = _activeHandles.size; try { bridgeDispatchSync(HANDLE_DISPATCH.unregister, id); @@ -90,6 +95,33 @@ function _waitForActiveHandles() { } }); } +// A context call or evaluation completes when its result settles. Anything it +// left behind would otherwise stay frozen in the idle context, so cancel the +// timers, close the closable handles, and report both on stderr. +function _releaseLeftoverHandles() { + const released = []; + for (const [id, entry] of Array.from(_timerEntries)) { + clearTimeout2(id); + released.push(entry.repeat ? "setInterval" : "setTimeout"); + } + for (const id of Array.from(_immediateEntries.keys())) { + clearImmediate(id); + released.push("setImmediate"); + } + for (const [id, close] of Array.from(_handleClosers)) { + if (!_handleClosers.has(id)) continue; + released.push(String(_activeHandles.get(id)).split(":")[0]); + _handleClosers.delete(id); + close(); + } + if (released.length === 0 || typeof _error === "undefined") return; + const counts = new Map(); + for (const name of released) counts.set(name, (counts.get(name) ?? 0) + 1); + const summary = Array.from(counts, ([name, count]) => `${name} x${count}`).join(", "); + _error.applySync(void 0, [ + `agentOS: the call completed with work still pending; cancelled ${summary}` + ]); +} function _getActiveHandles() { return Array.from(_activeHandles.values()); } @@ -97,4 +129,5 @@ exposeCustomGlobal("_registerHandle", _registerHandle2); exposeCustomGlobal("_unregisterHandle", _unregisterHandle2); exposeCustomGlobal("_waitForActiveHandles", _waitForActiveHandles); exposeCustomGlobal("_getActiveHandles", _getActiveHandles); +exposeCustomGlobal("_releaseLeftoverHandles", _releaseLeftoverHandles); export { HANDLE_DISPATCH, _activeHandles, _waitResolvers, _registerHandle2, _unregisterHandle2, _waitForActiveHandles, _getActiveHandles }; diff --git a/packages/build-tools/bridge-src/builtins/child-process.ts b/packages/build-tools/bridge-src/builtins/child-process.ts index 282604723c..efa6ecf9a3 100644 --- a/packages/build-tools/bridge-src/builtins/child-process.ts +++ b/packages/build-tools/bridge-src/builtins/child-process.ts @@ -1149,7 +1149,7 @@ var ChildProcess = class { } ref() { if (!this._handleRefed && this._handleId && typeof _registerHandle === "function") { - _registerHandle(this._handleId, this._handleDescription); + _registerHandle(this._handleId, this._handleDescription, () => this.kill()); this._handleRefed = true; } return this; @@ -1431,7 +1431,7 @@ function spawn(command, args, options) { if (typeof _registerHandle === "function") { child._handleId = `child:${sessionId}`; child._handleDescription = `child_process: ${command} ${argsArray.join(" ")}`; - _registerHandle(child._handleId, child._handleDescription); + _registerHandle(child._handleId, child._handleDescription, () => child.kill()); child._handleRefed = true; } queueMicrotask(() => { diff --git a/packages/build-tools/bridge-src/builtins/dgram.ts b/packages/build-tools/bridge-src/builtins/dgram.ts index e2b3f9a57c..c7508cf3ba 100644 --- a/packages/build-tools/bridge-src/builtins/dgram.ts +++ b/packages/build-tools/bridge-src/builtins/dgram.ts @@ -1290,7 +1290,7 @@ var DgramSocket = class { } this._handleRefId = nextHandleId; if (typeof _registerHandle === "function") { - _registerHandle(this._handleRefId, "dgram socket"); + _registerHandle(this._handleRefId, "dgram socket", () => this.close()); } } _emit(event, ...args) { diff --git a/packages/build-tools/bridge-src/builtins/net.ts b/packages/build-tools/bridge-src/builtins/net.ts index 234a7529b6..da7aa02c3a 100644 --- a/packages/build-tools/bridge-src/builtins/net.ts +++ b/packages/build-tools/bridge-src/builtins/net.ts @@ -2527,7 +2527,7 @@ var NetSocket = class _NetSocket extends CanonicalDuplex { } this._handleRefId = this._livenessId; if (typeof _registerHandle === "function") { - _registerHandle(this._handleRefId, "net socket"); + _registerHandle(this._handleRefId, "net socket", () => this.destroy()); } } address() { @@ -3641,7 +3641,7 @@ var NetServer = class { } this._handleRefId = nextHandleId; if (typeof _registerHandle === "function") { - _registerHandle(this._handleRefId, "net server"); + _registerHandle(this._handleRefId, "net server", () => this.close()); } } async _pumpAccepts() { diff --git a/packages/build-tools/bridge-src/global-exposure.ts b/packages/build-tools/bridge-src/global-exposure.ts index 526b502ab0..be2b279b51 100644 --- a/packages/build-tools/bridge-src/global-exposure.ts +++ b/packages/build-tools/bridge-src/global-exposure.ts @@ -336,6 +336,12 @@ var NODE_CUSTOM_GLOBAL_INVENTORY = [ rationale: "Active-handle lifecycle hook controls runtime completion semantics.", }, + { + name: "_releaseLeftoverHandles", + classification: "hardened", + rationale: + "Active-handle lifecycle hook controls runtime completion semantics.", + }, { name: "_getActiveHandles", classification: "hardened", diff --git a/packages/build-tools/scripts/build-v8-bridge.mjs b/packages/build-tools/scripts/build-v8-bridge.mjs index cc3504bffd..b22bc24d5f 100644 --- a/packages/build-tools/scripts/build-v8-bridge.mjs +++ b/packages/build-tools/scripts/build-v8-bridge.mjs @@ -254,6 +254,7 @@ async function validateBridgeContractGlobals(sourceText) { "_registerHandle", "_unregisterHandle", "_waitForActiveHandles", + "_releaseLeftoverHandles", "_getActiveHandles", "_childProcessDispatch", "_childProcessModule",