Skip to content

daemon: a FIN waits for the data sent before it - #521

Merged
TeoSlayer merged 4 commits into
mainfrom
fix/fin-in-order
Oct 8, 2026
Merged

TeoSlayer merged 4 commits into
mainfrom
fix/fin-in-order

Conversation

@TeoSlayer

@TeoSlayer TeoSlayer commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A FIN that overtook a lost segment was acted on at once:

  • the receiver closed the stream with the segment missing, so the reader saw EOF without it;
  • its FIN-ACK acknowledged the FIN's sequence number, so the sender cleared its unacknowledged data, including the segment it was about to resend.

Every version does this. It is the cause of the pub/sub losses in the v1.17.0 lab run: at 5% loss, 4 of 30 events never reached the broker while pilotctl publish reported success, and the broker logged unexpected EOF. Any client that closes right after writing is exposed the same way.

Fix

Receiver.

  • An established connection that gets a FIN ahead of missing data records it (finPending/finSeq, the first copy's sequence number) and sends an ACK asking for the gap.
  • It handles the FIN (acceptFIN: close the receive side, TIME_WAIT, FIN-ACK) once in-order delivery reaches the FIN's sequence number.
  • If nothing more comes from the peer for 15 s (finHoldIdle), it handles the FIN anyway (expireHeldFIN), and the reader gets EOF without the missing data, as before. A sender still there resends at most 10 s apart (RTOMax), so it always stays inside the bound. Without the bound, a sender that stopped with a segment lost (any version, stopped or restarted) left the stream open until keepalive gave up, about two minutes later.
  • A connection this side has already closed handles a FIN at once, as before, since it discards the peer's data anyway.

Sender.

  • CloseConnection reserves the FIN's sequence number as before.
  • When data is still unacknowledged, it holds the FIN on the connection. releaseHeldFIN sends it in whichever of these comes first:
    • the ACK that acknowledges the last data (the ACK path);
    • the end of the 5 s linger (finLinger, a timer);
    • the reaper removing the connection;
    • shutdown.
  • No goroutine waits, and the caller is not blocked. A held FIN is dropped only if the connection has already closed (TIME_WAIT/CLOSED), where the FIN-ACK this side sent stands for it.
  • This protects the end of a write on receivers that have not upgraded.

Shutdown. Stopping the daemon sends a FIN that is still held, and no second FIN after it. CloseConnection reserves the FIN's sequence number and holds the FIN in one critical section, so shutdown never sees one without the other.

A restarted peer. A SYN on the ports of a connection whose peer FIN is held comes from a restarted peer dialling again, whose ephemeral ports start over. It is not a resend: a peer that sent a FIN is past its SYN.

  • It ends the old connection quietly, because a FIN-ACK would reach the new one.
  • It opens a new connection. Answered as the old connection, the new one's data was acknowledged as duplicates and never delivered.

The hold ends with the connection. An RST, a local close, the reaper or shutdown each stop the hold timer, and the timer acts only on an established connection, so it never sends a FIN-ACK for a connection that is gone.

Interop:

  • A new receiver with an old sender: the receiver rule protects the data.
  • An old receiver with a new sender: the FIN arrives after the data is acknowledged.
  • Wire format and flags are unchanged.

Tests

zz_fin_in_order_test.go:

  • TestEarlyFINIsHeldUntilTheDataBeforeItArrives: the reply to the early FIN is a plain ACK asking for the gap, the state stays established, and the reader gets every byte before EOF.
  • TestFINOnAClosingConnectionIsAcceptedAtOnce.
  • TestCloseSendsTheFINAfterTheLingerIfNothingIsAcknowledged.
  • TestHeldFINGoesOutWithTheACKOfTheLastData.
  • TestHeldPeerFINIsHandledOnceThePeerFallsSilent, where arriving data restarts the wait.
  • TestStopSendsAFINThatWasWaitingForData and TestStopSendsOnlyTheHeldFINOfAHalfDoneClose.
  • TestSYNOnAConnectionWithAHeldFINIsANewConnection.
  • TestHoldEndsWithTheConnection.
  • TestWriteThenCloseSurvivesALostSegment: two daemons, one segment's first send dropped, with the FIN sent at once (old senders) and with the FIN held.

Each fails with its fix reverted.

Results:

  • TestWriteThenCloseSurvivesALostSegment under -race -count=300: 0 failures. A review of the first version found 3 in 300, a race that dropped the held FIN, now fixed.
  • go test -race ./pkg/... ./cmd/...: passes.
  • ./tests: passes.

Existing tests adjusted because the FIN now waits for acknowledged data:

  • TestHandleStreamFINEstablishedClosesAndSendsFINACK sets ExpectedSeq to the FIN's sequence number.
  • TestCloseSendsHeldTailBeforeFIN acknowledges the data through the ACK path's release.
  • TestCloseCommitsTheWholeTailDespiteSendErrors checks the held FIN's sequence number.

Not changed

  • A FIN that goes out after its data is acknowledged is not resent if lost. The retransmit loop leaves FIN_WAIT once no data is outstanding, as it does in every version; the peer's keepalive then closes its side.
  • If a writer is blocked on the window when another goroutine closes the connection, the writer can still send data numbered after the FIN once the window opens. That predates this PR (found reviewing daemon: a send error no longer strands the end of a write; no RTT sample from a resent segment's ACK #518); the receiver drops data past the FIN.

🤖 Generated with Claude Code

Comment thread pkg/daemon/daemon.go Fixed
Comment thread pkg/daemon/daemon.go
Seq: sendSeq,
Ack: finSeq + 1,
}
d.tunnels.Send(remoteAddr.Node, finack)
Comment thread pkg/daemon/daemon.go

// sendFIN puts the FIN on the wire and tracks it for retransmission.
func (d *Daemon) sendFIN(conn *Connection, fin *protocol.Packet) {
d.tunnels.Send(fin.Dst.Node, fin)
Teo Calin and others added 3 commits October 8, 2026 13:19
A FIN that overtook a lost segment was acted on at once: the receiver
closed the stream with the segment missing and answered with a FIN-ACK,
and that FIN-ACK made the sender drop the segment from its retransmit
queue. Under loss a writer that closed right after its last write lost the
end of it: 4 of 30 pub/sub events at 5% loss never reached the broker while
the publisher reported success. Every version behaves this way.

Receiver: a FIN ahead of undelivered data is remembered, and an immediate
ACK with SACK asks for the gap; the FIN is handled (receive side closed,
TIME_WAIT, FIN-ACK) once delivery reaches it. A side that has already
closed its own receive side still takes the peer's FIN at once.

Sender: CloseConnection holds the FIN until its data is acknowledged, at
most finLinger (5 s), in a goroutine, so receivers that have not upgraded
get the whole write too. FIN placement and the FIN retransmit rules are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A connection closed with data unacknowledged holds its FIN until the data
is acknowledged. Stopping the daemon in that window dropped the FIN: the
waiting goroutine returned on stop, and shutdown sends FINs only to
established connections. The peer then kept the connection open until
keepalive gave up on it (TestConnectionAfterPeerRestart: 5 of 5 failed
at its 60 s budget). The held FIN now lives on the connection, and
whichever of the two takes it sends it, once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the peer goes silent

Review of the FIN hold found:

- The held FIN could be dropped: the goroutine that sends it started
  before CloseConnection set FIN_WAIT, and a goroutine that found the
  data already acknowledged read ESTABLISHED and gave up. 3 of 300 runs
  of the write-then-close test lost the FIN. A held FIN is now dropped
  only once the connection is in TIME_WAIT or CLOSED.
- A FIN held for data the sender never resends (a daemon stopped or
  restarted with a segment lost, any version) stayed held until
  keepalive gave up, about two minutes. It is now handled once nothing
  has come from the peer for 15 s; a sender still there resends at most
  10 s apart (RTOMax).
- Each closing connection polled every 20 ms for up to 5 s: 2,000
  closes at once cost half a core. There is no goroutine now: the ACK
  path sends the held FIN when the last data is acknowledged, a timer
  sends it at the linger, and the reaper and shutdown send it if still
  held.
- Shutdown could send a second FIN, at SendSeq, after a held one, and a
  receiver would hold that one for good. It now sends the held FIN only.
  The peer FIN's first sequence number is kept.

Tests: the early-FIN test reads only what the FIN caused, the end-to-end
test's reader no longer races its failure message, and the tail test
checks the held FIN's sequence number again. New tests for the ACK-path
release, the silent-peer expiry (and data restarting its wait) and a
half-done close at shutdown each fail with their fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread pkg/daemon/daemon.go
// It may still read ESTABLISHED, its close half done; the
// FIN's sequence number is already reserved, so send that one
// and no second FIN after it.
d.tunnels.Send(conn.RemoteAddr.Node, held)
…old ends with the connection

Second review of the FIN hold:

- A peer that restarts and dials the same port again (its ephemeral
  ports start over) matched the old connection while its FIN was held,
  got the old SYN-ACK back, and had its data acknowledged as duplicates
  and never delivered. A peer that sent a FIN is past its SYN, so that
  SYN now ends the old connection, quietly (a FIN-ACK would reach the
  new one), and opens a new connection.
- The hold timer outlived the hold: after an RST, a local close, the
  reaper or shutdown, it later set TIME_WAIT on the removed connection
  and sent a FIN-ACK that could close a new connection on the same
  ports. The hold now ends with the connection (stopFINHold), and the
  timer acts only on an established connection.
- CloseConnection reserves the FIN's sequence number and holds the FIN
  in one critical section, so shutdown cannot see the reservation
  without the held FIN and send a second FIN past it.
- The silent-peer test has wider margins (1 s hold, data at 250 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TeoSlayer
TeoSlayer merged commit 3d1810f into main Oct 8, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants