From 72b73bec0c103475471a47a8fc8e0b18e6fbdb92 Mon Sep 17 00:00:00 2001 From: TadahiroYamamura Date: Tue, 6 Oct 2026 22:06:55 +0900 Subject: [PATCH] fix(net): send our FIN when the guest closes before any payload Before the first payload there is no upstream socket, so when the guest closed first Gondolin never sent a FIN: the guest stayed in FIN-WAIT-2 and the natTable entry stayed behind. Send our FIN right away in that case and let the existing "both sides closed" path drop the session. Advance myAck before calling onTcpClose so that FIN acks the guest's. Co-Authored-By: Claude Opus 5.5 --- host/src/qemu/net.ts | 4 ++ host/src/qemu/network-stack.ts | 3 +- host/test/qemu-net.test.ts | 104 +++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/host/src/qemu/net.ts b/host/src/qemu/net.ts index b8fca0d6..cf142539 100644 --- a/host/src/qemu/net.ts +++ b/host/src/qemu/net.ts @@ -1184,6 +1184,10 @@ export class QemuNetworkBackend extends EventEmitter { } } else { this.tcpSessions.delete(message.key); + if (!message.destroy && session.protocol === null) { + // No upstream will ever send "end", so close our side right away + this.stack?.handleTcpEnd({ key: message.key }); + } } } } diff --git a/host/src/qemu/network-stack.ts b/host/src/qemu/network-stack.ts index 5fc41610..21df49e0 100644 --- a/host/src/qemu/network-stack.ts +++ b/host/src/qemu/network-stack.ts @@ -993,8 +993,9 @@ export class NetworkStack extends EventEmitter { } // finSeq === session.myAck - this.callbacks.onTcpClose({ key, destroy: false }); + // Advance first so a FIN sent from inside onTcpClose acks the guest's FIN session.myAck = wrapSeq(session.myAck + 1); + this.callbacks.onTcpClose({ key, destroy: false }); this.sendTCP( session.srcIP, diff --git a/host/test/qemu-net.test.ts b/host/test/qemu-net.test.ts index 16ab99da..6781fff5 100644 --- a/host/test/qemu-net.test.ts +++ b/host/test/qemu-net.test.ts @@ -4961,3 +4961,107 @@ test("qemu-net: http bridge limits concurrent upstream fetches", async () => { releaseBlockedFetches(); await Promise.all(runs); }); + +function recordGuestTcp(backend: QemuNetworkBackend) { + (backend as any).resetStack(); + const stack = (backend as any).stack; + + const sent: { seq: number; ack: number; flags: number }[] = []; + const sendTCP = stack.sendTCP.bind(stack); + stack.sendTCP = (...args: any[]) => { + sent.push({ seq: args[4], ack: args[5], flags: args[6] }); + sendTCP(...args); + }; + + const segment = ( + dst: { ip: number[]; port: number }, + srcPort: number, + seq: number, + ack: number, + flags: number, + ) => { + const header = Buffer.alloc(20); + header.writeUInt16BE(srcPort, 0); + header.writeUInt16BE(dst.port, 2); + header.writeUInt32BE(seq, 4); + header.writeUInt32BE(ack, 8); + header[12] = 0x50; + header[13] = flags; + header.writeUInt16BE(65535, 14); + stack.handleTCP( + header, + Buffer.from([192, 168, 127, 3]), + Buffer.from(dst.ip), + ); + }; + + return { stack, sent, segment }; +} + +test("qemu-net: guest FIN before any payload is answered with a FIN, a guest RST is not, and both sessions are dropped", () => { + const backend = makeBackend(); + const { stack, sent, segment } = recordGuestTcp(backend); + const dst = { ip: [203, 0, 113, 10], port: 80 }; + + segment(dst, 40030, 100, 0, 0x02); + assert.equal(sent.length, 1); + assert.equal(sent[0].flags, 0x12); + const hostSeq = sent[0].seq + 1; + + sent.length = 0; + segment(dst, 40030, 101, hostSeq, 0x11); + + const fins = sent.filter((s) => (s.flags & 0x01) !== 0); + assert.equal(fins.length, 1, "expected one FIN to the guest"); + assert.equal(fins[0].seq, hostSeq); + assert.equal(fins[0].ack, 102, "FIN must acknowledge the guest's FIN"); + + sent.length = 0; + segment(dst, 40031, 200, 0, 0x02); + segment(dst, 40031, 201, sent[0].seq + 1, 0x10); + sent.length = 0; + segment(dst, 40031, 201, 0, 0x04); + assert.deepEqual(sent, []); + + assert.equal(stack.natTable.size, 0); + assert.equal(backend.tcpSessions.size, 0); +}); + +test("qemu-net: mapped tcp answers a guest FIN before any payload with a FIN and drops the session", () => { + const backend = makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { hosts: { "foo.internal": "127.0.0.1:9" } }, + }); + const { stack, sent, segment } = recordGuestTcp(backend); + + const responses: any[] = []; + stack.handleUdpResponse = (msg: any) => responses.push(msg); + (backend as any).handleUdpSend({ + key: "udp-tcp-map-early-fin", + srcIP: "192.168.127.3", + srcPort: 41127, + dstIP: "192.168.127.1", + dstPort: 53, + payload: buildQueryA("foo.internal", 0x4014), + }); + const response = responses[0].data as Buffer; + const dst = { ip: [...response.subarray(response.length - 4)], port: 7000 }; + + segment(dst, 40032, 100, 0, 0x02); + assert.equal(sent.length, 1); + assert.equal(sent[0].flags, 0x12); + const hostSeq = sent[0].seq + 1; + const [session] = [...backend.tcpSessions.values()]; + assert.ok(session.mappedTcp); + + sent.length = 0; + segment(dst, 40032, 101, hostSeq, 0x11); + + const fins = sent.filter((s) => (s.flags & 0x01) !== 0); + assert.equal(fins.length, 1, "expected one FIN to the guest"); + assert.equal(fins[0].seq, hostSeq); + assert.equal(fins[0].ack, 102, "FIN must acknowledge the guest's FIN"); + assert.equal(session.socket, null); + assert.equal(stack.natTable.size, 0); + assert.equal(backend.tcpSessions.size, 0); +});