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:
@@ -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: [],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user