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/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() { 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);