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",