From 7e18de771c4d3280f5a03a658125362ff8f7f952 Mon Sep 17 00:00:00 2001 From: TadahiroYamamura Date: Tue, 6 Oct 2026 22:06:57 +0900 Subject: [PATCH] fix(net): keep guest data queued before connect on a guest half-close On mapped TCP, data and a FIN arriving before the upstream connect() finished made us discard the already-acked data before end(). On a graceful close, write the data to the connecting socket before end(); Node sends written data before the FIN. Co-Authored-By: Claude Opus 5.5 --- host/src/qemu/net.ts | 7 ++++ host/test/qemu-net.test.ts | 68 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/host/src/qemu/net.ts b/host/src/qemu/net.ts index b8fca0d6..1d05e2e5 100644 --- a/host/src/qemu/net.ts +++ b/host/src/qemu/net.ts @@ -1162,6 +1162,13 @@ export class QemuNetworkBackend extends EventEmitter { // above before clearing this pointer. session.http = undefined; session.ws = undefined; + if (!message.destroy && session.socket) { + // The guest already got ACKs for these. A connecting net.Socket buffers + // writes and sends end()'s FIN only after them. + for (const pending of session.pendingWrites) { + session.socket.write(pending); + } + } session.pendingWrites = []; session.pendingWriteBytes = 0; session.flowControlPaused = false; diff --git a/host/test/qemu-net.test.ts b/host/test/qemu-net.test.ts index 16ab99da..76c2167a 100644 --- a/host/test/qemu-net.test.ts +++ b/host/test/qemu-net.test.ts @@ -4961,3 +4961,71 @@ test("qemu-net: http bridge limits concurrent upstream fetches", async () => { releaseBlockedFetches(); await Promise.all(runs); }); + +test("qemu-net: mapped tcp delivers guest data queued before connect when the guest closes", async () => { + const server = net.createServer(); + // Resolves on the guest's FIN, so the bytes seen by then arrived before it. + const received = new Promise((resolve, reject) => { + server.once("connection", (sock) => { + const chunks: Buffer[] = []; + sock.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + sock.on("end", () => { + resolve(Buffer.concat(chunks)); + sock.end(); + }); + sock.on("error", reject); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + assert.ok(addr && typeof addr !== "string"); + + const backend = makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { hosts: { "foo.internal": `127.0.0.1:${addr.port}` } }, + }); + + const responses: any[] = []; + (backend as any).stack = { + handleUdpResponse: (msg: any) => responses.push(msg), + handleTcpConnected: () => {}, + handleTcpData: () => {}, + handleTcpEnd: () => {}, + handleTcpClosed: () => {}, + handleTcpError: () => {}, + }; + + (backend as any).handleUdpSend({ + key: "udp-tcp-map-fin", + srcIP: "192.168.127.3", + srcPort: 41126, + dstIP: "192.168.127.1", + dstPort: 53, + payload: buildQueryA("foo.internal", 0x4013), + }); + const response = responses[0].data as Buffer; + const fooIp = [...response.subarray(response.length - 4)].join("."); + + const key = "tcp-map-fin"; + const connect = (backend as any).handleTcpConnect({ + key, + srcIP: "192.168.127.3", + srcPort: 50030, + dstIP: fooIp, + dstPort: 7000, + }); + assert.equal(connect.allowRawTcp, true); + + // Both run synchronously, so the upstream connect() cannot have completed. + (backend as any).handleTcpSend({ key, data: Buffer.from("hello") }); + assert.equal((backend as any).tcpSessions.get(key).connected, false); + (backend as any).handleTcpClose({ key, destroy: false }); + + try { + const data = await received; + assert.equal(data.toString("utf8"), "hello"); + } finally { + await backend.close().catch(() => {}); + await new Promise((resolve) => server.close(() => resolve())); + } +});