Skip to content

Commit c6f0f3f

Browse files
committed
fix: propagate full socket close to guest peers
1 parent 1db3302 commit c6f0f3f

4 files changed

Lines changed: 55 additions & 17 deletions

File tree

crates/native-sidecar/src/execution/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ use url::Url;
292292

293293
const DEFAULT_KERNEL_STDIN_READ_MAX_BYTES: usize = 64 * 1024;
294294
const DEFAULT_KERNEL_STDIN_READ_TIMEOUT_MS: u64 = 100;
295+
const JAVASCRIPT_NET_CLOSE_SENTINEL: &str = "__agentos_net_close__";
295296
const JAVASCRIPT_NET_TIMEOUT_SENTINEL: &str = "__agentos_net_timeout__";
296297
const PYTHON_PYODIDE_GUEST_ROOT: &str = "/__agentos_pyodide";
297298
const PYTHON_PYODIDE_CACHE_GUEST_ROOT: &str = "/__agentos_pyodide_cache";

crates/native-sidecar/src/execution/network/tcp.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,14 @@ impl ActiveTcpSocket {
609609
.fetch_add(1, Ordering::Relaxed);
610610
}
611611
self.saw_remote_end.store(true, Ordering::SeqCst);
612-
Ok(Some(JavascriptTcpSocketEvent::End))
612+
if kernel
613+
.socket_get(socket_id)
614+
.is_some_and(|record| record.peer_socket_id().is_none())
615+
{
616+
Ok(Some(JavascriptTcpSocketEvent::Close { had_error: false }))
617+
} else {
618+
Ok(Some(JavascriptTcpSocketEvent::End))
619+
}
613620
}
614621
Err(error) if error.code() == "EAGAIN" => {
615622
if trace_enabled {
@@ -634,7 +641,16 @@ impl ActiveTcpSocket {
634641
}
635642
if revents.intersects(POLLHUP) {
636643
self.saw_remote_end.store(true, Ordering::SeqCst);
637-
return Ok(Some(JavascriptTcpSocketEvent::End));
644+
return Ok(Some(
645+
if kernel
646+
.socket_get(socket_id)
647+
.is_some_and(|record| record.peer_socket_id().is_none())
648+
{
649+
JavascriptTcpSocketEvent::Close { had_error: false }
650+
} else {
651+
JavascriptTcpSocketEvent::End
652+
},
653+
));
638654
}
639655
if revents.intersects(POLLERR) {
640656
return Ok(Some(JavascriptTcpSocketEvent::Error {
@@ -2996,8 +3012,9 @@ pub(in crate::execution) fn javascript_net_read_value(
29963012
Some(JavascriptTcpSocketEvent::Data { bytes, .. }) => Ok(Value::String(
29973013
base64::engine::general_purpose::STANDARD.encode(bytes),
29983014
)),
2999-
Some(JavascriptTcpSocketEvent::End | JavascriptTcpSocketEvent::Close { .. }) => {
3000-
Ok(Value::Null)
3015+
Some(JavascriptTcpSocketEvent::End) => Ok(Value::Null),
3016+
Some(JavascriptTcpSocketEvent::Close { .. }) => {
3017+
Ok(Value::String(String::from(JAVASCRIPT_NET_CLOSE_SENTINEL)))
30013018
}
30023019
Some(JavascriptTcpSocketEvent::Error { code, message }) => {
30033020
let detail = code.unwrap_or_else(|| String::from("socket read"));

packages/build-tools/bridge-src/builtins/net.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1282,10 +1282,11 @@ function createAcceptedClientHandle(socketId, info) {
12821282
};
12831283
}
12841284

1285-
// Must match JAVASCRIPT_NET_TIMEOUT_SENTINEL in crates/native-sidecar/src/execution/mod.rs.
1285+
// Must match the sentinels in crates/native-sidecar/src/execution/mod.rs.
12861286
// A mismatched sentinel is NOT a soft failure: every no-data poll response then
12871287
// falls through to base64 decoding and injects the decoded sentinel bytes into
12881288
// the socket stream as phantom data.
1289+
var NET_BRIDGE_CLOSE_SENTINEL = "__agentos_net_close__";
12891290
var NET_BRIDGE_TIMEOUT_SENTINEL = "__agentos_net_timeout__";
12901291

12911292
function isNetBridgeTraceEnabled() {
@@ -2897,6 +2898,13 @@ var NetSocket = class _NetSocket extends CanonicalDuplex {
28972898
countNetBridgeMetric("readWaitsForWake");
28982899
return;
28992900
}
2901+
if (chunk === NET_BRIDGE_CLOSE_SENTINEL) {
2902+
countNetBridgeMetric("readCloseEvents");
2903+
this._pendingBridgeWake = false;
2904+
this._pendingBridgeWakeRetries = 0;
2905+
this.destroy();
2906+
return;
2907+
}
29002908
if (chunk === null) {
29012909
if (firstPumpRun && !firstPumpResultRecorded) {
29022910
firstPumpResultRecorded = true;
@@ -3830,6 +3838,7 @@ export {
38303838
isValidIPv6Zone,
38313839
isValidTcpPort,
38323840
maxNetBridgeMetric,
3841+
NET_BRIDGE_CLOSE_SENTINEL,
38333842
NET_BRIDGE_MAX_RAW_WRITE_BYTES,
38343843
NET_BRIDGE_TIMEOUT_SENTINEL,
38353844
NET_SERVER_HANDLE_PREFIX,

packages/core/tests/network-http-request.test.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ async function runSpawnedProcess(
1212
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
1313
const stdoutChunks: string[] = [];
1414
const stderrChunks: string[] = [];
15-
const { pid } = vm.spawn(command, args, {
15+
const { pid } = await vm.spawn(command, args, {
1616
onStdout: (chunk) => {
1717
stdoutChunks.push(textDecoder.decode(chunk));
1818
},
@@ -22,8 +22,9 @@ async function runSpawnedProcess(
2222
},
2323
});
2424

25+
const exit = await vm.process.wait(pid);
2526
return {
26-
exitCode: await vm.waitProcess(pid),
27+
exitCode: exit.exitCode ?? -1,
2728
stdout: stdoutChunks.join(""),
2829
stderr: stderrChunks.join(""),
2930
};
@@ -248,7 +249,7 @@ describe("guest http.request transport", () => {
248249
});
249250
});
250251

251-
test("streams a guest response after the handler opens an outbound websocket", async () => {
252+
test("reclaims cancelled guest response streams while an outbound websocket stays open", async () => {
252253
const upstream = createServer();
253254
const upstreamWebSocket = new WebSocketServer({ server: upstream });
254255
upstreamWebSocket.on("connection", (socket) => {
@@ -282,20 +283,21 @@ describe("guest http.request transport", () => {
282283
});
283284
const script = [
284285
'const http = require("node:http");',
285-
"const server = http.createServer(async (_request, response) => {",
286+
"void (async () => {",
286287
` const socket = new WebSocket("ws://127.0.0.1:${upstreamAddress.port}/", ["rivet", "rivet_token.token"]);`,
287288
' socket.binaryType = "arraybuffer";',
288289
" const binaryLength = await new Promise((resolve, reject) => {",
289290
" socket.onopen = () => queueMicrotask(() => socket.send(new Uint8Array([4, 5, 6])));",
290291
" socket.onmessage = (event) => resolve(event.data.byteLength);",
291292
" socket.onerror = reject;",
292293
" });",
293-
" socket.close();",
294-
' response.writeHead(200, { "Content-Type": "text/event-stream" });',
295-
" response.flushHeaders();",
296-
" response.write(`data: websocket-${binaryLength}\\n\\n`);",
297-
"});",
298-
'server.listen(3000, "0.0.0.0", () => console.log("READY"));',
294+
" const server = http.createServer((_request, response) => {",
295+
' response.writeHead(200, { "Content-Type": "text/event-stream" });',
296+
" response.flushHeaders();",
297+
" response.write(`data: websocket-${binaryLength}\\n\\n`);",
298+
" });",
299+
' server.listen(3000, "0.0.0.0", () => console.log("READY"));',
300+
"})().catch((error) => { console.error(error); process.exitCode = 1; });",
299301
].join("\n");
300302
const child = await vm.spawn("node", ["-e", script], {
301303
onStdout: (chunk) => {
@@ -320,15 +322,24 @@ describe("guest http.request transport", () => {
320322
),
321323
]);
322324

323-
for (let requestIndex = 0; requestIndex < 10; requestIndex++) {
325+
const requestCount = Number.parseInt(
326+
process.env.AGENTOS_VM_FETCH_STREAM_REQUESTS ?? "300",
327+
10,
328+
);
329+
for (let requestIndex = 0; requestIndex < requestCount; requestIndex++) {
324330
const head = await Promise.race([
325331
vm.fetchStreamStart(
326332
3000,
327333
new Request(`http://guest/events-${requestIndex}`),
328334
),
329335
new Promise<never>((_, reject) =>
330336
setTimeout(
331-
() => reject(new Error("stream response head timed out")),
337+
() =>
338+
reject(
339+
new Error(
340+
`stream response head timed out at request ${requestIndex + 1}/${requestCount}`,
341+
),
342+
),
332343
5_000,
333344
),
334345
),

0 commit comments

Comments
 (0)