From adfc1cfd57905c0e8ca86c6295b62a7bc742af9d Mon Sep 17 00:00:00 2001 From: Sree Narayanan Date: Wed, 21 Oct 2026 09:30:42 +0400 Subject: [PATCH 1/2] fix(execution): let an unread fetch body release the guest event loop The undici stream shim's finished() polled web streams with a ref'd 0 ms timer until they closed. An unread fetch body never closes, so the timer kept the guest process alive and busy. Watch the web-streams-polyfill state transition instead, so finished() holds no event-loop reference, matching Node's eos. --- .../execution/assets/undici-shims/stream.js | 55 +++++++++++++++---- packages/core/tests/network-vm-fetch.test.ts | 53 ++++++++++++++++++ 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/crates/execution/assets/undici-shims/stream.js b/crates/execution/assets/undici-shims/stream.js index 524c6c21d4..90e053781d 100644 --- a/crates/execution/assets/undici-shims/stream.js +++ b/crates/execution/assets/undici-shims/stream.js @@ -45,6 +45,37 @@ const normalizeStreamError = (value) => { return new Error(String(value)); }; +// Web streams expose no way to observe close or error without locking the +// stream. Node's eos waits on an internal promise that does not keep the event +// loop alive, so watch web-streams-polyfill `_state` transitions. A polling +// timer here held the guest process open while a fetch body stayed unread. +const webStreamSettleListeners = new WeakMap(); + +const watchWebStreamState = (stream) => { + const listeners = new Set(); + let state = stream._state; + Object.defineProperty(stream, "_state", { + configurable: true, + enumerable: true, + get: () => state, + set: (next) => { + state = next; + if (next === "closed" || next === "errored") { + for (const listener of listeners) listener(); + } + }, + }); + webStreamSettleListeners.set(stream, listeners); + return listeners; +}; + +const onWebStreamSettled = (stream, listener) => { + const listeners = + webStreamSettleListeners.get(stream) ?? watchWebStreamState(stream); + listeners.add(listener); + return () => listeners.delete(listener); +}; + export const finished = (stream, options, callback) => { let normalizedOptions = options; let normalizedCallback = callback; @@ -65,14 +96,12 @@ export const finished = (stream, options, callback) => { const readableEnabled = normalizedOptions?.readable !== false; const writableEnabled = normalizedOptions?.writable !== false; let cancelled = false; - let timer = null; + let unsubscribe = null; const cleanup = () => { cancelled = true; - if (timer !== null) { - clearTimeout(timer); - timer = null; - } + unsubscribe?.(); + unsubscribe = null; }; const complete = (error = undefined) => { @@ -83,14 +112,14 @@ export const finished = (stream, options, callback) => { queueMicrotask(() => done(error)); }; - const poll = () => { + const check = () => { if (cancelled) { - return; + return true; } const state = stream?._state; if (state === "errored") { complete(normalizeStreamError(stream?._storedError)); - return; + return true; } if ( state === "closed" || @@ -98,12 +127,16 @@ export const finished = (stream, options, callback) => { (isWebWritableStream(stream) && !writableEnabled) ) { complete(); - return; + return true; } - timer = setTimeout(poll, 0); + return false; }; - poll(); + if (!check()) { + // The polyfill assigns `_storedError` after `_state`, so read it once the + // transition has finished. + unsubscribe = onWebStreamSettled(stream, () => queueMicrotask(check)); + } return cleanup; }; diff --git a/packages/core/tests/network-vm-fetch.test.ts b/packages/core/tests/network-vm-fetch.test.ts index be94f62bef..75ed05310a 100644 --- a/packages/core/tests/network-vm-fetch.test.ts +++ b/packages/core/tests/network-vm-fetch.test.ts @@ -69,3 +69,56 @@ test("httpRequest reaches a guest http.createServer listener", async () => { await vm.process.wait(pid).catch(() => {}); } }); + +test("a guest fetch with an unread body lets the process exit", async () => { + vm = await AgentOs.create({ permissions: { network: "allow" } }); + // The server keeps the connection open after each response, so the client + // holds an idle keep-alive socket and an unread body when its script ends. + await vm.writeFile( + "/app/server.mjs", + [ + 'import net from "node:net";', + "const response =", + ' "HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=120\\r\\n\\r\\nok";', + 'const server = net.createServer((socket) => socket.on("data", () => socket.write(response)));', + 'server.listen(3000, "127.0.0.1", () => console.log("LISTENING"));', + ].join("\n"), + ); + await vm.writeFile( + "/app/client.mjs", + 'console.log((await fetch("http://127.0.0.1:3000/")).status);', + ); + + let listening!: () => void; + const ready = new Promise((resolve) => { + listening = resolve; + }); + const server = await vm.process.spawn("node", ["/app/server.mjs"], { + onStdout: (chunk) => { + if (textDecoder.decode(chunk).includes("LISTENING")) listening(); + }, + }); + try { + await ready; + let stdout = ""; + let stderr = ""; + const startedAt = Date.now(); + const client = await vm.process.spawn("node", ["/app/client.mjs"], { + onStdout: (chunk) => { + stdout += textDecoder.decode(chunk); + }, + onStderr: (chunk) => { + stderr += textDecoder.decode(chunk); + }, + }); + const exit = await vm.process.wait(client.pid); + const elapsedMs = Date.now() - startedAt; + + expect(exit.exitCode, stderr).toBe(0); + expect(stdout.trim()).toBe("200"); + expect(elapsedMs).toBeLessThan(5_000); + } finally { + await vm.process.kill(server.pid); + await vm.process.wait(server.pid).catch(() => {}); + } +}, 60_000); From 2a9ef330c64a40afa2ca84d2e0447cd442839a75 Mon Sep 17 00:00:00 2001 From: Sree Narayanan Date: Wed, 21 Oct 2026 20:46:47 +0400 Subject: [PATCH 2/2] fix(runtime): fail released context handles instead of truncating them When a context call or evaluation completes, sockets it left open are now destroyed with ERR_AGENTOS_CONTEXT_HANDLE_RELEASED, so a stored fetch Response body or an in-flight fetch fails with that error in a later call instead of ending early or hanging. Only ref'd timers are cancelled; unref'd runtime timers (undici's shared timer, stream completion polling, which is now unref'd) keep working across calls and no longer show up as leftover work. The call's result is finalized before the release. Failures and unhandled rejections raised by the release are written to the call's stderr and no longer fail the completed call or leak into a later one. --- .../tests/language_execution.rs | 162 +++++++++++++++--- crates/v8-runtime/CLAUDE.md | 2 +- crates/v8-runtime/src/execution.rs | 45 +++++ crates/v8-runtime/src/session.rs | 71 +++++--- docs/content/docs/javascript.mdx | 25 ++- .../bridge-src/builtins/active-handles.ts | 27 ++- .../build-tools/bridge-src/builtins/net.ts | 9 +- 7 files changed, 277 insertions(+), 64 deletions(-) diff --git a/crates/native-sidecar/tests/language_execution.rs b/crates/native-sidecar/tests/language_execution.rs index 20ea6a2ebd..494f358aea 100644 --- a/crates/native-sidecar/tests/language_execution.rs +++ b/crates/native-sidecar/tests/language_execution.rs @@ -1117,6 +1117,32 @@ fn execution_timeout_is_enforced_by_the_sidecar() { dispose_vm_and_close_session_wire(&mut sidecar, &connection_id, &session_id, &vm_id); } +fn evaluate_in_context( + sidecar: &mut agentos_native_sidecar::NativeSidecar, + (connection_id, session_id, vm_id): (&str, &str, &str), + context_id: &str, + format: wire::JavaScriptModuleFormat, + expression: &str, +) -> wire::ExecutionCompletedResponse { + let mut process = context_process_options(context_id); + process.timeout_ms = Some(5_000); + let started = sidecar + .dispatch_wire_blocking(wire_request( + 5, + 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) +} + #[test] fn context_call_completes_when_its_result_settles_despite_leftover_handles() { let mut sidecar = new_sidecar("language-execution-leftover-handles"); @@ -1132,32 +1158,18 @@ fn context_call_completes_when_its_result_settles_despite_leftover_handles() { &cwd, ); - let mut request_id = 4; - let mut evaluate = + let evaluate = |sidecar: &mut agentos_native_sidecar::NativeSidecar, 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) + evaluate_in_context( + sidecar, + (&connection_id, &session_id, &vm_id), + context_id, + format, + expression, + ) }; for (context_id, format) in [ @@ -1202,6 +1214,112 @@ fn context_call_completes_when_its_result_settles_despite_leftover_handles() { dispose_vm_and_close_session_wire(&mut sidecar, &connection_id, &session_id, &vm_id); } +#[test] +fn released_context_socket_fails_reads_in_later_calls() { + let mut sidecar = new_sidecar("language-execution-released-socket"); + let connection_id = authenticate_wire(&mut sidecar, "released-socket-connection"); + let session_id = open_session_wire(&mut sidecar, 2, &connection_id); + let cwd = temp_dir("language-execution-released-socket-cwd"); + let (vm_id, _) = create_vm_wire( + &mut sidecar, + 3, + &connection_id, + &session_id, + wire::GuestRuntimeKind::JavaScript, + &cwd, + ); + create_context( + &mut sidecar, + &connection_id, + &session_id, + &vm_id, + "released", + ); + let ids = (connection_id.as_str(), session_id.as_str(), vm_id.as_str()); + let stderr = |result: &wire::ExecutionCompletedResponse| { + String::from_utf8_lossy(result.stderr.as_deref().unwrap_or_default()).into_owned() + }; + + // The body is far larger than one read, so most of it is still on the + // socket when the first call completes and releases that socket. + let stored = evaluate_in_context( + &mut sidecar, + ids, + "released", + wire::JavaScriptModuleFormat::Module, + r#"await (async () => { + const http = require("node:http"); + const body = Buffer.alloc(8 * 1024 * 1024, 97); + const server = http.createServer((_request, response) => { + response.setHeader("content-length", body.length); + response.end(body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + globalThis.response = await fetch(`http://127.0.0.1:${server.address().port}/`); + return response.status; + })()"#, + ); + assert_eq!( + stored.outcome, + wire::ExecutionOutcome::Succeeded, + "{:?}", + stored.error + ); + assert_eq!(stored.evaluation_value.as_deref(), Some("200")); + + let read = evaluate_in_context( + &mut sidecar, + ids, + "released", + wire::JavaScriptModuleFormat::Module, + "await response.arrayBuffer().then((body) => ({ bytes: body.byteLength }), (error) => ({ code: error.cause?.code ?? error.code }))", + ); + assert_eq!( + read.outcome, + wire::ExecutionOutcome::Succeeded, + "{:?}", + read.error + ); + assert_eq!( + read.evaluation_value.as_deref(), + Some(r#"{"code":"ERR_AGENTOS_CONTEXT_HANDLE_RELEASED"}"#), + "a released response body must fail, not end early" + ); + + // A call that finishes its own I/O leaves nothing to release. + let complete = evaluate_in_context( + &mut sidecar, + ids, + "released", + wire::JavaScriptModuleFormat::Module, + r#"await (async () => { + const http = require("node:http"); + const server = http.createServer((_request, response) => { + response.setHeader("connection", "close"); + response.end("ok"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const text = await (await fetch(`http://127.0.0.1:${server.address().port}/`)).text(); + await new Promise((resolve) => server.close(resolve)); + return text; + })()"#, + ); + assert_eq!( + complete.outcome, + wire::ExecutionOutcome::Succeeded, + "{:?}", + complete.error + ); + assert_eq!(complete.evaluation_value.as_deref(), Some(r#""ok""#)); + assert!( + !stderr(&complete).contains("work still pending"), + "a call that finished its fetch reported leftover work: {}", + stderr(&complete) + ); + + 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 15109b31a1..a5cc912cf0 100644 --- a/crates/v8-runtime/CLAUDE.md +++ b/crates/v8-runtime/CLAUDE.md @@ -8,7 +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. +- 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`), the result is finalized, then `_releaseLeftoverHandles()` cancels ref'd timers and closes ref'd handles with `ERR_AGENTOS_CONTEXT_HANDLE_RELEASED` (never a clean EOF), then the loop runs until no async bridge call is in flight (`EventLoopExit::BridgeIdle`). Failures and unhandled rejections after the result is finalized go to the call's stderr via `report_after_call_failure` and never change its outcome or leak into a later call. Never apply `_waitForActiveHandles()` to these calls, and never cancel unref'd timers: runtime-internal timers (undici's shared timer, stream pollers) must be unref'd so they survive across calls. - 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/execution.rs b/crates/v8-runtime/src/execution.rs index 6acf3b284d..65ca5f532a 100644 --- a/crates/v8-runtime/src/execution.rs +++ b/crates/v8-runtime/src/execution.rs @@ -1144,6 +1144,51 @@ pub fn release_leftover_handles(scope: &mut v8::HandleScope) -> Result<(), Execu } } +/// Report a failure that happened after a settle-complete call's result was +/// fixed. It goes to the call's stderr (sidecar stderr when the bridge is +/// absent) instead of changing the call's outcome. +pub fn report_after_call_failure(scope: &mut v8::HandleScope, error: &ExecutionError) { + let detail = if error.stack.is_empty() { + format!("{}: {}", error.error_type, error.message) + } else { + error.stack.clone() + }; + let message = format!("agentOS: after the context call completed: {detail}\n"); + if !write_guest_stderr(scope, &message) { + eprint!("{message}"); + } +} + +fn write_guest_stderr(scope: &mut v8::HandleScope, message: &str) -> bool { + let tc = &mut v8::TryCatch::new(scope); + let global = tc.get_current_context().global(tc); + let (Some(error_key), Some(apply_key), Some(text)) = ( + v8::String::new(tc, "_error"), + v8::String::new(tc, "applySync"), + v8::String::new(tc, message), + ) else { + return false; + }; + let Some(target) = global + .get(tc, error_key.into()) + .and_then(|value| v8::Local::::try_from(value).ok()) + else { + return false; + }; + let Some(apply) = target + .get(tc, apply_key.into()) + .and_then(|value| v8::Local::::try_from(value).ok()) + else { + return false; + }; + let args = v8::Array::new_with_elements(tc, &[text.into()]); + let undefined = v8::undefined(tc).into(); + apply + .call(tc, target.into(), &[undefined, args.into()]) + .is_some() + && !tc.has_caught() +} + fn serialize_module_exports( scope: &mut v8::HandleScope, module: v8::Local, diff --git a/crates/v8-runtime/src/session.rs b/crates/v8-runtime/src/session.rs index 062283e103..db1f858e26 100644 --- a/crates/v8-runtime/src/session.rs +++ b/crates/v8-runtime/src/session.rs @@ -2973,27 +2973,31 @@ fn session_thread( 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) + // The call's outcome is fixed once its entry + // evaluation settles. Finalize it first, then + // release what it left behind and let in-flight + // bridge calls (including the closes) finish so + // no response outlives this execution. Failures + // after this point go to stderr; they never + // rewrite the completed call's outcome. + let scope = &mut v8::HandleScope::new(iso); + let ctx = v8::Local::new(scope, &exec_context); + let scope = &mut v8::ContextScope::new(scope, ctx); + if !module_mode && error.is_none() { + if let Some((next_code, next_error)) = + execution::finalize_pending_script_evaluation(scope) { - if error.is_none() { - code = 1; - error = Some(release_error); - } + code = next_code; + error = next_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( + if let Err(release_error) = + execution::release_leftover_handles(scope) + { + execution::report_after_call_failure(scope, &release_error); + } + while !pending.is_empty() { + match run_event_loop_with_readiness( scope, EventLoopSources { commands: &rx, @@ -3005,16 +3009,29 @@ fn session_thread( 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); - } + ) { + EventLoopStatus::Completed => break, + EventLoopStatus::Terminated => { + terminated = true; + break; } + EventLoopStatus::Failed(_, after_call_error) => { + execution::report_after_call_failure( + scope, + &after_call_error, + ); + } + } + } + // Rejections that are still unhandled arose after + // the result settled (typically from released + // handles). Report them here so they cannot fail + // an unrelated later call in this context. + if !terminated { + while let Some(rejection) = + execution::take_unhandled_promise_rejection(scope) + { + execution::report_after_call_failure(scope, &rejection); } } } else { diff --git a/docs/content/docs/javascript.mdx b/docs/content/docs/javascript.mdx index cc1ef32d72..faac27bfa4 100644 --- a/docs/content/docs/javascript.mdx +++ b/docs/content/docs/javascript.mdx @@ -40,8 +40,8 @@ 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. +in-flight host calls finish. Work it leaves behind is released as described in +[Keep state between calls](#keep-state-between-calls). ## Execute code @@ -89,10 +89,23 @@ calls. 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. +awaits, settles. It does not wait for work it leaves behind. Globals stay, so the +next call sees the state, but agentOS releases what would keep a Node process +alive: + +- **Ref'd timers and immediates** are cancelled and never run. A promise that + waits on one never settles. +- **Ref'd sockets, servers, and child processes** are closed. Pending I/O on them + fails with `ERR_AGENTOS_CONTEXT_HANDLE_RELEASED`, never a clean end. A `fetch` + that is still in flight rejects with that error as its `cause`, and reading a + stored `Response` body fails the same way. +- **Unref'd timers and handles** are left alone, but nothing runs between calls, + so they only make progress while a later call is running. + +The call's stderr names what was released. Errors raised during that cleanup, +such as an unhandled rejection from a released `fetch`, are written to the same +stderr and never change the call's result. Finish I/O inside one call, and 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, diff --git a/packages/build-tools/bridge-src/builtins/active-handles.ts b/packages/build-tools/bridge-src/builtins/active-handles.ts index 9b53471e52..c3baecf0ea 100644 --- a/packages/build-tools/bridge-src/builtins/active-handles.ts +++ b/packages/build-tools/bridge-src/builtins/active-handles.ts @@ -95,31 +95,44 @@ 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. +var CONTEXT_HANDLE_RELEASED = "ERR_AGENTOS_CONTEXT_HANDLE_RELEASED"; +function contextHandleReleasedError(description) { + const error = new Error( + `${CONTEXT_HANDLE_RELEASED}: ${description} was closed because the context call that opened it completed; finish I/O inside one call, or use spawn for long-lived work` + ); + error.code = CONTEXT_HANDLE_RELEASED; + return error; +} +// A context call or evaluation completes when its result settles. Whatever +// would keep a Node process alive is released: ref'd timers are cancelled and +// never run; ref'd sockets, servers, and child processes are closed, failing +// any pending read or request with CONTEXT_HANDLE_RELEASED instead of ending +// it cleanly. Unref'd timers (including the runtime's own) are left alone. function _releaseLeftoverHandles() { const released = []; for (const [id, entry] of Array.from(_timerEntries)) { + if (!entry.handle._refed) continue; clearTimeout2(id); released.push(entry.repeat ? "setInterval" : "setTimeout"); } - for (const id of Array.from(_immediateEntries.keys())) { + for (const [id, entry] of Array.from(_immediateEntries)) { + if (!entry.handle._refed) continue; 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]); + const description = String(_activeHandles.get(id)).split(":")[0]; + released.push(description); _handleClosers.delete(id); - close(); + close(contextHandleReleasedError(description)); } 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}` + `agentOS: the context call completed with work still pending; released ${summary}. Released timers never run; pending I/O on released handles fails with ${CONTEXT_HANDLE_RELEASED}.\n` ]); } function _getActiveHandles() { diff --git a/packages/build-tools/bridge-src/builtins/net.ts b/packages/build-tools/bridge-src/builtins/net.ts index da7aa02c3a..c6c65c8ec4 100644 --- a/packages/build-tools/bridge-src/builtins/net.ts +++ b/packages/build-tools/bridge-src/builtins/net.ts @@ -2503,6 +2503,13 @@ var NetSocket = class _NetSocket extends CanonicalDuplex { } return this; } + // Fail the stream instead of ending it, so readers (including undici + // bodies) see the release. The summary on stderr already reports it, so a + // socket without its own error listener must not crash the context. + _destroyReleased(error) { + if (this.listenerCount("error") === 0) this.once("error", () => {}); + this.destroy(error); + } _syncHandleRef() { const active = this._refed && @@ -2527,7 +2534,7 @@ var NetSocket = class _NetSocket extends CanonicalDuplex { } this._handleRefId = this._livenessId; if (typeof _registerHandle === "function") { - _registerHandle(this._handleRefId, "net socket", () => this.destroy()); + _registerHandle(this._handleRefId, "net socket", (error) => this._destroyReleased(error)); } } address() {