feat(core): SYN+ACK and FIN go once, as in Java, measured with Java FILIUS

TcpRun loses exactly one frame (loseFirst: the card's output buffer drops
it; the sender's packet log still shows it, as with setDrop) in three new
stories: lostSynAck, lostFin, lostLastFin.

Measured: Java never repeats a SYN+ACK or a FIN. The server gives a
handshake up after one timeout (3 RTT) and closes with a FIN from SYN_RCVD
(its boundary check sometimes lets it wait twice; it means once). After its
FIN a socket waits at most five times, each wait ended by a segment or the
timeout; then it is gone. The core does the same (TCP_CLOSING_WAITS, with
TIME_WAIT in the loop) and shows CLOSED where Java leaves the state
standing (a named Java bug).

One departure: a repeated SYN in SYN_RCVD gets the SYN+ACK once more and
the wait starts anew; Java closes there, but its client never repeats the
SYN. lostSynAck is compared up to the second SYN (JAVA_UNTIL); the rest is
an engine test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 18:08:18 +00:00
co-authored by Claude Opus 5.5
parent a88fa1512c
commit 7e0348636c
11 changed files with 1056 additions and 40 deletions
+94 -28
View File
@@ -9,7 +9,8 @@
// "Java-compatible TCP sockets" (lines ~5187–5913; the
// timing and closing after Java's TCPSocket, measured with Java
// FILIUS: 3 round trips per attempt and for TIME_WAIT, the other end
// closes by itself on FIN, failed data closes with FIN)
// closes by itself on FIN, failed data closes with FIN; SYN+ACK and
// FIN go once, then Java's close loop of five waits)
// "DHCP server and automatic client" (lines ~5913–6694; the
// client rebuilt after Java's DHCPClient, measured with Java FILIUS:
// one message per attempt, an ARP check that waits a round trip,
@@ -308,6 +309,8 @@ export interface TcpSession {
remoteSequenceNumber: number;
lastAcknowledgedSequenceNumber: number | undefined;
lastSentSegment: TcpSegment | undefined;
/** While the socket closes (Java's close loop): the waits it has left; else undefined. */
closingWaitsLeft: number | undefined;
receiveAssembly: Uint8Array;
receivedMessages: Uint8Array[];
}
@@ -411,6 +414,12 @@ function sameIgnoringCase(lhs: string | undefined, rhs: string | undefined): boo
export const TCP_MAXIMUM_SEGMENT_SIZE = 1_460;
export const TCP_MAXIMUM_SEND_ATTEMPTS = 3;
/**
* Java's TCPSocket.close: after its FIN (sent once) a socket waits at most
* five times, each wait ended by a segment or after defaultTimeout; then it
* is gone.
*/
export const TCP_CLOSING_WAITS = 5;
export const TCP_EPHEMERAL_PORT_LOWER_BOUND = 49_152;
export const TCP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE = 65_535;
@@ -1508,20 +1517,49 @@ export class NetworkRuntimeEngine {
return;
case 'ESTABLISHED':
case 'SYN_RCVD':
this.updateTcpState(socketId, 'FIN_WAIT_1');
this.resetTcpSendAttempts(socketId);
this.transmitTcpSegment(socketId, TCP_FLAG.finish, new Uint8Array(), 0, 'TCP FIN');
this.beginTcpClosing(socketId, 'FIN_WAIT_1', 'TCP FIN');
return;
case 'CLOSE_WAIT':
this.updateTcpState(socketId, 'LAST_ACK');
this.resetTcpSendAttempts(socketId);
this.transmitTcpSegment(socketId, TCP_FLAG.finish, new Uint8Array(), 0, 'TCP FIN after CLOSE_WAIT');
this.beginTcpClosing(socketId, 'LAST_ACK', 'TCP FIN after CLOSE_WAIT');
return;
default:
return;
}
}
/**
* Java's close: the FIN goes once, never again; the first wait counts from
* handing it down (the timeout transmitTcpSegment schedules).
*/
private beginTcpClosing(socketId: Id, state: 'FIN_WAIT_1' | 'LAST_ACK', detail: string): void {
const session = this.stateValue.tcpSessionsById.get(socketId);
this.clearTcpPendingTransmission(socketId);
this.updateTcpState(socketId, state);
this.resetTcpSendAttempts(socketId);
if (session !== undefined) session.closingWaitsLeft = TCP_CLOSING_WAITS;
this.transmitTcpSegment(socketId, TCP_FLAG.finish, new Uint8Array(), 0, detail);
}
/**
* One wait of Java's close loop has ended, with a segment (already handled)
* or after defaultTimeout. TIME_WAIT ends with it; after the last wait the
* socket is gone — Java leaves its state as it was, the core shows CLOSED.
*/
private endTcpClosingWait(socketId: Id): void {
const socket = this.stateValue.socketsById.get(socketId);
const session = this.stateValue.tcpSessionsById.get(socketId);
if (socket === undefined || session?.closingWaitsLeft === undefined) return;
this.cancelTcpTimeouts(socketId);
session.closingWaitsLeft -= 1;
if (socket.tcpState === 'CLOSED') return;
if (session.closingWaitsLeft <= 0) {
session.lastSentSegment = undefined;
this.updateTcpState(socketId, 'CLOSED');
return;
}
this.schedule(this.stateValue.currentTimeMilliseconds + this.tcpTimeoutMilliseconds(), { kind: 'tcpTimeout', sessionId: socketId });
}
/** The socket at the other end of a connection, the first by id. */
peerTcpSocketId(socketId: Id): Id | undefined {
const socket = this.stateValue.socketsById.get(socketId);
@@ -1657,6 +1695,11 @@ export class NetworkRuntimeEngine {
this.updateTcpState(socketId, 'CLOSED');
return;
}
if (this.stateValue.tcpSessionsById.get(socketId)?.closingWaitsLeft !== undefined) {
this.processTcpSegmentWhileClosing(socketId, segment);
this.endTcpClosingWait(socketId);
return;
}
switch (socket.tcpState) {
case 'SYN_SENT': {
if (
@@ -1674,6 +1717,16 @@ export class NetworkRuntimeEngine {
return;
}
case 'SYN_RCVD': {
if (has(TCP_FLAG.synchronize) && !has(TCP_FLAG.acknowledgement)) {
// The client's SYN again: its SYN+ACK was lost. Java closes here
// (its client never repeats the SYN); the core answers once more
// and waits anew.
const last = this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment;
if (last === undefined) return;
this.cancelTcpTimeouts(socketId);
this.transmitTcpSegment(socketId, last.flags, last.payload, last.acknowledgementNumber, 'TCP SYN+ACK', true);
return;
}
if (!has(TCP_FLAG.acknowledgement) || !this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) return;
this.updateTcpState(socketId, 'ESTABLISHED');
// Java: the socket that listened is the connection now; the server
@@ -1684,10 +1737,6 @@ export class NetworkRuntimeEngine {
}
return;
}
case 'TIME_WAIT':
this.clearTcpPendingTransmission(socketId);
this.updateTcpState(socketId, 'CLOSED');
return;
case 'ESTABLISHED':
if (has(TCP_FLAG.finish)) {
this.sendTcpAcknowledgement(socketId, segment);
@@ -1701,6 +1750,20 @@ export class NetworkRuntimeEngine {
this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber);
}
return;
default:
return;
}
}
/** A segment in Java's close loop: TIME_WAIT ends with it, the other states move on. */
private processTcpSegmentWhileClosing(socketId: Id, segment: TcpSegment): void {
const socket = this.stateValue.socketsById.get(socketId);
const has = (flag: number) => (segment.flags & flag) !== 0;
switch (socket?.tcpState) {
case 'TIME_WAIT':
this.clearTcpPendingTransmission(socketId);
this.updateTcpState(socketId, 'CLOSED');
return;
case 'FIN_WAIT_1': {
const hasAck = has(TCP_FLAG.acknowledgement);
const hasFin = has(TCP_FLAG.finish);
@@ -1842,19 +1905,29 @@ export class NetworkRuntimeEngine {
private handleTcpTimeout(socketId: Id): void {
const socket = this.stateValue.socketsById.get(socketId);
if (socket === undefined || socket.tcpState === undefined) return;
if (socket.tcpState === 'TIME_WAIT') {
this.updateTcpState(socketId, 'CLOSED');
this.clearTcpPendingTransmission(socketId);
const session = this.stateValue.tcpSessionsById.get(socketId);
if (session?.closingWaitsLeft !== undefined) {
if (socket.tcpState === 'TIME_WAIT') {
this.clearTcpPendingTransmission(socketId);
this.updateTcpState(socketId, 'CLOSED');
}
this.endTcpClosingWait(socketId);
return;
}
if (socket.tcpState === 'SYN_RCVD') {
// Java's server sends its SYN+ACK once and waits defaultTimeout for the
// ACK, then closes: a FIN (Java's boundary check sometimes lets it wait
// a second time; it means one).
this.closeTcpSocket(socketId);
return;
}
const session = this.stateValue.tcpSessionsById.get(socketId);
const last = session?.lastSentSegment;
if (session === undefined || last === undefined) return;
if (session.sendAttempts >= TCP_MAXIMUM_SEND_ATTEMPTS) {
this.clearTcpPendingTransmission(socketId);
// Java: data that no one acknowledges (sent only while ESTABLISHED)
// ends senden with schliessen, an orderly close with FIN. A handshake
// or a FIN that fails closes at once.
// ends senden with schliessen, an orderly close with FIN. A connection
// attempt that fails closes at once.
if (socket.tcpState === 'ESTABLISHED') {
this.closeTcpSocket(socketId);
} else {
@@ -1862,15 +1935,8 @@ export class NetworkRuntimeEngine {
}
return;
}
const synchronize = (last.flags & TCP_FLAG.synchronize) !== 0;
const baseDetail = synchronize
? (last.flags & TCP_FLAG.acknowledgement) === 0
? 'TCP SYN'
: 'TCP SYN+ACK'
: (last.flags & TCP_FLAG.finish) !== 0
? 'TCP FIN'
: 'TCP data';
this.transmitTcpSegment(socketId, last.flags, last.payload, last.acknowledgementNumber, baseDetail, true);
const detail = (last.flags & TCP_FLAG.synchronize) !== 0 ? 'TCP SYN' : 'TCP data';
this.transmitTcpSegment(socketId, last.flags, last.payload, last.acknowledgementNumber, detail, true);
}
private acknowledgeTcpIfExpected(socketId: Id, acknowledgementNumber: number): boolean {
@@ -1896,11 +1962,10 @@ export class NetworkRuntimeEngine {
);
}
/** Java's close loop: TIME_WAIT ends with the next segment, else after defaultTimeout. */
/** Java's close loop: TIME_WAIT ends with the next wait, by a segment or after defaultTimeout. */
private enterTcpTimeWait(socketId: Id): void {
this.clearTcpPendingTransmission(socketId);
this.updateTcpState(socketId, 'TIME_WAIT');
this.schedule(this.stateValue.currentTimeMilliseconds + this.tcpTimeoutMilliseconds(), { kind: 'tcpTimeout', sessionId: socketId });
}
/**
@@ -1996,6 +2061,7 @@ export class NetworkRuntimeEngine {
remoteSequenceNumber: 0,
lastAcknowledgedSequenceNumber: undefined,
lastSentSegment: undefined,
closingWaitsLeft: undefined,
receiveAssembly: new Uint8Array(),
receivedMessages: [],
};