5 Commits

Author SHA1 Message Date
Pat Whittingslow e6a5be3628 ci: laxify codecoverage (#191)
* ci: laxify codecoverage

* reread coverage docs

* try this yaml out

* add project level codecov informational

* add a project wide coverage

* test project wide coverage status fail

* remove test code
2026-08-25 13:36:33 -03:00
Patricio Whittingslow 56f943ed30 ci: run 2026-08-24 22:03:42 -03:00
Derek den Haas c879d0497c fix(internal): keep the write position when a ring is read empty (#181)
Ring.onReadEnd and Ring.ReadDiscard call Reset() when the last readable
byte is consumed, which rewinds Off and End to 0. That is a valid empty
state, but it also moves the write position, and bytes staged past that
position with PeekWrite are addressed relative to it. After the rewind a
Commit hands back whatever bytes happen to live at the start of the
buffer instead of the staged ones.

tcp stages out-of-order segments exactly this way (see reassembly.store,
"the ring write pointer advances in lockstep with rcv.NXT, so the staged
bytes are always where seq implies"). Reading is driven by the
application, so a receiver that drains its stream while a gap is open
loses the staged tail silently: the stream arrives with the correct
length and the wrong contents.

Observed on a Sophgo SG2002 (100Mbit DWMAC, receive ring dropping frames
under load): a 16 MiB download arrived complete with a different
SHA-256, and TLS over the same path failed with "bad record MAC", while
the transmit path was bit-perfect.

Mark the ring empty without rewinding instead: End=0 is what empty
means, and the write position is then Off, which Ring.Write already
supports explicitly.

Two tests, both failing before the change:

  - internal: staged bytes survive the ring being read empty.
  - tcp: a reassembled stream is byte-identical when segments arrive
    reordered (segment K arrived as the contents of an earlier segment).

Co-authored-by: Derek den Haas <i.pestano@easyflor.nl>
2026-08-24 19:41:05 -03:00
Derek den Haas 6313b1570d fix(xnet): allocate ephemeral ports sequentially, not randomly (#180)
Ephemeral ports were drawn at random from the 16384-port dynamic range on
every dial. Under connection-per-request churn the birthday paradox reuses
a recently-released port after only a few dozen dials (~1% per dial at 200
outstanding-in-teardown), and a reused 4-tuple lands on whatever state the
previous conversation left along the path, such as the peer's TIME-WAIT
socket or a NAT's flow-table entry, which silently swallows the new SYN.
Measured end to end: an HTTP client with keep-alives off against a macOS
peer hit a dead dial after ~286 connections and stayed dark for ~30
seconds, exactly one 2MSL TIME-WAIT expiry.

Allocate sequentially from a per-stack random starting offset instead: a
port is only revisited after the full 16384-port cycle, and the random
start keeps a rebooted node off the ports its previous life just used.

TestEphemeralPortSequence pins the full-cycle-no-reuse property.

Co-authored-by: Derek den Haas <d.haas@directcode.com>
2026-08-24 19:39:46 -03:00
Derek den Haas 21f477b86e fix(tcp): retransmit unacknowledged data after a local close (#182)
ControlBlock.Send refused any outgoing segment carrying data in
FIN-WAIT-1, citing RFC 9293's "no further SENDs from the user will be
accepted by the TCP implementation". That rule bounds what the
application may queue, which Handler.Write already enforces, and not the
retransmission of data the connection has already accepted from it.

The consequence is that write-then-close, which is what nearly every
server does with a response, cannot recover from losing its last data
segment. The FIN occupies a sequence number above that data, so the peer
cannot cross the gap to process the close: it waits for bytes that are
never resent while the sender waits for an ACK that cannot arrive.
Neither side times out at the TCP layer.

FIN-WAIT-2 keeps the restriction and gains the reasoning: it is reached
by our FIN being acknowledged, which acknowledges everything below it, so
no unacknowledged data can remain there.

PendingSegment needs the same distinction, since it decides whether to
offer send-buffer data at all; it gets an unexported State predicate
rather than a new exported one.

Two tests, the second red before the change:

  - retransmission after RTO expiry, covering the Handler/LossRecovery
    seam that the RTO unit tests do not reach (they exercise the state
    machine in isolation, where it behaves correctly).
  - retransmission after a close with unacknowledged data.

Co-authored-by: Derek den Haas <i.pestano@easyflor.nl>
2026-08-24 19:36:48 -03:00
21 changed files with 1063 additions and 1268 deletions
+9
View File
@@ -1,3 +1,12 @@
coverage:
status:
project:
default:
target: 62% # Ensure we don't accumulate too much debt.
patch:
default:
informational: true # Shows the patch metric but never turns red/fails the PR
ignore:
- "examples/**"
- "**/stringers.go"
+14 -2
View File
@@ -157,7 +157,7 @@ func (r *Ring) ReadDiscard(n int) error {
case n > buffered:
return errDiscardExceeds
case n == buffered:
r.Reset()
r.emptied()
case n+r.Off > len(r.Buf):
r.Off = n - (len(r.Buf) - r.Off)
default:
@@ -224,6 +224,18 @@ func (r *Ring) Reset() {
r.End = 0
}
// emptied marks the ring empty keeping the write position where it is, unlike
// [Ring.Reset] which rewinds it to index 0. Bytes staged past that position with
// [Ring.PeekWrite] are addressed relative to it, so moving it makes a later
// [Ring.Commit] hand back the wrong bytes.
func (r *Ring) emptied() {
off := r.End
if off == len(r.Buf) {
off = 0 // Tail exhausted, next write wraps.
}
r.Off, r.End = off, 0
}
// Size returns the capacity of the ring buffer.
func (r *Ring) Size() int {
return len(r.Buf)
@@ -306,7 +318,7 @@ func (r *Ring) onReadEnd(totalRead int) {
}
newOff := r.addOff(r.Off, totalRead)
if newOff == r.End {
r.Reset()
r.emptied()
} else if newOff == len(r.Buf) {
r.Off = 0 // Optimization case.
} else {
+37
View File
@@ -661,3 +661,40 @@ func TestRingPeekWriteRejects(t *testing.T) {
t.Error("Commit beyond free must error")
}
}
// TestRingPeekWriteSurvivesEmptyRead checks bytes staged with [Ring.PeekWrite]
// survive the ring being read empty, an event the stager does not control.
func TestRingPeekWriteSurvivesEmptyRead(t *testing.T) {
r := &Ring{Buf: make([]byte, 16)}
if _, err := r.Write([]byte("AAAA")); err != nil {
t.Fatal("first write:", err)
}
// Stage "CCCC" one 4-byte gap past the write position.
if !r.PeekWrite([]byte("CCCC"), 4) {
t.Fatal("PeekWrite should fit")
}
// Drain everything readable: ring goes empty, staged bytes still pending.
got := make([]byte, 16)
n, err := r.Read(got)
if err != nil || string(got[:n]) != "AAAA" {
t.Fatalf("drain read %q (%v), want AAAA", got[:n], err)
}
if !r.IsEmpty() {
t.Fatal("ring should be empty after draining")
}
// Fill the gap and commit the staged tail.
if _, err := r.Write([]byte("BBBB")); err != nil {
t.Fatal("gap write:", err)
}
if err := r.Commit(4); err != nil {
t.Fatal("commit:", err)
}
n, err = r.Read(got)
if err != nil {
t.Fatal("read:", err)
}
if string(got[:n]) != "BBBBCCCC" {
t.Fatalf("read %q, want BBBBCCCC: the staged bytes were committed from the wrong offset", got[:n])
}
testRingSanity(t, r)
}
+15 -5
View File
@@ -76,10 +76,16 @@ type ConnConfig struct {
// Logger sets the [Conn] logger.
// Lower level logging available at [Handler.SetLoggers] via [Conn.InternalHandler].
Logger *slog.Logger
// Policy is the optional transmit-steering algorithm (RTO, congestion
// control, ...) for the connection. nil disables it. A Policy needing time
// carries its own clock. See [Policy].
Policy Policy
// LossRecovery is the optional packet-loss recovery algorithm (RTO,
// congestion control, ...) for the connection. If set, Nanotime must also be
// set (else Configure returns an error). Leaving it nil disables loss
// recovery. See [LossRecovery].
LossRecovery LossRecovery
// Nanotime is the monotonic time source in nanoseconds (the func() int64
// convention used across lneto) that drives LossRecovery. It is required when
// LossRecovery is set and unused otherwise. The tcp package reads it only to
// stamp the loss-recovery hooks; it holds no clock itself.
Nanotime func() int64
}
// Configure should be called on any newly created connection before usage. See [ConnConfig].
@@ -87,6 +93,10 @@ func (conn *Conn) Configure(config ConnConfig) (err error) {
if config.RWBackoff == nil {
return lneto.ErrMissingHALConfig
}
if config.LossRecovery != nil && config.Nanotime == nil {
// The tcp package holds no clock: a loss-recovery algorithm cannot run without it.
return lneto.ErrInvalidConfig
}
conn.mu.Lock()
defer conn.mu.Unlock()
err = conn.h.SetBuffers(config.TxBuf, config.RxBuf, config.TxPacketQueueSize)
@@ -95,7 +105,7 @@ func (conn *Conn) Configure(config ConnConfig) (err error) {
}
conn._backoff = config.RWBackoff
conn.logger.log = config.Logger
conn.h.SetPolicy(config.Policy)
conn.h.SetLossRecovery(config.LossRecovery, config.Nanotime)
return nil
}
+11 -32
View File
@@ -95,12 +95,6 @@ func (tcb *ControlBlock) RecvWindow() Size { return tcb.rcv.WND }
// ISS returns the initial sequence number of the connection that was defined on a call to Open by user.
func (tcb *ControlBlock) ISS() Value { return tcb.snd.ISS }
// SendUNA returns snd.UNA, the oldest sequence number not yet acked by the remote.
func (tcb *ControlBlock) SendUNA() Value { return tcb.snd.UNA }
// SendNext returns snd.NXT, one past the highest sequence number sent.
func (tcb *ControlBlock) SendNext() Value { return tcb.snd.NXT }
// MaxInFlightData returns the maximum size of a segment that can be sent by taking into account
// the send window size and the unacked data. Returns 0 before StateSynRcvd.
func (tcb *ControlBlock) MaxInFlightData() Size {
@@ -263,32 +257,14 @@ func (tcb *ControlBlock) HasPendingRetransmit() bool {
return tcb._state.TxDataOpen() && tcb.dupack >= retransmitAfterDupacks && tcb.nRetransmit <= tcb.dupack-retransmitAfterDupacks
}
// RetransmitFrom rewinds snd.NXT back to newNxt so the next PendingSegment and
// Send calls retransmit unacknowledged data from that sequence number onwards.
// It must be paired with ringTx.RetransmitFrom to rewind the transmit buffer to
// the same point. Implements RFC 9293 §3.10.8 (RETRANSMISSION TIMEOUT).
//
// It reports false and changes nothing when newNxt falls outside the
// unacknowledged range [snd.UNA, snd.NXT] or the connection cannot send data, so
// a misbehaving [Policy] cannot corrupt the send sequence space.
func (tcb *ControlBlock) RetransmitFrom(newNxt Value) bool {
if !tcb._state.TxDataOpen() {
return false
} else if newNxt.LessThan(tcb.snd.UNA) || tcb.snd.NXT.LessThan(newNxt) {
return false
}
tcb.snd.NXT = newNxt
tcb.dupack = 0
tcb.nRetransmit = 0
return true
}
// RetransmitAll rewinds snd.NXT back to snd.UNA so the next PendingSegment and
// Send calls retransmit all unacknowledged data from the oldest sequence number
// (go-back-N). It must be paired with ringTx.RetransmitFromUNA to rewind the
// transmit buffer. Implements RFC 9293 §3.10.8 (RETRANSMISSION TIMEOUT).
func (tcb *ControlBlock) RetransmitAll() {
tcb.RetransmitFrom(tcb.snd.UNA)
tcb.snd.NXT = tcb.snd.UNA
tcb.dupack = 0
tcb.nRetransmit = 0
}
// PendingSegment calculates a suitable next segment to send from a payload length.
@@ -303,10 +279,9 @@ func (tcb *ControlBlock) PendingSegment(payloadLen int) (_ Segment, ok bool) {
// Optimist Strategy: retransmit oldest data once.
return Segment{SEQ: tcb.snd.UNA, DATALEN: Size(payloadLen), ACK: tcb.rcv.NXT, WND: tcb.rcv.WND, Flags: FlagACK}, true
}
established := tcb._state == StateEstablished
canSendData := established || tcb._state == StateCloseWait
canSendData := tcb._state.txQueuedDataOpen()
if !canSendData {
payloadLen = 0 // Can't send data if not established or close-wait.
payloadLen = 0 // No send-buffer data may go out in this state.
}
if pending == 0 && payloadLen == 0 {
return Segment{}, false // No pending segment.
@@ -546,8 +521,12 @@ func (tcb *ControlBlock) validateOutgoingSegment(seg Segment) (err error) {
err = errSeqNotInWindow
}
case seg.DATALEN > 0 && (tcb._state == StateFinWait1 || tcb._state == StateFinWait2):
err = errConnectionClosing // Case 1: No further SENDs from the user will be accepted by the TCP implementation.
case seg.DATALEN > 0 && tcb._state == StateFinWait2:
// FIN-WAIT-2 means our FIN was acknowledged, so no data below it can be
// unacknowledged and data here is a caller error. FIN-WAIT-1 is excluded:
// its FIN sits above data the peer may still be missing, which must go out
// for either side to make progress (RFC 9293 §3.10.8).
err = errConnectionClosing
case checkSeq && tcb.snd.WND == 0 && seg.DATALEN > 0 && seg.SEQ == tcb.snd.NXT:
err = errZeroWindow
+8
View File
@@ -329,6 +329,14 @@ func (s State) TxDataOpen() bool {
return s == StateEstablished || s == StateCloseWait
}
// txQueuedDataOpen returns true if already-queued send-buffer data may still be
// put on the wire. It stays true after a local close, where the FIN occupies a
// sequence above data the peer has not acknowledged: until that data is
// (re)transmitted the peer cannot reach the FIN. RFC 9293 §3.10.8.
func (s State) txQueuedDataOpen() bool {
return s.TxDataOpen() || s == StateFinWait1 || s == StateClosing || s == StateLastAck
}
// RxDataOpen returns true if the state allows the receiving of incoming data segments.
// Combine with [State.IsPreestablished] to know whether there is no more data to be received over the network.
func (s State) RxDataOpen() bool {
+59 -69
View File
@@ -31,8 +31,14 @@ type Handler struct {
optcodec OptionCodec
// reasm tracks out-of-order segments staged in bufRx's free region. Always
// enabled once buffers are set (see [Handler.SetBuffers]).
reasm reassembly
policy Policy
reasm reassembly
// loss is the optional packet-loss recovery algorithm (RTO, congestion
// control, ...) driven from the rx/tx hooks. nil disables loss recovery, in
// which case the connection behaves as if no timing existed. nanotime is the
// monotonic time source (nanoseconds) passed to those hooks; it is non-nil
// whenever loss is non-nil (enforced by [Conn.Configure]). See [LossRecovery].
loss LossRecovery
nanotime func() int64
closing bool
shutdownRx bool
@@ -73,16 +79,27 @@ func (h *Handler) SetBuffers(txbuf, rxbuf []byte, packets int) error {
return h.bufTx.ResetOrReuse(txbuf, packets, 0)
}
// SetPolicy installs the transmit-steering algorithm. nil disables it.
// It should be set before the connection is opened. See [Policy].
func (h *Handler) SetPolicy(policy Policy) {
h.policy = policy
// SetLossRecovery installs the packet-loss recovery algorithm and the monotonic
// time source (nanoseconds, the func() int64 convention used across lneto) that
// drives it. The tcp package keeps no clock of its own; nanotime is read only to
// stamp the rx/tx hooks (see [LossRecovery]). Passing loss == nil disables loss
// recovery. It should be set before the connection is opened.
func (h *Handler) SetLossRecovery(loss LossRecovery, nanotime func() int64) {
h.loss = loss
h.nanotime = nanotime
}
func (h *Handler) policyEnabled() bool { return h.policy != nil }
// ControlBlock returns the state machine underlying the Handler, mainly so a
// [Policy] can read the sequence spaces. Not for modification.
func (h *Handler) ControlBlock() *ControlBlock { return &h.scb }
func (h *Handler) lossEnabled() bool { return h.loss != nil }
// NextDeadline returns the monotonic-nanosecond instant at which the connection
// must next be serviced by a transmit attempt (e.g. an RTO expiry), or 0 when
// there is no deadline or no loss recovery is configured. See [LossRecovery].
func (h *Handler) NextDeadline() int64 {
if h.loss == nil {
return 0
}
return h.loss.NextDeadline()
}
// LocalPort returns the local port of the connection. Returns 0 if the connection is closed and uninitialized.
func (h *Handler) LocalPort() uint16 {
@@ -148,15 +165,16 @@ func (h *Handler) reset(localPort, remotePort uint16, iss Value) {
shutdownRx: false,
// Persist configuration across reopen:
validator: h.validator,
policy: h.policy,
loss: h.loss,
nanotime: h.nanotime,
logger: h.logger,
// persist memory across repoen:
bufTx: h.bufTx,
bufRx: h.bufRx,
reasm: h.reasm,
}
if h.policyEnabled() {
h.policy.Reset()
if h.lossEnabled() {
h.loss.Reset()
}
h.reasm.clear() // preserve metadata capacity across reopen, drop held segments.
h.bufTx.ResetOrReuse(nil, 0, iss)
@@ -194,7 +212,9 @@ func (h *Handler) Recv(incomingPacket []byte) error {
return nil
}
if h.policyEnabled() && !h.policy.PreRx(h, tfrm) {
// Notify loss recovery of the received segment (RTT sampling, timer
// management) and let it drop the segment before processing if it asks to.
if h.lossEnabled() && !h.loss.PreRx(segIncoming, h.nanotime()).Keep {
return nil
}
@@ -225,9 +245,6 @@ func (h *Handler) Recv(incomingPacket []byte) error {
if prevState != h.scb.State() {
h.info("tcp.Handler:rx-statechange", slog.Uint64("port", uint64(h.localPort)), slog.String("old", prevState.String()), slog.String("new", h.scb.State().String()), slog.String("rxflags", segIncoming.Flags.String()))
}
if h.policyEnabled() {
h.policy.PostRx(h, prevState, tfrm)
}
if segIncoming.DATALEN != 0 && h.shutdownRx && (h.scb.State() == StateFinWait1 || h.scb.State() == StateFinWait2) {
// soypat/lneto#50: the application is done in both directions — read side
// shut down (CloseRead) and our FIN sent (Close) — so inbound data has no
@@ -363,28 +380,16 @@ func (h *Handler) Send(b []byte) (int, error) {
if h.IsTxOver() {
return 0, net.ErrClosed
}
tfrm, err := NewFrame(b)
if err != nil {
return 0, err
}
offset := uint8(5)
var holdNew bool
if h.policyEnabled() {
// Hand the Policy a defined frame: zeroed header at the minimum offset.
// It may append options and raise the offset, which is read back below.
tfrm.ClearHeader()
tfrm.SetOffsetAndFlags(offset, 0)
rtxFrom, doRtx, hold := h.policy.PreTx(h, tfrm)
holdNew = hold
if doRtx && h.scb.RetransmitFrom(rtxFrom) {
// Retransmission directed by the Policy: rewind the transmit buffer
// to match the send sequence so unacknowledged data is resent. Done
// before the early short-circuit below so an expired RTO
// retransmits even with no new data queued.
h.bufTx.RetransmitFrom(rtxFrom)
}
if o, _ := tfrm.OffsetAndFlags(); o > offset && int(o)*4 < len(b) {
offset = o
var now int64
if h.lossEnabled() {
now = h.nanotime()
if h.loss.PreTx(now).RetransmitAll {
// Go-back-N retransmission directed by loss recovery: rewind the
// send sequence and transmit buffer so unacknowledged data is resent
// from snd.UNA. Done before the early short-circuit below so an
// expired RTO retransmits even with no new data queued.
h.scb.RetransmitAll()
h.bufTx.RetransmitFromUNA()
}
}
awaitingSyn := h.AwaitingSynSend()
@@ -400,27 +405,29 @@ func (h *Handler) Send(b []byte) (int, error) {
// Early nop short circuit.
return 0, nil
}
tfrm, err := NewFrame(b)
if err != nil {
return 0, err
}
if buffered == 0 && h.closing && (h.scb.State() != StateCloseWait || !h.scb.HasPending()) {
// If Close called and no more data to be sent, terminate connection.
// In CLOSE-WAIT: wait until the pending ACK is sent first, since scb.Close()
// overwrites pending with [FIN|ACK] (unlike ESTABLISHED which merges via bitmask).
h.closing = false
err := h.scb.Close()
err = h.scb.Close()
if err != nil {
h.logerr("tcp.Handler.Close", slog.String("err", errstr(err)), slog.String("state", h.State().String()))
h.Abort()
return 0, io.EOF
}
}
// optHead is where the Handler's own options begin: after the fixed header
// and after any options the Policy already wrote, so neither clobbers the other.
optHead := int(offset) * 4
mss := uint16(len(b) - optHead)
offset := uint8(5)
mss := uint16(len(b) - sizeHeaderTCP)
var segment Segment
if awaitingSyn || requeueControl && h.scb.State() == StateSynSent {
// Handling init syn segment.
segment = ClientSynSegment(h.bufTx.iss, Size(h.bufRx.Size()))
h.optcodec.PutOption16(b[optHead:], OptMaxSegmentSize, mss)
h.optcodec.PutOption16(b[sizeHeaderTCP:], OptMaxSegmentSize, mss)
offset++
if requeueControl {
h.info("tcp.Handler:requeue-syn", slog.Uint64("port", uint64(h.localPort)), slog.Uint64("rport", uint64(h.remotePort)))
@@ -432,7 +439,7 @@ func (h *Handler) Send(b []byte) (int, error) {
WND: Size(h.bufRx.Free()),
Flags: synack,
}
h.optcodec.PutOption16(b[optHead:], OptMaxSegmentSize, mss)
h.optcodec.PutOption16(b[sizeHeaderTCP:], OptMaxSegmentSize, mss)
offset++
h.info("tcp.Handler:requeue-synack", slog.Uint64("port", uint64(h.localPort)), slog.Uint64("rport", uint64(h.remotePort)))
} else if requeueControl {
@@ -440,22 +447,17 @@ func (h *Handler) Send(b []byte) (int, error) {
return 0, nil
} else {
var ok bool
maxPayload := len(b) - optHead
if holdNew && !h.nextSegmentIsRetransmit() {
// Policy is holding new data back (congestion window exhausted).
// A retransmission it directed in this same call still proceeds.
maxPayload = 0
}
maxPayload := len(b) - sizeHeaderTCP
segment, ok = h.scb.PendingSegment(maxPayload)
segment.WND = h.recvWindow()
if !ok {
// No pending control segment or data to send. Yield.
return 0, nil
} else if segment.Flags == synack {
h.optcodec.PutOption16(b[optHead:], OptMaxSegmentSize, mss)
h.optcodec.PutOption16(b[sizeHeaderTCP:], OptMaxSegmentSize, mss)
offset++
} else if segment.DATALEN > 0 {
n, err := h.bufTx.MakePacket(b[optHead:optHead+int(segment.DATALEN)], segment.SEQ)
n, err := h.bufTx.MakePacket(b[sizeHeaderTCP:sizeHeaderTCP+segment.DATALEN], segment.SEQ)
if err != nil {
return 0, err
}
@@ -472,19 +474,15 @@ func (h *Handler) Send(b []byte) (int, error) {
} else if prevState != h.scb.State() && h.logenabled(slog.LevelInfo) {
h.info("tcp.Handler:tx-statechange", slog.Uint64("port", uint64(h.localPort)), slog.String("oldState", prevState.String()), slog.String("newState", h.scb.State().String()), slog.String("txflags", segment.Flags.String()))
}
if h.lossEnabled() {
h.loss.PostTx(segment, now)
}
h.requeueControl = false
tfrm.SetSourcePort(h.localPort)
tfrm.SetDestinationPort(h.remotePort)
tfrm.SetSegment(segment, offset)
tfrm.SetUrgentPtr(0)
datalen := int(offset)*4 + int(segment.DATALEN)
if h.policyEnabled() {
// Frame trimmed to what is actually emitted so the Policy's Payload()
// is the segment data and nothing more.
if sent, err := NewFrame(b[:datalen]); err == nil {
h.policy.PostTx(h, sent)
}
}
closedSuccess := prevState == StateTimeWait && segment.Flags.HasAny(FlagACK)
if closedSuccess {
h.reset(0, 0, 0)
@@ -496,14 +494,6 @@ func (h *Handler) Send(b []byte) (int, error) {
return datalen, nil
}
// nextSegmentIsRetransmit reports whether the next data segment would resend
// already-transmitted bytes rather than open new sequence space. Used to let a
// retransmission through while a [Policy] holds new data back.
func (h *Handler) nextSegmentIsRetransmit() bool {
endSeq, hasSent := h.bufTx.sentEndSeq()
return hasSent && h.scb.snd.NXT.LessThan(endSeq)
}
// Write implements [io.Writer] by copying b to a internal buffer to be sent over the network on the next
// [Handler.Send] call that can send data to remote peer. Use [Handler.Free] to know the maximum length the argument slice can be before erroring.
func (h *Handler) Write(b []byte) (int, error) {
+83
View File
@@ -0,0 +1,83 @@
package tcp
// LossRecovery abstracts TCP packet-loss recovery: RTO, congestion control and
// any similar algorithm that observes segment traffic and steers the
// connection's transmit behaviour. As far as the tcp package is concerned these
// are all the same thing — packet-loss recovery algorithms — so they share one
// interface (see discussion #157).
//
// The tcp package stays free of any time source: the current monotonic time in
// nanoseconds (the func() int64 convention used across lneto) is passed in at
// each hook boundary. It originates from [ConnConfig.Nanotime] and satisfies the
// "WHEN was this segment rx/tx'd" requirement without a clock living inside the
// state machine, which also keeps implementations deterministic for testing
// (see issue #140).
//
// The interface is intentionally free of errors: an implementation handles or
// reports its own errors rather than propagating them into lneto internals.
//
// Introspection (smoothed RTT, current window, ...) is deliberately left off the
// interface; expose it on the concrete implementation the caller constructs and
// hands to [ConnConfig].
type LossRecovery interface {
// Reset returns the implementation to its initial, pre-connection state. It
// is invoked whenever the connection is (re)opened or aborted so a single
// LossRecovery value can be reused across the lifetime of connection reuse
// (see discussion #115).
Reset()
// NextDeadline returns the monotonic-nanosecond instant at which the
// connection must next be serviced by a transmit attempt — typically the RTO
// expiry. A return of 0 means there is no pending deadline. It replaces a
// poll/atomic-flag scheme with a deadline the caller's event loop can
// schedule against.
NextDeadline() int64
// PreRx is called for every segment received on the TCP port before the
// state machine processes it, with the monotonic time the segment arrived. It
// returns whether the segment should be kept (processed) or dropped.
PreRx(incoming Segment, now int64) RxDirective
// PreTx is called on entering the transmit path (Encapsulate), before a
// segment is built, with the current monotonic time. Its directive tells the
// connection whether to retransmit unacknowledged data, rewind the send
// pointer, or hold back new data.
PreTx(now int64) TxDirective
// PostTx is called on leaving the transmit path with the segment that was
// actually emitted and the monotonic time it was sent. This is where segment
// timing (for RTT sampling and the retransmission timer) is recorded.
PostTx(outgoing Segment, now int64)
}
// TxDirective is returned by [LossRecovery.PreTx] to steer the transmit path.
// The zero value directs the connection to proceed normally (send new data if
// available, no retransmission).
type TxDirective struct {
// RewindNXT is the number of sequence-space octets to rewind snd.NXT by
// before transmitting, for partial (e.g. selective) retransmission. Zero
// means no rewind. It is independent of Retransmit, which rewinds fully to
// snd.UNA.
// RewindNXT uint32
// RetransmitAll requests go-back-N retransmission: the connection rewinds
// snd.NXT to snd.UNA and resends unacknowledged data from the oldest
// sequence number.
RetransmitAll bool
// HoldNew pauses transmission of new data (for example when the congestion
// window is exhausted). Retransmissions already directed by this same
// directive still proceed.
// HoldNew bool
}
// RxDirective is returned by [LossRecovery.PreRx].
//
// NOTE: its shape is the minimum viable contract — it mirrors the original
// PreRx "keep" boolean from discussion #157 — and is the one element of the
// interface not yet fully settled there. It is a struct (rather than a bare
// bool) so fields can be added without breaking implementations.
type RxDirective struct {
// Keep reports whether the received segment should be handed to the state
// machine. A false value drops the segment before it is processed.
Keep bool
}
+262
View File
@@ -0,0 +1,262 @@
package tcp
import (
"math/rand"
"testing"
"github.com/soypat/lneto/ethernet"
)
// recordingLoss is a test LossRecovery that records every hook invocation and
// lets the test steer the directives returned to the Handler. It is the
// interface counterpart driven by the Handler under test.
type recordingLoss struct {
resets int
preRx []hookCall
preTx []int64
postTx []hookCall
deadline int64 // value NextDeadline reports back.
// Directives handed back to the Handler.
keep bool // PreRx result. Default true (see newRecordingLoss).
tx TxDirective // PreTx result.
}
type hookCall struct {
seg Segment
now int64
}
func newRecordingLoss() *recordingLoss { return &recordingLoss{keep: true} }
var _ LossRecovery = (*recordingLoss)(nil)
func (l *recordingLoss) Reset() { l.resets++ }
func (l *recordingLoss) NextDeadline() int64 { return l.deadline }
func (l *recordingLoss) PreRx(incoming Segment, now int64) RxDirective {
l.preRx = append(l.preRx, hookCall{seg: incoming, now: now})
return RxDirective{Keep: l.keep}
}
func (l *recordingLoss) PreTx(now int64) TxDirective {
l.preTx = append(l.preTx, now)
return l.tx
}
func (l *recordingLoss) PostTx(outgoing Segment, now int64) {
l.postTx = append(l.postTx, hookCall{seg: outgoing, now: now})
}
// TestLossRecovery_DisabledByDefault verifies the Handler runs normally with no
// loss recovery installed: NextDeadline reports no deadline and the transmit/
// receive paths never touch a nil LossRecovery.
func TestLossRecovery_DisabledByDefault(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(1))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
setupClientServer(t, rng, client, server)
if d := client.NextDeadline(); d != 0 {
t.Fatalf("NextDeadline with no loss recovery = %d, want 0", d)
}
var buf [mtu]byte
establish(t, client, server, buf[:]) // must not panic on nil loss recovery.
}
// TestLossRecovery_HooksInvoked verifies the Handler drives the full hook
// contract across a handshake: Reset on open, PreTx+PostTx on every transmit,
// PreRx on every receive, each stamped with the configured monotonic clock.
func TestLossRecovery_HooksInvoked(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(2))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
loss := newRecordingLoss()
const clockNow = 1_000_000
client.SetLossRecovery(loss, func() int64 { return clockNow })
setupClientServer(t, rng, client, server) // OpenActive → reset → Reset().
if loss.resets == 0 {
t.Fatal("Reset not called on open")
}
var buf [mtu]byte
establish(t, client, server, buf[:])
// Client emitted SYN and the final ACK: both paths must have hit PreTx/PostTx.
if len(loss.preTx) == 0 {
t.Fatal("PreTx never called on transmit")
}
if len(loss.postTx) == 0 {
t.Fatal("PostTx never called on transmit")
}
if len(loss.preTx) != len(loss.postTx) {
t.Fatalf("PreTx calls=%d, PostTx calls=%d, want equal", len(loss.preTx), len(loss.postTx))
}
// Client received the SYN-ACK: PreRx must have seen it.
if len(loss.preRx) == 0 {
t.Fatal("PreRx never called on receive")
}
// The Handler holds no clock: every hook must be stamped from the supplied
// nanotime source.
for i, c := range loss.postTx {
if c.now != clockNow {
t.Fatalf("PostTx[%d].now = %d, want clock %d", i, c.now, clockNow)
}
}
for i, now := range loss.preTx {
if now != clockNow {
t.Fatalf("PreTx[%d].now = %d, want clock %d", i, now, clockNow)
}
}
for i, c := range loss.preRx {
if c.now != clockNow {
t.Fatalf("PreRx[%d].now = %d, want clock %d", i, c.now, clockNow)
}
}
// PostTx receives the segment actually emitted: the first is the SYN.
if !loss.postTx[0].seg.Flags.HasAny(FlagSYN) {
t.Fatalf("first PostTx segment flags=%s, want SYN", loss.postTx[0].seg.Flags)
}
}
// TestLossRecovery_NextDeadlineDelegates verifies NextDeadline is forwarded to
// the installed LossRecovery unchanged.
func TestLossRecovery_NextDeadlineDelegates(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(3))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
loss := newRecordingLoss()
loss.deadline = 4242
client.SetLossRecovery(loss, func() int64 { return 1 })
setupClientServer(t, rng, client, server)
if d := client.NextDeadline(); d != 4242 {
t.Fatalf("NextDeadline = %d, want delegated 4242", d)
}
}
// TestLossRecovery_PreRxDropsSegment verifies a PreRx directive of Keep=false
// drops the segment before the state machine sees it: the payload is not
// buffered and connection state is untouched.
func TestLossRecovery_PreRxDropsSegment(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(4))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
loss := newRecordingLoss()
server.SetLossRecovery(loss, func() int64 { return 1 })
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:]) // keep=true so handshake completes.
// Now start dropping everything the server receives.
loss.keep = false
preRxBefore := len(loss.preRx)
data := []byte("dropme")
if _, err := client.Write(data); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
n, err := client.Send(buf[:])
if err != nil {
t.Fatal("client send:", err)
}
if err := server.Recv(buf[:n]); err != nil {
t.Fatalf("dropped segment must return nil, got %v", err)
}
if len(loss.preRx) != preRxBefore+1 {
t.Fatalf("PreRx calls=%d, want %d (segment must reach PreRx)", len(loss.preRx), preRxBefore+1)
}
if server.BufferedInput() != 0 {
t.Fatalf("dropped segment must not be buffered, got %d bytes", server.BufferedInput())
}
if server.State() != StateEstablished {
t.Fatalf("dropped segment must not change state, got %s", server.State())
}
}
// TestLossRecovery_PreTxRetransmitAll verifies a PreTx directive of
// RetransmitAll drives go-back-N: the Handler rewinds and re-emits already-sent,
// unacknowledged data from snd.UNA on the next transmit.
func TestLossRecovery_PreTxRetransmitAll(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(5))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
loss := newRecordingLoss()
client.SetLossRecovery(loss, func() int64 { return 1 })
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
// Emit one data segment; server never ACKs, so it stays unacknowledged.
data := []byte("payload")
if _, err := client.Write(data); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
n, err := client.Send(buf[:])
if err != nil {
t.Fatal("client send data:", err)
}
if n <= sizeHeaderTCP {
t.Fatal("expected data segment")
}
firstSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
// Direct go-back-N on the next transmit.
loss.tx = TxDirective{RetransmitAll: true}
clear(buf[:])
n, err = client.Send(buf[:])
if err != nil {
t.Fatal("client send retransmit:", err)
}
if n <= sizeHeaderTCP {
t.Fatal("expected retransmitted data segment")
}
rtSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
if rtSeg.SEQ != firstSeg.SEQ {
t.Fatalf("retransmit SEQ=%d, want original UNA SEQ=%d (go-back-N)", rtSeg.SEQ, firstSeg.SEQ)
}
if rtSeg.DATALEN != firstSeg.DATALEN {
t.Fatalf("retransmit DATALEN=%d, want %d", rtSeg.DATALEN, firstSeg.DATALEN)
}
}
// TestLossRecovery_ResetOnReopen verifies Reset fires on every (re)open and on
// Abort, so a single LossRecovery value can be reused across connection reuse.
func TestLossRecovery_ResetOnReopen(t *testing.T) {
const mtu = ethernet.MaxMTU
client := newHandler(t, mtu, 3)
loss := newRecordingLoss()
client.SetLossRecovery(loss, func() int64 { return 1 })
if err := client.OpenActive(1234, 5678, 0); err != nil {
t.Fatal("open 1:", err)
}
afterOpen := loss.resets
if afterOpen == 0 {
t.Fatal("Reset not called on first open")
}
client.Abort()
if loss.resets <= afterOpen {
t.Fatalf("Reset not called on Abort: resets=%d, want >%d", loss.resets, afterOpen)
}
afterAbort := loss.resets
if err := client.OpenActive(1234, 5678, 0); err != nil {
t.Fatal("open 2:", err)
}
if loss.resets <= afterAbort {
t.Fatalf("Reset not called on reopen: resets=%d, want >%d", loss.resets, afterAbort)
}
}
-24
View File
@@ -1,24 +0,0 @@
package tcp
// Policy observes segment traffic and steers transmit behaviour: RTO,
// congestion control and the like (discussion #157). The tcp package holds no
// clock, so a Policy needing time carries its own (issue #140).
// Introspection stays off the interface; put it on the concrete type.
type Policy interface {
// Reset returns the Policy to its pre-connection state. Called on every
// (re)open and Abort. Must preserve configuration such as a clock.
Reset()
// PreTx is called before writing to a frame.
// The outgoing frame options can be set by the Policy and will be respected if Frame offset >5.
// rtxFrom is ignored unless within [snd.UNA, snd.NXT]. Nothing is committed
// until PostTx: a transmit attempt may emit no segment at all.
PreTx(h *Handler, outgoingOpts Frame) (rtxFrom Value, retransmit, holdNew bool)
// PreRx is called by [Handler] on every incoming segment.
// PreRx can choose to drop segment if it returns keep=false.
PreRx(h *Handler, incoming Frame) (keep bool)
// PostRx is called by [Handler] after accepting an incoming segment.
// TODO: congestion control will also want the pre-Recv snd.UNA here.
PostRx(h *Handler, prevState State, accepted Frame)
// PostTx called on leaving the transmit path with the fully written frame.
PostTx(h *Handler, outgoing Frame)
}
-434
View File
@@ -1,434 +0,0 @@
package tcp
import (
"math/rand"
"testing"
"github.com/soypat/lneto/ethernet"
)
// recordingPolicy records every hook invocation and lets the test steer what is
// returned to the Handler. It is the [Policy] counterpart driven by the Handler
// under test.
type recordingPolicy struct {
resets int
preRx []Segment
preTx int
postRx []Segment
postTx []txRecord
// Values handed back to the Handler.
keep bool // PreRx result. Default true (see newRecordingPolicy).
rtxFrom Value
retransmit bool
holdNew bool
// writeOpts, when non-empty, is appended as TCP options by PreTx.
writeOpts []byte
}
// txRecord is what PostTx observed on the emitted frame.
type txRecord struct {
seg Segment
offset uint8
sport uint16
dport uint16
}
func newRecordingPolicy() *recordingPolicy { return &recordingPolicy{keep: true} }
var _ Policy = (*recordingPolicy)(nil)
func (p *recordingPolicy) Reset() { p.resets++ }
func (p *recordingPolicy) PreRx(h *Handler, incoming Frame) bool {
p.preRx = append(p.preRx, incoming.Segment(len(incoming.Payload())))
return p.keep
}
func (p *recordingPolicy) PostRx(h *Handler, prevState State, accepted Frame) {
p.postRx = append(p.postRx, accepted.Segment(len(accepted.Payload())))
}
func (p *recordingPolicy) PreTx(h *Handler, outgoingOpts Frame) (Value, bool, bool) {
p.preTx++
if len(p.writeOpts) > 0 {
// Raise the offset first: Options() is sized from it.
words := uint8(5 + (len(p.writeOpts)+3)/4)
outgoingOpts.SetOffsetAndFlags(words, 0)
copy(outgoingOpts.Options(), p.writeOpts)
}
return p.rtxFrom, p.retransmit, p.holdNew
}
func (p *recordingPolicy) PostTx(h *Handler, outgoing Frame) {
offset, _ := outgoing.OffsetAndFlags()
p.postTx = append(p.postTx, txRecord{
seg: outgoing.Segment(len(outgoing.Payload())),
offset: offset,
sport: outgoing.SourcePort(),
dport: outgoing.DestinationPort(),
})
}
// TestPolicy_DisabledByDefault verifies the Handler runs normally with no Policy
// installed: the transmit and receive paths never touch a nil Policy.
func TestPolicy_DisabledByDefault(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(1))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:]) // must not panic on nil Policy.
}
// TestPolicy_HooksInvoked verifies the Handler drives the full hook contract
// across a handshake: Reset on open, PreTx+PostTx on transmit, PreRx+PostRx on
// receive.
func TestPolicy_HooksInvoked(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(2))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
pol := newRecordingPolicy()
client.SetPolicy(pol)
setupClientServer(t, rng, client, server) // OpenActive → reset → Reset().
if pol.resets == 0 {
t.Fatal("Reset not called on open")
}
var buf [mtu]byte
establish(t, client, server, buf[:])
if pol.preTx == 0 {
t.Fatal("PreTx never called on transmit")
}
if len(pol.postTx) == 0 {
t.Fatal("PostTx never called on transmit")
}
if pol.preTx < len(pol.postTx) {
t.Fatalf("PreTx calls=%d < PostTx calls=%d: PostTx must never fire without PreTx", pol.preTx, len(pol.postTx))
}
// Client received the SYN-ACK and accepted it.
if len(pol.preRx) == 0 {
t.Fatal("PreRx never called on receive")
}
if len(pol.postRx) == 0 {
t.Fatal("PostRx never called on accepted receive")
}
// PostTx receives the segment actually emitted: the first is the SYN.
if !pol.postTx[0].seg.Flags.HasAny(FlagSYN) {
t.Fatalf("first PostTx segment flags=%s, want SYN", pol.postTx[0].seg.Flags)
}
}
// TestPolicy_PostTxSeesWrittenFrame verifies PostTx observes the fully populated
// frame — ports, sequence numbers and payload length as emitted — and not the
// frame as it stood before the segment was written into it.
func TestPolicy_PostTxSeesWrittenFrame(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(6))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
pol := newRecordingPolicy()
client.SetPolicy(pol)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
data := []byte("payload")
if _, err := client.Write(data); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
n, err := client.Send(buf[:])
if err != nil {
t.Fatal("client send:", err)
}
last := pol.postTx[len(pol.postTx)-1]
wantSeg := mustSegment(t, buf[:n], n-int(last.offset)*4)
if last.seg != wantSeg {
t.Fatalf("PostTx segment=%+v, want emitted %+v", last.seg, wantSeg)
}
if int(last.seg.DATALEN) != len(data) {
t.Fatalf("PostTx DATALEN=%d, want %d", last.seg.DATALEN, len(data))
}
if last.sport != client.LocalPort() || last.dport != client.RemotePort() {
t.Fatalf("PostTx ports=%d→%d, want %d→%d", last.sport, last.dport, client.LocalPort(), client.RemotePort())
}
}
// TestPolicy_NoPostTxWithoutSegment verifies a transmit attempt that emits
// nothing still runs PreTx but never PostTx, so a Policy cannot mistake a
// no-op Send for a segment on the wire.
func TestPolicy_NoPostTxWithoutSegment(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(7))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
pol := newRecordingPolicy()
client.SetPolicy(pol)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
preTxBefore, postTxBefore := pol.preTx, len(pol.postTx)
n, err := client.Send(buf[:]) // Nothing queued: no segment.
if err != nil {
t.Fatal("client send:", err)
}
if n != 0 {
t.Fatalf("expected no segment, got %d bytes", n)
}
if pol.preTx != preTxBefore+1 {
t.Fatalf("PreTx calls=%d, want %d: PreTx must run on every attempt", pol.preTx, preTxBefore+1)
}
if len(pol.postTx) != postTxBefore {
t.Fatalf("PostTx calls=%d, want %d: no segment was emitted", len(pol.postTx), postTxBefore)
}
}
// TestPolicy_PreTxOptions verifies options written by PreTx survive to the wire:
// the data offset accounts for them and the payload starts after them.
func TestPolicy_PreTxOptions(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(8))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
pol := newRecordingPolicy()
client.SetPolicy(pol)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
// One 4-byte option word: NOP,NOP,NOP,EOL.
opts := []byte{1, 1, 1, 0}
pol.writeOpts = opts
data := []byte("payload")
if _, err := client.Write(data); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
n, err := client.Send(buf[:])
if err != nil {
t.Fatal("client send:", err)
}
frm, err := NewFrame(buf[:n])
if err != nil {
t.Fatal("frame:", err)
}
offset, _ := frm.OffsetAndFlags()
if offset != 6 {
t.Fatalf("data offset=%d, want 6 (header + one option word)", offset)
}
if got := frm.Options(); string(got) != string(opts) {
t.Fatalf("options=%v, want %v", got, opts)
}
if got := frm.Payload(); string(got) != string(data) {
t.Fatalf("payload=%q, want %q: options must not overlap data", got, data)
}
if n != int(offset)*4+len(data) {
t.Fatalf("frame length=%d, want %d", n, int(offset)*4+len(data))
}
}
// TestPolicy_PreRxDropsSegment verifies keep=false drops the segment before the
// state machine sees it: the payload is not buffered, connection state is
// untouched and PostRx never fires.
func TestPolicy_PreRxDropsSegment(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(4))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
pol := newRecordingPolicy()
server.SetPolicy(pol)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:]) // keep=true so handshake completes.
// Now start dropping everything the server receives.
pol.keep = false
preRxBefore, postRxBefore := len(pol.preRx), len(pol.postRx)
data := []byte("dropme")
if _, err := client.Write(data); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
n, err := client.Send(buf[:])
if err != nil {
t.Fatal("client send:", err)
}
if err := server.Recv(buf[:n]); err != nil {
t.Fatalf("dropped segment must return nil, got %v", err)
}
if len(pol.preRx) != preRxBefore+1 {
t.Fatalf("PreRx calls=%d, want %d (segment must reach PreRx)", len(pol.preRx), preRxBefore+1)
}
if len(pol.postRx) != postRxBefore {
t.Fatalf("PostRx calls=%d, want %d: a dropped segment was never accepted", len(pol.postRx), postRxBefore)
}
if server.BufferedInput() != 0 {
t.Fatalf("dropped segment must not be buffered, got %d bytes", server.BufferedInput())
}
if server.State() != StateEstablished {
t.Fatalf("dropped segment must not change state, got %s", server.State())
}
}
// TestPolicy_PreTxRetransmit verifies a PreTx retransmit directive drives
// go-back-N: the Handler rewinds the send sequence and the transmit buffer
// together and re-emits already-sent, unacknowledged data from snd.UNA.
func TestPolicy_PreTxRetransmit(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(5))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
pol := newRecordingPolicy()
client.SetPolicy(pol)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
// Emit one data segment; server never ACKs, so it stays unacknowledged.
data := []byte("payload")
if _, err := client.Write(data); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
n, err := client.Send(buf[:])
if err != nil {
t.Fatal("client send data:", err)
}
if n <= sizeHeaderTCP {
t.Fatal("expected data segment")
}
firstSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
firstData := append([]byte(nil), buf[sizeHeaderTCP:n]...)
// Direct go-back-N on the next transmit.
pol.rtxFrom, pol.retransmit = client.ControlBlock().SendUNA(), true
clear(buf[:])
n, err = client.Send(buf[:])
if err != nil {
t.Fatal("client send retransmit:", err)
}
if n <= sizeHeaderTCP {
t.Fatal("expected retransmitted data segment")
}
rtSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
if rtSeg.SEQ != firstSeg.SEQ {
t.Fatalf("retransmit SEQ=%d, want original UNA SEQ=%d (go-back-N)", rtSeg.SEQ, firstSeg.SEQ)
}
if rtSeg.DATALEN != firstSeg.DATALEN {
t.Fatalf("retransmit DATALEN=%d, want %d", rtSeg.DATALEN, firstSeg.DATALEN)
}
if got := buf[sizeHeaderTCP:n]; string(got) != string(firstData) {
t.Fatalf("retransmit payload=%q, want %q", got, firstData)
}
}
// TestPolicy_PreTxRetransmitOutOfRange verifies an out-of-range rtxFrom is
// refused, leaving the send sequence and transmit buffer untouched.
func TestPolicy_PreTxRetransmitOutOfRange(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(9))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
pol := newRecordingPolicy()
client.SetPolicy(pol)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
if _, err := client.Write([]byte("payload")); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
if _, err := client.Send(buf[:]); err != nil {
t.Fatal("client send data:", err)
}
nxtBefore := client.ControlBlock().SendNext()
// Well beyond snd.NXT: must be refused.
pol.rtxFrom, pol.retransmit = nxtBefore+1000, true
clear(buf[:])
if _, err := client.Send(buf[:]); err != nil {
t.Fatal("client send:", err)
}
if got := client.ControlBlock().SendNext(); got != nxtBefore {
t.Fatalf("snd.NXT=%d, want unchanged %d: out-of-range rtxFrom must be refused", got, nxtBefore)
}
}
// TestPolicy_HoldNew verifies holdNew suppresses new data while leaving control
// segments free to go out.
func TestPolicy_HoldNew(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(10))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
pol := newRecordingPolicy()
client.SetPolicy(pol)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
pol.holdNew = true
if _, err := client.Write([]byte("payload")); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
n, err := client.Send(buf[:])
if err != nil {
t.Fatal("client send:", err)
}
if n > sizeHeaderTCP {
t.Fatalf("holdNew must suppress new data, got %d payload bytes", n-sizeHeaderTCP)
}
// Releasing the hold lets the same data out.
pol.holdNew = false
clear(buf[:])
n, err = client.Send(buf[:])
if err != nil {
t.Fatal("client send after hold:", err)
}
if n <= sizeHeaderTCP {
t.Fatal("data must flow once holdNew is cleared")
}
}
// TestPolicy_ResetOnReopen verifies Reset fires on every (re)open and on Abort,
// so a single Policy value can be reused across connection reuse.
func TestPolicy_ResetOnReopen(t *testing.T) {
const mtu = ethernet.MaxMTU
client := newHandler(t, mtu, 3)
pol := newRecordingPolicy()
client.SetPolicy(pol)
if err := client.OpenActive(1234, 5678, 0); err != nil {
t.Fatal("open 1:", err)
}
afterOpen := pol.resets
if afterOpen == 0 {
t.Fatal("Reset not called on first open")
}
client.Abort()
if pol.resets <= afterOpen {
t.Fatalf("Reset not called on Abort: resets=%d, want >%d", pol.resets, afterOpen)
}
afterAbort := pol.resets
if err := client.OpenActive(1234, 5678, 0); err != nil {
t.Fatal("open 2:", err)
}
if pol.resets <= afterAbort {
t.Fatalf("Reset not called on reopen: resets=%d, want >%d", pol.resets, afterAbort)
}
}
+122
View File
@@ -0,0 +1,122 @@
package tcp
import (
"math/rand"
"strconv"
"testing"
"github.com/soypat/lneto/ethernet"
)
// TestHandlerStreamIntegrityUnderReorder asserts byte identity of a reassembled
// stream whose segments arrive out of order: arrival order is randomised within
// each block of shuffleWindow segments, and nothing is lost or retransmitted, so
// several segments sit staged in the receive ring at once. Reordering may cost
// throughput; it may not change the bytes.
func TestHandlerStreamIntegrityUnderReorder(t *testing.T) {
const (
mtu = ethernet.MaxMTU
maxpackets = 8
segSize = 100
nsegs = 8 // per round; 800 bytes through a 1500-byte ring
rounds = 40 // enough for the ring to wrap many times
shuffleWindow = 4 // segments that may arrive in any order among themselves
)
rng := rand.New(rand.NewSource(3))
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
setupClientServer(t, rng, client, server)
var rawbuf [mtu]byte
establish(t, client, server, rawbuf[:])
var want, got []byte
rb := make([]byte, mtu)
letter := byte('A')
for round := range rounds {
// Capture this round's segments on the wire, one segment per write.
segs := make([][]byte, 0, nsegs)
for range nsegs {
payload := make([]byte, segSize)
for j := range payload {
payload[j] = letter
}
letter++
if letter > 'Z' {
letter = 'A'
}
if n, err := client.Write(payload); err != nil || n != segSize {
t.Fatalf("round %d: client write: %d %v", round, n, err)
}
clear(rawbuf[:])
n, err := client.Send(rawbuf[:])
if err != nil {
t.Fatalf("round %d: client send: %v", round, err)
}
segs = append(segs, append([]byte(nil), rawbuf[:n]...))
want = append(want, payload...)
}
order := make([]int, 0, nsegs)
for i := 0; i < nsegs; i += shuffleWindow {
block := make([]int, 0, shuffleWindow)
for j := i; j < min(i+shuffleWindow, nsegs); j++ {
block = append(block, j)
}
rng.Shuffle(len(block), func(a, b int) { block[a], block[b] = block[b], block[a] })
order = append(order, block...)
}
for _, idx := range order {
if err := server.Recv(append([]byte(nil), segs[idx]...)); err != nil {
t.Logf("round %d segment %d refused: %v", round, idx, err)
}
// Drain as an application would, keeping the ring from filling.
for {
n, err := server.Read(rb)
if n > 0 {
got = append(got, rb[:n]...)
}
if n == 0 || err != nil {
break
}
}
// Feed ACKs back so the sender's window keeps opening; without this
// the test stalls on flow control instead of exercising reassembly.
clear(rawbuf[:])
if n, err := server.Send(rawbuf[:]); err == nil && n > 0 {
client.Recv(rawbuf[:n])
}
}
if string(got) != string(want) {
// Report the first divergence; later rounds only add noise.
i := 0
for i < len(got) && i < len(want) && got[i] == want[i] {
i++
}
t.Errorf("stream diverges in round %d at byte %d of %d; arrival order %v",
round, i, len(want), order)
lo := max(0, i-200)
t.Errorf("got %s", summarizeRuns(got[lo:min(len(got), i+200)]))
t.Errorf("want %s", summarizeRuns(want[lo:min(len(want), i+200)]))
t.FailNow()
}
}
t.Logf("%d bytes intact across %d rounds of reordering (window %d)", len(got), rounds, shuffleWindow)
}
// summarizeRuns renders a byte stream as run-length pairs ("A*100 B*100") so a
// duplicated or missing segment is visible at a glance.
func summarizeRuns(b []byte) string {
out := make([]byte, 0, 64)
for i := 0; i < len(b); {
j := i
for j < len(b) && b[j] == b[i] {
j++
}
out = append(out, b[i], '*')
out = append(out, strconv.Itoa(j-i)...)
out = append(out, ' ')
i = j
}
return string(out)
}
+47 -113
View File
@@ -1,11 +1,6 @@
package rto
package tcp
import (
"time"
"github.com/soypat/lneto"
"github.com/soypat/lneto/tcp"
)
import "time"
// RFC 6298 retransmission-timeout (RTO) parameters. The algorithm keeps a
// single retransmission timer per connection (RFC 6298 §5): the timer is
@@ -35,80 +30,61 @@ const (
backoffMax = 12
)
// Timer implements the RFC 6298 round-trip-time estimator and the single
// retransmission timer as a [tcp.Policy]. Construct it with [NewTimer] and hand
// it to [tcp.ConnConfig.Policy].
// RTO implements the RFC 6298 round-trip-time estimator and the single
// retransmission timer as a [LossRecovery]. Construct it with new(RTO) and hand
// it to [ConnConfig.LossRecovery]; the connection calls [RTO.Reset] on open, so
// the zero value is ready to use.
//
// Timer is a pure, reactive state machine: it observes the segments a connection
// sends and receives (via the tcp.Policy hooks) and from those alone derives RTT
// estimates and retransmission decisions. The tcp package holds no clock, so the
// Timer carries its own; injecting it keeps the estimator deterministic for unit
// testing (see issue #140).
// RTO is a pure, reactive state machine: it observes the segments a connection
// sends and receives (via the LossRecovery hooks) and the monotonic time handed
// in at each hook, and from those alone derives RTT estimates and retransmission
// decisions. It holds no clock and allocates nothing, which keeps it
// deterministic for unit testing (see issue #140).
//
// Timer tracks its own shadow of the send sequence space purely from the segments
// it observes: [Timer.PostTx] advances the highest sequence sent and [Timer.PreRx]
// RTO tracks its own shadow of the send sequence space purely from the segments
// it observes: [RTO.PostTx] advances the highest sequence sent and [RTO.PreRx]
// advances the highest sequence acknowledged. This is what lets it manage the
// timer (RFC 6298 §5.2/§5.3) without reaching into the tcp state machine, and it
// is also how retransmissions are distinguished for Karn's algorithm — a segment
// whose sequence space is not beyond the shadow snd.NXT is a retransmission and
// is never RTT-sampled.
type Timer struct {
// nanotime is the monotonic time source in nanoseconds. Preserved by Reset.
nanotime func() int64
type RTO struct {
srtt time.Duration // smoothed round-trip time (SRTT).
rttvar time.Duration // round-trip-time variation (RTTVAR).
rto time.Duration // current retransmission timeout.
haveRTT bool // false until the first RTT sample is taken.
// Shadow of the send sequence space, derived from observed segments.
haveSeq bool // false until the first data segment is observed.
sndUNA tcp.Value // highest acknowledged sequence number seen on the wire.
sndNXT tcp.Value // one past the highest sequence number sent.
haveSeq bool // false until the first data segment is observed.
sndUNA Value // highest acknowledged sequence number seen on the wire.
sndNXT Value // one past the highest sequence number sent.
// RTT sampling state (Karn's algorithm, RFC 6298 §3): at most one segment is
// timed at a time and retransmitted segments are never sampled.
timing bool
timedSeq tcp.Value // ACK at or beyond this value completes the sample.
timedAt int64 // send time (monotonic ns) of the timed segment.
timedSeq Value // ACK at or beyond this value completes the sample.
timedAt int64 // send time (monotonic ns) of the timed segment.
// Retransmission timer state.
running bool
deadline int64 // time (monotonic ns) at which the timer expires.
backoff uint8 // consecutive timeouts, for exponential backoff.
// expirations counts timeouts since Reset. It exists so a policy sharing this
// timer can notice a timeout it did not itself drive: a congestion controller
// must collapse its window on one, and a policy that composes the timer as a
// peer never sees the timer's own directive.
expirations uint32
}
var _ tcp.Policy = (*Timer)(nil)
var _ LossRecovery = (*RTO)(nil)
// Configure prepares the Timer for use with nanotime, the monotonic time source
// in nanoseconds (the func() int64 convention used across lneto). It must be
// called before the connection is opened.
func (r *Timer) Configure(nanotime func() int64) error {
if nanotime == nil {
return lneto.ErrMissingHALConfig // The estimator cannot run without a clock.
}
*r = Timer{rto: rtoInitial, nanotime: nanotime}
return nil
}
// Reset returns the estimator to its pre-connection state with the initial RTO,
// preserving the configured clock. It implements [tcp.Policy] and is called when
// the connection opens or aborts so the estimator survives connection reuse.
func (r *Timer) Reset() { *r = Timer{rto: rtoInitial, nanotime: r.nanotime} }
// Reset returns the estimator to its pre-connection state with the initial RTO.
// It implements [LossRecovery] and is called when the connection opens or aborts
// so the estimator can be reused across connection reuse.
func (r *RTO) Reset() { *r = RTO{rto: rtoInitial} }
// SmoothedRTT returns the current smoothed round-trip time (SRTT), or zero
// before the first RTT measurement. It is concrete-type introspection and is
// intentionally not part of [tcp.Policy].
func (r *Timer) SmoothedRTT() time.Duration { return r.srtt }
// intentionally not part of [LossRecovery].
func (r *RTO) SmoothedRTT() time.Duration { return r.srtt }
// CurrentRTO returns the timeout currently in effect, clamped to [rtoMin, rtoMax].
func (r *Timer) CurrentRTO() time.Duration {
func (r *RTO) CurrentRTO() time.Duration {
rto := r.rto
if rto < rtoMin {
rto = rtoMin
@@ -119,46 +95,23 @@ func (r *Timer) CurrentRTO() time.Duration {
}
// Running reports whether the retransmission timer is currently armed.
func (r *Timer) Running() bool { return r.running }
// Expirations returns how many times the retransmission timer has expired since
// [Timer.Reset]. A policy that shares this timer rather than driving it watches
// this for a change to learn that a timeout happened, since it never sees the
// timer's own directive. It is concrete-type introspection and is intentionally
// not part of [tcp.Policy].
func (r *Timer) Expirations() uint32 { return r.expirations }
func (r *RTO) Running() bool { return r.running }
// NextDeadline returns the monotonic-nanosecond instant at which the timer
// expires, or 0 when it is not armed. It is concrete-type introspection, not
// part of [tcp.Policy]: an event loop that wants to schedule against the RTO
// holds the Timer it configured and reads this.
func (r *Timer) NextDeadline() int64 {
// expires, or 0 when it is not armed. It implements [LossRecovery].
func (r *RTO) NextDeadline() int64 {
if !r.running {
return 0
}
return r.deadline
}
// PreRx keeps every segment: the estimator never drops traffic and records
// nothing before the connection has decided whether the segment counts. It
// implements [tcp.Policy].
func (r *Timer) PreRx(h *tcp.Handler, incoming tcp.Frame) bool {
return true
}
// PostRx samples the RTT and manages the retransmission timer from a segment the
// connection accepted (RFC 6298 §5.2/§5.3). It implements [tcp.Policy].
//
// Only accepted segments reach here. Acting on a refused one would let an
// acknowledgement the state machine rejected, for data never sent, collapse the
// backoff and take a bogus RTT sample.
func (r *Timer) PostRx(h *tcp.Handler, prevState tcp.State, accepted tcp.Frame) {
r.postRx(accepted.Segment(len(accepted.Payload())), r.nanotime())
}
func (r *Timer) postRx(incoming tcp.Segment, now int64) {
if !r.haveSeq || !incoming.Flags.HasAny(tcp.FlagACK) {
return
// PreRx samples the RTT and manages the retransmission timer from a received
// segment (RFC 6298 §5.2/§5.3). It implements [LossRecovery] and always keeps
// the segment (the estimator never drops traffic).
func (r *RTO) PreRx(incoming Segment, now int64) RxDirective {
if !r.haveSeq || !incoming.Flags.HasAny(FlagACK) {
return RxDirective{Keep: true}
}
ack := incoming.ACK
if r.timing && !ack.LessThan(r.timedSeq) {
@@ -179,22 +132,18 @@ func (r *Timer) postRx(incoming tcp.Segment, now int64) {
r.running = true
r.deadline = now + int64(r.CurrentRTO())
}
return RxDirective{Keep: true}
}
// PreTx reports whether the retransmission timer has expired and, if so, applies
// the RFC 6298 §5.4–§5.6 timeout response — discard the outstanding RTT sample
// (Karn), back the RTO off exponentially and restart the timer — and asks the
// connection to retransmit from snd.UNA (go-back-N). It writes no TCP options:
// retransmission timing needs none of its own. It implements [tcp.Policy].
func (r *Timer) PreTx(h *tcp.Handler, outgoingOpts tcp.Frame) (rtxFrom tcp.Value, retransmit, holdNew bool) {
return r.preTx(r.nanotime(), h.ControlBlock().SendUNA())
}
func (r *Timer) preTx(now int64, una tcp.Value) (rtxFrom tcp.Value, retransmit, holdNew bool) {
// (Karn), back the RTO off exponentially and restart the timer — returning a
// directive that asks the connection to retransmit from snd.UNA (go-back-N). It
// implements [LossRecovery].
func (r *RTO) PreTx(now int64) TxDirective {
if !r.running || now < r.deadline || r.sndUNA == r.sndNXT {
return 0, false, false
return TxDirective{}
}
r.expirations++
r.timing = false // §5.4: do not sample a retransmitted segment.
if r.backoff < backoffMax {
r.backoff++
@@ -202,24 +151,20 @@ func (r *Timer) preTx(now int64, una tcp.Value) (rtxFrom tcp.Value, retransmit,
}
r.running = true
r.deadline = now + int64(r.CurrentRTO())
return una, true, false
return TxDirective{RetransmitAll: true}
}
// PostTx records an emitted segment: it advances the shadow send sequence,
// begins timing newly transmitted data (RFC 6298 §3) and arms the timer (§5.1).
// Segments that do not extend the send sequence are retransmissions and are
// never RTT-sampled (Karn's algorithm). Control-only segments (no data) are
// ignored. It implements [tcp.Policy].
func (r *Timer) PostTx(h *tcp.Handler, outgoing tcp.Frame) {
r.postTx(outgoing.Segment(len(outgoing.Payload())), r.nanotime())
}
func (r *Timer) postTx(outgoing tcp.Segment, now int64) {
// ignored. It implements [LossRecovery].
func (r *RTO) PostTx(outgoing Segment, now int64) {
if outgoing.DATALEN == 0 {
return // only data segments are timed / arm the RTO.
}
segStart := outgoing.SEQ
segEnd := segStart + tcp.Value(outgoing.LEN())
segEnd := segStart + Value(outgoing.LEN())
if !r.haveSeq {
r.haveSeq = true
r.sndUNA = segStart
@@ -244,20 +189,9 @@ func (r *Timer) postTx(outgoing tcp.Segment, now int64) {
}
}
// ObserveRTT folds a round-trip measurement taken by other means into the
// estimator, for a policy that composes this timer and can measure the round trip
// more accurately than acknowledgement timing allows. The RFC 7323 timestamp echo
// is the case this exists for.
//
// Unlike the timer's own sampling this does not apply Karn's algorithm, because a
// sample derived from an echoed timestamp is unambiguous even when the segment
// carrying it was a retransmission (RFC 7323 §4.1). Non-positive samples are
// ignored.
func (r *Timer) ObserveRTT(rtt time.Duration) { r.updateRTT(rtt) }
// updateRTT folds a round-trip measurement into SRTT/RTTVAR/RTO using the
// integer-shift form of RFC 6298 §2.2/§2.3.
func (r *Timer) updateRTT(sample time.Duration) {
func (r *RTO) updateRTT(sample time.Duration) {
if sample <= 0 {
return
}
-311
View File
@@ -1,311 +0,0 @@
package rto
import (
"testing"
"time"
"github.com/soypat/lneto/tcp"
)
const rtoMs = int64(time.Millisecond)
// dataSeg builds a data segment of datalen octets starting at seq.
func dataSeg(seq uint32, datalen int) tcp.Segment {
return tcp.Segment{SEQ: tcp.Value(seq), DATALEN: tcp.Size(datalen), Flags: tcp.FlagPSH | tcp.FlagACK}
}
// ackSeg builds a bare ACK acknowledging up to ack.
func ackSeg(ack uint32) tcp.Segment {
return tcp.Segment{ACK: tcp.Value(ack), Flags: tcp.FlagACK}
}
func newRTO() *Timer {
var r Timer
if err := r.Configure(func() int64 { return 0 }); err != nil {
panic(err)
}
return &r
}
// frameOf renders a segment as the wire frame the [tcp.Policy] hooks receive.
func frameOf(t *testing.T, s tcp.Segment) tcp.Frame {
t.Helper()
frm, err := tcp.NewFrame(make([]byte, 20+int(s.DATALEN)))
if err != nil {
t.Fatal(err)
}
frm.SetSegment(s, 5)
return frm
}
func TestRTO_Configure(t *testing.T) {
var r Timer
if err := r.Configure(nil); err == nil {
t.Error("Configure must reject a nil clock")
}
if err := r.Configure(func() int64 { return 0 }); err != nil {
t.Fatal(err)
}
if r.nanotime == nil {
t.Fatal("clock not stored")
}
r.Reset()
if r.nanotime == nil {
t.Error("Reset must preserve the configured clock")
}
}
func TestRTO_Reset(t *testing.T) {
r := newRTO()
r.Reset()
if r.rto != rtoInitial {
t.Errorf("initial rto=%v, want %v", r.rto, rtoInitial)
}
if r.CurrentRTO() != rtoInitial {
t.Errorf("CurrentRTO=%v, want %v", r.CurrentRTO(), rtoInitial)
}
if r.haveRTT {
t.Error("haveRTT should be false before first sample")
}
if r.Running() || r.NextDeadline() != 0 {
t.Error("timer must be disarmed after Reset")
}
}
// TestRTO_ArmOnSendSampleOnAck sends data, verifies the timer arms, then acks it
// and verifies an RTT sample is taken and the timer stops once all data is acked.
func TestRTO_ArmOnSendSampleOnAck(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.postTx(dataSeg(iss, 100), 0)
if !r.Running() {
t.Fatal("timer must arm after sending data")
}
if r.NextDeadline() != int64(rtoInitial) {
t.Errorf("deadline=%d, want %d", r.NextDeadline(), int64(rtoInitial))
}
// ACK arrives one RTT (40ms) later covering all sent data.
if !r.PreRx(nil, frameOf(t, ackSeg(iss+100))) {
t.Error("PreRx must keep the segment")
}
r.postRx(ackSeg(iss+100), 40*rtoMs)
if r.Running() {
t.Error("timer must stop once all data is acknowledged")
}
if r.SmoothedRTT() != 40*time.Millisecond {
t.Errorf("srtt=%v, want 40ms", r.SmoothedRTT())
}
}
// TestRTO_RetransmitOnTimeout verifies PreTx directs a go-back-N retransmit once
// the deadline passes with data outstanding, and backs the RTO off.
func TestRTO_RetransmitOnTimeout(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.postTx(dataSeg(iss, 100), 0)
if _, rtx, _ := r.preTx(int64(rtoInitial)-1, tcp.Value(iss)); rtx {
t.Fatal("must not retransmit before the deadline")
}
from, rtx, hold := r.preTx(int64(rtoInitial), tcp.Value(iss))
if !rtx {
t.Fatal("RTO must fire at the deadline with data outstanding")
}
if hold {
t.Error("the estimator never holds new data back")
}
if from != tcp.Value(iss) {
t.Errorf("retransmit from %d, want snd.UNA=%d", from, iss)
}
if r.CurrentRTO() != 2*rtoInitial {
t.Errorf("rto=%v after one backoff, want %v", r.CurrentRTO(), 2*rtoInitial)
}
// The connection resends from snd.UNA; postTx sees a retransmission.
r.postTx(dataSeg(iss, 100), int64(rtoInitial))
if r.timing {
t.Error("retransmitted segment must not be RTT-sampled (Karn)")
}
}
// TestRTO_KarnNoSampleOnRetransmittedAck verifies that after a retransmission the
// ACK does not produce an RTT sample (Karn's algorithm).
func TestRTO_KarnNoSampleOnRetransmittedAck(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.postTx(dataSeg(iss, 100), 0)
// Timeout and retransmit.
r.preTx(int64(rtoInitial), tcp.Value(iss))
r.postTx(dataSeg(iss, 100), int64(rtoInitial))
// ACK now arrives; no sample should be taken since timing was discarded.
r.postRx(ackSeg(iss+100), int64(rtoInitial)+10*rtoMs)
if r.haveRTT {
t.Error("no RTT sample should exist after a retransmission (Karn)")
}
}
// TestRTO_TimerRestartsWhilePartiallyAcked verifies the timer restarts (not
// stops) when an ACK advances UNA but data remains in flight (RFC 6298 §5.3).
func TestRTO_TimerRestartsWhilePartiallyAcked(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.postTx(dataSeg(iss, 100), 0)
r.postTx(dataSeg(iss+100, 100), 0) // 200 octets outstanding, iss..iss+200.
r.postRx(ackSeg(iss+100), 40*rtoMs) // acks first 100 only.
if !r.Running() {
t.Fatal("timer must remain armed while data is still in flight")
}
if r.NextDeadline() != 40*rtoMs+int64(r.CurrentRTO()) {
t.Errorf("deadline=%d, want %d", r.NextDeadline(), 40*rtoMs+int64(r.CurrentRTO()))
}
}
// TestRTO_NoArmWithoutData verifies control-only segments neither arm the timer
// nor start an RTT sample.
func TestRTO_NoArmWithoutData(t *testing.T) {
r := newRTO()
r.postTx(tcp.Segment{SEQ: 1000, Flags: tcp.FlagACK}, 0) // pure ACK, DATALEN==0.
if r.Running() || r.timing {
t.Error("pure control segment must not arm the timer or start a sample")
}
}
// TestRTO_BackoffCollapsesOnValidSample verifies a valid RTT measurement
// collapses the exponential backoff counter (RFC 6298 §5.7).
func TestRTO_BackoffCollapsesOnValidSample(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.postTx(dataSeg(iss, 100), 0)
r.preTx(int64(rtoInitial), tcp.Value(iss)) // one timeout: backoff=1.
r.postTx(dataSeg(iss, 100), int64(rtoInitial)) // retransmit (no sample).
if r.backoff != 1 {
t.Fatalf("backoff=%d, want 1 after a timeout", r.backoff)
}
// New data sent and freshly sampled, then acked.
r.postTx(dataSeg(iss+100, 100), int64(rtoInitial)+rtoMs)
r.postRx(ackSeg(iss+200), int64(rtoInitial)+30*rtoMs)
if r.backoff != 0 {
t.Errorf("backoff=%d, want 0 after a valid RTT sample", r.backoff)
}
}
// TestRTO_Clamped verifies CurrentRTO is clamped to [rtoMin, rtoMax].
func TestRTO_Clamped(t *testing.T) {
r := newRTO()
r.rto = time.Nanosecond
if got := r.CurrentRTO(); got != rtoMin {
t.Errorf("CurrentRTO=%v, want floor %v", got, rtoMin)
}
r.rto = time.Hour
if got := r.CurrentRTO(); got != rtoMax {
t.Errorf("CurrentRTO=%v, want ceiling %v", got, rtoMax)
}
}
// TestRTO_UpdateRTTFirstSample verifies the first-measurement initialization of
// SRTT/RTTVAR (RFC 6298 §2.2).
func TestRTO_UpdateRTTFirstSample(t *testing.T) {
r := newRTO()
r.updateRTT(100 * time.Millisecond)
if r.srtt != 100*time.Millisecond {
t.Errorf("srtt=%v, want 100ms", r.srtt)
}
if r.rttvar != 50*time.Millisecond {
t.Errorf("rttvar=%v, want 50ms", r.rttvar)
}
// RTO = SRTT + K*RTTVAR = 100 + 4*50 = 300ms.
if r.rto != 300*time.Millisecond {
t.Errorf("rto=%v, want 300ms", r.rto)
}
}
// TestRTO_PolicyHooksDeriveFromFrame exercises Timer through the [tcp.Policy]
// hooks, verifying it reads the segment out of the frame it is handed: sending
// data arms a deadline and a full ACK disarms it and yields the RTT sample.
func TestRTO_PolicyHooksDeriveFromFrame(t *testing.T) {
var clock int64
var r Timer
if err := r.Configure(func() int64 { return clock }); err != nil {
t.Fatal(err)
}
var pol tcp.Policy = &r
pol.Reset()
pol.PostTx(nil, frameOf(t, dataSeg(1000, 100)))
if r.NextDeadline() == 0 {
t.Fatal("expected an armed deadline after sending data")
}
clock = 10 * rtoMs
if !pol.PreRx(nil, frameOf(t, ackSeg(1100))) {
t.Error("PreRx must keep")
}
pol.PostRx(nil, tcp.StateEstablished, frameOf(t, ackSeg(1100)))
if r.NextDeadline() != 0 {
t.Error("expected disarmed timer after full ack")
}
if r.SmoothedRTT() != 10*time.Millisecond {
t.Errorf("srtt=%v, want 10ms sampled through the hooks", r.SmoothedRTT())
}
}
// TestRTO_PreRxNeverDrops verifies the estimator keeps every segment and records
// nothing at PreRx time. Dropping is not its business, and the connection has not
// yet judged the segment: an acknowledgement for data never sent would otherwise
// collapse the backoff and take a bogus round-trip sample. Only accepted segments
// reach PostRx, which the Handler guarantees.
func TestRTO_PreRxNeverDrops(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.postTx(dataSeg(iss, 100), 0)
armed := r.NextDeadline()
if armed == 0 {
t.Fatal("timer must be armed after sending data")
}
// An acknowledgement far beyond anything sent, which the connection refuses.
if !r.PreRx(nil, frameOf(t, ackSeg(iss+100000))) {
t.Error("PreRx must keep: dropping is not the estimator's business")
}
if r.NextDeadline() != armed {
t.Errorf("deadline moved to %d at PreRx, want it left at %d", r.NextDeadline(), armed)
}
if r.SmoothedRTT() != 0 {
t.Errorf("took an RTT sample of %v at PreRx", r.SmoothedRTT())
}
if !r.Running() {
t.Error("timer disarmed at PreRx")
}
}
// TestRTO_RetransmitsZeroWindowProbe verifies the timer takes over the periodic
// probing of a closed send window. A zero-window probe is a single octet the peer
// cannot accept, so it goes unacknowledged; the timer must keep resending it, with
// exponential backoff, which is the persist-timer behaviour of RFC 9293 §3.8.6.1.
// The tcp package relies on this and refuses to probe without a policy installed.
func TestRTO_RetransmitsZeroWindowProbe(t *testing.T) {
r := newRTO()
const iss = uint32(5000)
probe := dataSeg(iss, 1) // The one-octet probe.
r.postTx(probe, 0)
now := int64(rtoInitial)
prevRTO := r.CurrentRTO()
for attempt := 1; attempt <= 4; attempt++ {
from, rtx, _ := r.preTx(now, tcp.Value(iss))
if !rtx {
t.Fatalf("attempt %d: timer did not fire; the probe would never be resent", attempt)
}
if from != tcp.Value(iss) {
t.Errorf("attempt %d: retransmit from %d, want the probe octet at %d", attempt, from, iss)
}
if got := r.CurrentRTO(); got <= prevRTO {
t.Errorf("attempt %d: rto %v did not back off past %v", attempt, got, prevRTO)
}
prevRTO = r.CurrentRTO()
// The peer still cannot accept the octet, so it stays unacknowledged.
r.postTx(probe, now)
now += int64(prevRTO)
}
}
+206
View File
@@ -0,0 +1,206 @@
package tcp
import (
"testing"
"time"
)
const rtoMs = int64(time.Millisecond)
// rtoDataSeg builds a data segment of datalen octets starting at seq.
func rtoDataSeg(seq uint32, datalen int) Segment {
return Segment{SEQ: Value(seq), DATALEN: Size(datalen), Flags: FlagPSH | FlagACK}
}
// rtoAckSeg builds a bare ACK acknowledging up to ack.
func rtoAckSeg(ack uint32) Segment {
return Segment{ACK: Value(ack), Flags: FlagACK}
}
func newRTO() *RTO {
var r RTO
r.Reset()
return &r
}
func TestRTO_Reset(t *testing.T) {
var r RTO
r.Reset()
if r.rto != rtoInitial {
t.Errorf("initial rto=%v, want %v", r.rto, rtoInitial)
}
if r.CurrentRTO() != rtoInitial {
t.Errorf("CurrentRTO=%v, want %v", r.CurrentRTO(), rtoInitial)
}
if r.haveRTT {
t.Error("haveRTT should be false before first sample")
}
if r.Running() || r.NextDeadline() != 0 {
t.Error("timer must be disarmed after Reset")
}
}
// TestRTO_ArmOnSendSampleOnAck sends data, verifies the timer arms, then acks it
// and verifies an RTT sample is taken and the timer stops once all data is acked.
func TestRTO_ArmOnSendSampleOnAck(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
if !r.Running() {
t.Fatal("timer must arm after sending data")
}
if r.NextDeadline() != int64(rtoInitial) {
t.Errorf("deadline=%d, want %d", r.NextDeadline(), int64(rtoInitial))
}
// ACK arrives one RTT (40ms) later covering all sent data.
dir := r.PreRx(rtoAckSeg(iss+100), 40*rtoMs)
if !dir.Keep {
t.Error("PreRx must keep the segment")
}
if r.Running() {
t.Error("timer must stop once all data is acknowledged")
}
if r.SmoothedRTT() != 40*time.Millisecond {
t.Errorf("srtt=%v, want 40ms", r.SmoothedRTT())
}
}
// TestRTO_RetransmitOnTimeout verifies PreTx directs a go-back-N retransmit once
// the deadline passes with data outstanding, and backs the RTO off.
func TestRTO_RetransmitOnTimeout(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
if r.PreTx(int64(rtoInitial) - 1).RetransmitAll {
t.Fatal("must not retransmit before the deadline")
}
dir := r.PreTx(int64(rtoInitial))
if !dir.RetransmitAll {
t.Fatal("RTO must fire at the deadline with data outstanding")
}
if r.CurrentRTO() != 2*rtoInitial {
t.Errorf("rto=%v after one backoff, want %v", r.CurrentRTO(), 2*rtoInitial)
}
// The connection resends from snd.UNA; PostTx sees a retransmission.
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial))
if r.timing {
t.Error("retransmitted segment must not be RTT-sampled (Karn)")
}
}
// TestRTO_KarnNoSampleOnRetransmittedAck verifies that after a retransmission the
// ACK does not produce an RTT sample (Karn's algorithm).
func TestRTO_KarnNoSampleOnRetransmittedAck(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
// Timeout and retransmit.
r.PreTx(int64(rtoInitial))
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial))
// ACK now arrives; no sample should be taken since timing was discarded.
r.PreRx(rtoAckSeg(iss+100), int64(rtoInitial)+10*rtoMs)
if r.haveRTT {
t.Error("no RTT sample should exist after a retransmission (Karn)")
}
}
// TestRTO_TimerRestartsWhilePartiallyAcked verifies the timer restarts (not
// stops) when an ACK advances UNA but data remains in flight (RFC 6298 §5.3).
func TestRTO_TimerRestartsWhilePartiallyAcked(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
r.PostTx(rtoDataSeg(iss+100, 100), 0) // 200 octets outstanding, iss..iss+200.
dir := r.PreRx(rtoAckSeg(iss+100), 40*rtoMs) // acks first 100 only.
if !r.Running() {
t.Fatal("timer must remain armed while data is still in flight")
}
if r.NextDeadline() != 40*rtoMs+int64(r.CurrentRTO()) {
t.Errorf("deadline=%d, want %d", r.NextDeadline(), 40*rtoMs+int64(r.CurrentRTO()))
}
if !dir.Keep {
t.Error("PreRx must keep the segment")
}
}
// TestRTO_NoArmWithoutData verifies control-only segments neither arm the timer
// nor start an RTT sample.
func TestRTO_NoArmWithoutData(t *testing.T) {
r := newRTO()
r.PostTx(Segment{SEQ: 1000, Flags: FlagACK}, 0) // pure ACK, DATALEN==0.
if r.Running() || r.timing {
t.Error("pure control segment must not arm the timer or start a sample")
}
}
// TestRTO_BackoffCollapsesOnValidSample verifies a valid RTT measurement
// collapses the exponential backoff counter (RFC 6298 §5.7).
func TestRTO_BackoffCollapsesOnValidSample(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
r.PreTx(int64(rtoInitial)) // one timeout: backoff=1.
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial)) // retransmit (no sample).
if r.backoff != 1 {
t.Fatalf("backoff=%d, want 1 after a timeout", r.backoff)
}
// New data sent and freshly sampled, then acked.
r.PostTx(rtoDataSeg(iss+100, 100), int64(rtoInitial)+rtoMs)
r.PreRx(rtoAckSeg(iss+200), int64(rtoInitial)+30*rtoMs)
if r.backoff != 0 {
t.Errorf("backoff=%d, want 0 after a valid RTT sample", r.backoff)
}
}
// TestRTO_Clamped verifies CurrentRTO is clamped to [rtoMin, rtoMax].
func TestRTO_Clamped(t *testing.T) {
var r RTO
r.Reset()
r.rto = time.Nanosecond
if got := r.CurrentRTO(); got != rtoMin {
t.Errorf("CurrentRTO=%v, want floor %v", got, rtoMin)
}
r.rto = time.Hour
if got := r.CurrentRTO(); got != rtoMax {
t.Errorf("CurrentRTO=%v, want ceiling %v", got, rtoMax)
}
}
// TestRTO_UpdateRTTFirstSample verifies the first-measurement initialization of
// SRTT/RTTVAR (RFC 6298 §2.2).
func TestRTO_UpdateRTTFirstSample(t *testing.T) {
var r RTO
r.Reset()
r.updateRTT(100 * time.Millisecond)
if r.srtt != 100*time.Millisecond {
t.Errorf("srtt=%v, want 100ms", r.srtt)
}
if r.rttvar != 50*time.Millisecond {
t.Errorf("rttvar=%v, want 50ms", r.rttvar)
}
// RTO = SRTT + K*RTTVAR = 100 + 4*50 = 300ms.
if r.rto != 300*time.Millisecond {
t.Errorf("rto=%v, want 300ms", r.rto)
}
}
// TestRTO_ImplementsLossRecovery exercises RTO through the [LossRecovery]
// interface: sending data arms a deadline and a full ACK disarms it.
func TestRTO_ImplementsLossRecovery(t *testing.T) {
var lr LossRecovery = newRTO()
lr.Reset()
lr.PostTx(rtoDataSeg(1000, 100), 0)
if lr.NextDeadline() == 0 {
t.Error("expected an armed deadline after sending data")
}
if !lr.PreRx(rtoAckSeg(1100), 10*rtoMs).Keep {
t.Error("PreRx must keep")
}
if lr.NextDeadline() != 0 {
t.Error("expected disarmed timer after full ack")
}
}
+120
View File
@@ -0,0 +1,120 @@
package tcp
import (
"math/rand"
"testing"
"time"
"github.com/soypat/lneto/ethernet"
)
// TestHandlerRetransmitsAfterRTO covers the seam between a Handler and its
// LossRecovery, which the RTO unit tests do not: a lost data segment must be
// resent once the timer expires, with nothing arriving to prompt it.
func TestHandlerRetransmitsAfterRTO(t *testing.T) {
const mtu = ethernet.MaxMTU
const maxpackets = 4
rng := rand.New(rand.NewSource(5))
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
var now int64 // injected monotonic clock, in nanoseconds
client.SetLossRecovery(new(RTO), func() int64 { return now })
setupClientServer(t, rng, client, server)
var rawbuf [mtu]byte
establish(t, client, server, rawbuf[:])
data := []byte("hello")
if n, err := client.Write(data); err != nil || n != len(data) {
t.Fatal("client write:", n, err)
}
clear(rawbuf[:])
n, err := client.Send(rawbuf[:])
if err != nil || n == 0 {
t.Fatal("client send:", n, err)
}
// That frame is lost: it is never handed to the server.
// Nothing may come back before the timer expires.
var probe [mtu]byte
if n, err := client.Send(probe[:]); err != nil || n != 0 {
t.Fatalf("client sent %d bytes before the RTO expired (err %v)", n, err)
}
now += int64(3 * time.Second) // past the initial RTO and one backoff
clear(probe[:])
n, err = client.Send(probe[:])
if err != nil {
t.Fatal("client send after RTO:", err)
}
if n == 0 {
t.Fatal("no retransmission after the RTO expired: the loss-recovery directive is never applied")
}
if err := server.Recv(probe[:n]); err != nil {
t.Fatal("server refused the retransmission:", err)
}
got := make([]byte, 16)
nr, err := server.Read(got)
if err != nil || string(got[:nr]) != string(data) {
t.Fatalf("server read %q (%v), want %q", got[:nr], err, data)
}
}
// TestHandlerRetransmitsAfterCloseWithUnackedData is the write-then-close case
// every server performs. With the last data segment lost, the FIN behind it sits
// above a gap the peer cannot cross, so FIN-WAIT-1 must still retransmit that
// data or both sides wait forever.
func TestHandlerRetransmitsAfterCloseWithUnackedData(t *testing.T) {
const mtu = ethernet.MaxMTU
const maxpackets = 4
rng := rand.New(rand.NewSource(9))
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
var now int64
client.SetLossRecovery(new(RTO), func() int64 { return now })
setupClientServer(t, rng, client, server)
var rawbuf [mtu]byte
establish(t, client, server, rawbuf[:])
data := []byte("last response bytes")
if n, err := client.Write(data); err != nil || n != len(data) {
t.Fatal("client write:", n, err)
}
clear(rawbuf[:])
n, err := client.Send(rawbuf[:]) // this frame is lost in transit
if err != nil || n == 0 {
t.Fatal("client send:", n, err)
}
// The application closes right after writing.
if err := client.Close(); err != nil {
t.Fatal("client close:", err)
}
var finbuf [mtu]byte
nfin, err := client.Send(finbuf[:]) // FIN (also lost, or simply unacked)
if err != nil {
t.Fatal("client send FIN:", err)
}
t.Logf("state after close: %s (FIN frame %d bytes)", client.State(), nfin)
now += int64(3 * time.Second) // past the RTO
var probe [mtu]byte
n, err = client.Send(probe[:])
if err != nil {
t.Fatal("client send after RTO:", err)
}
if n == 0 {
t.Fatalf("no retransmission in %s: unacknowledged data is stranded by the close", client.State())
}
if err := server.Recv(probe[:n]); err != nil {
t.Fatal("server refused the retransmission:", err)
}
got := make([]byte, 32)
nr, err := server.Read(got)
if err != nil || string(got[:nr]) != string(data) {
t.Fatalf("server read %q (%v), want %q", got[:nr], err, data)
}
}
+10 -61
View File
@@ -227,39 +227,18 @@ func (rtx *ringTx) RetransmitFromUNA() {
if oldest == nil {
return // Nothing in the retransmission queue.
}
rtx.RetransmitFrom(oldest.seq)
}
// RetransmitFrom rewinds the transmit queue so sent-but-unacked data at and
// after seq becomes unsent again; the next MakePacket calls re-send it. seq is
// snapped down to the start of the packet containing it — the retransmission
// queue tracks whole packets, so sub-packet rewind is not representable. It is
// a no-op when seq is not covered by any queued packet (nothing to resend).
//
// Callers must pair this with [ControlBlock.RetransmitFrom] using the same seq
// so the send sequence space and the transmit buffer rewind together.
func (rtx *ringTx) RetransmitFrom(seq Value) {
pkt := rtx.slist.packetContaining(seq)
if pkt == nil {
return // seq not in the retransmission queue.
}
rewindOff, rewindSeq := pkt.off, pkt.seq
// The write position is unsentend, except when the unsent region is empty
// (unsentend==0) in which case data ends where the sent region ends. Capture
// it before reopening the unsent region over the rewound packets.
writeEnd := rtx.unsentend
if writeEnd == 0 {
writeEnd = rtx.sentend
}
if rewindOff == rtx.sentoff {
rtx.sentoff = 0 // Whole queue rewound: sent region becomes empty.
unaSeq := oldest.seq
if rtx.sentend != 0 {
// Merge sent region [sentoff, sentend) back into unsent.
rtx.unsentoff = rtx.sentoff
if rtx.unsentend == 0 {
rtx.unsentend = rtx.sentend
}
rtx.sentoff = 0
rtx.sentend = 0
} else {
rtx.sentend = rewindOff
}
rtx.unsentoff = rewindOff
rtx.unsentend = writeEnd
rtx.slist.truncateFrom(rewindSeq)
// Clear packet metadata; sequence tracking restarts from UNA.
rtx.slist.Reset(cap(rtx.slist.pkts), unaSeq)
}
func (rtx *ringTx) consolidateBufs() {
@@ -352,36 +331,6 @@ func (sl *sentlist) Free() int {
return cap(sl.pkts) - len(sl.pkts)
}
// packetContaining returns the queued packet whose sequence range covers seq, or
// nil when no packet does. It is the floor lookup a retransmission rewind needs:
// seq lands inside a packet and the whole packet is resent.
func (sl *sentlist) packetContaining(seq Value) *ringidx {
for i := range sl.pkts {
pkt := &sl.pkts[i]
if pkt.seq.LessThanEq(seq) && seq.LessThan(pkt.endSeq()) {
return pkt
}
}
return nil
}
// truncateFrom drops the packet starting at seq and every packet sent after it,
// so their data can be re-queued as unsent. seq must be a packet start sequence
// (see [sentlist.packetContaining]). When no packet survives, the auxiliary
// sequence counter is rewound to seq so [sentlist.EndSeq] keeps reporting where
// the next packet begins.
func (sl *sentlist) truncateFrom(seq Value) {
for i := range sl.pkts {
if sl.pkts[i].seq == seq {
sl.pkts = sl.pkts[:i]
if i == 0 {
sl.ssn = seq
}
return
}
}
}
func (sl *sentlist) AddPacket(datalen, off, bufsize int, seq Value) *ringidx {
free := sl.Free()
if free == 0 {
-215
View File
@@ -1,215 +0,0 @@
package tcp
import (
"bytes"
"testing"
)
// newRetransmitQueue builds a queue holding npkt sent packets of pktlen octets
// each, starting at iss, plus any leftover unsent data. It returns the queue and
// the full byte stream that was written.
func newRetransmitQueue(t *testing.T, bufsize, maxPkts, npkt, pktlen, unsent int, iss Value) (*ringTx, []byte) {
t.Helper()
var rtx ringTx
if err := rtx.Reset(make([]byte, bufsize), maxPkts, iss); err != nil {
t.Fatal(err)
}
stream := make([]byte, npkt*pktlen+unsent)
for i := range stream {
stream[i] = byte(i + 1) // Non-zero so a stale ring shows up as a mismatch.
}
if n, err := rtx.Write(stream); err != nil || n != len(stream) {
t.Fatalf("write n=%d err=%v", n, err)
}
seq := iss
scratch := make([]byte, pktlen)
for i := range npkt {
n, err := rtx.MakePacket(scratch, seq)
if err != nil {
t.Fatalf("packet %d: %v", i, err)
}
if n != pktlen {
t.Fatalf("packet %d: n=%d, want %d", i, n, pktlen)
}
seq += Value(n)
}
testQueueSanity(t, &rtx)
return &rtx, stream
}
// mustRemake asserts the queue re-emits datalen octets at seq matching want.
func mustRemake(t *testing.T, rtx *ringTx, seq Value, want []byte) {
t.Helper()
got := make([]byte, len(want))
n, err := rtx.MakePacket(got, seq)
if err != nil {
t.Fatalf("MakePacket at seq %d: %v", seq, err)
}
if n != len(want) {
t.Fatalf("MakePacket at seq %d: n=%d, want %d", seq, n, len(want))
}
if !bytes.Equal(got, want) {
t.Fatalf("MakePacket at seq %d: got %v, want %v", seq, got, want)
}
}
// TestRingTx_RetransmitFromBoundary rewinds to the start of the second of three
// sent packets: the first stays sent, the rest become unsent and re-emit their
// original bytes.
func TestRingTx_RetransmitFromBoundary(t *testing.T) {
const iss, pktlen = Value(100), 4
rtx, stream := newRetransmitQueue(t, 64, 4, 3, pktlen, 0, iss)
sentBefore := rtx.BufferedSent()
rtx.RetransmitFrom(iss + pktlen) // Start of packet 2.
testQueueSanity(t, rtx)
if got := rtx.BufferedSent(); got != pktlen {
t.Fatalf("sent=%d, want %d (only packet 1 remains sent)", got, pktlen)
}
if got := rtx.BufferedUnsent(); got != sentBefore-pktlen {
t.Fatalf("unsent=%d, want %d", got, sentBefore-pktlen)
}
mustRemake(t, rtx, iss+pktlen, stream[pktlen:2*pktlen])
testQueueSanity(t, rtx)
mustRemake(t, rtx, iss+2*pktlen, stream[2*pktlen:3*pktlen])
testQueueSanity(t, rtx)
}
// TestRingTx_RetransmitFromMidPacket verifies a sequence inside a packet is
// snapped down to that packet's start: the queue tracks whole packets.
func TestRingTx_RetransmitFromMidPacket(t *testing.T) {
const iss, pktlen = Value(100), 4
rtx, stream := newRetransmitQueue(t, 64, 4, 3, pktlen, 0, iss)
rtx.RetransmitFrom(iss + pktlen + 2) // Two octets into packet 2.
testQueueSanity(t, rtx)
if got := rtx.BufferedSent(); got != pktlen {
t.Fatalf("sent=%d, want %d: rewind must floor to the packet start", got, pktlen)
}
mustRemake(t, rtx, iss+pktlen, stream[pktlen:2*pktlen])
}
// TestRingTx_RetransmitFromOldest rewinds the whole queue, which must match
// RetransmitFromUNA.
func TestRingTx_RetransmitFromOldest(t *testing.T) {
const iss, pktlen, npkt = Value(100), 4, 3
rtx, stream := newRetransmitQueue(t, 64, 4, npkt, pktlen, 0, iss)
rtx.RetransmitFrom(iss)
testQueueSanity(t, rtx)
viaUNA, _ := newRetransmitQueue(t, 64, 4, npkt, pktlen, 0, iss)
viaUNA.RetransmitFromUNA()
testQueueSanity(t, viaUNA)
if rtx.BufferedSent() != 0 {
t.Fatalf("sent=%d, want 0 after a full rewind", rtx.BufferedSent())
}
if rtx.BufferedUnsent() != npkt*pktlen {
t.Fatalf("unsent=%d, want %d", rtx.BufferedUnsent(), npkt*pktlen)
}
if rtx.BufferedSent() != viaUNA.BufferedSent() || rtx.BufferedUnsent() != viaUNA.BufferedUnsent() {
t.Fatal("RetransmitFrom(oldest) must match RetransmitFromUNA")
}
mustRemake(t, rtx, iss, stream[:pktlen])
}
// TestRingTx_RetransmitFromUnknownSeq verifies a sequence covered by no queued
// packet leaves the queue untouched.
func TestRingTx_RetransmitFromUnknownSeq(t *testing.T) {
const iss, pktlen, npkt = Value(100), 4, 3
rtx, _ := newRetransmitQueue(t, 64, 4, npkt, pktlen, 0, iss)
sent, unsent := rtx.BufferedSent(), rtx.BufferedUnsent()
rtx.RetransmitFrom(iss - 1) // Before the queue.
rtx.RetransmitFrom(iss + npkt*pktlen) // One past the last octet sent.
rtx.RetransmitFrom(iss + 1000) // Far beyond.
testQueueSanity(t, rtx)
if rtx.BufferedSent() != sent || rtx.BufferedUnsent() != unsent {
t.Fatalf("queue moved: sent %d→%d, unsent %d→%d", sent, rtx.BufferedSent(), unsent, rtx.BufferedUnsent())
}
}
// TestRingTx_RetransmitWithUnsentTail verifies a rewind reopens the unsent region
// over the rewound packets without losing the unsent tail behind them.
func TestRingTx_RetransmitWithUnsentTail(t *testing.T) {
const iss, pktlen, npkt, tail = Value(100), 4, 2, 5
rtx, stream := newRetransmitQueue(t, 64, 4, npkt, pktlen, tail, iss)
if got := rtx.BufferedUnsent(); got != tail {
t.Fatalf("unsent tail=%d, want %d", got, tail)
}
rtx.RetransmitFrom(iss + pktlen) // Rewind the second packet only.
testQueueSanity(t, rtx)
if got := rtx.BufferedUnsent(); got != pktlen+tail {
t.Fatalf("unsent=%d, want %d (rewound packet plus the tail)", got, pktlen+tail)
}
// The rewound packet re-emits first, then the tail follows in order.
mustRemake(t, rtx, iss+pktlen, stream[pktlen:2*pktlen])
testQueueSanity(t, rtx)
mustRemake(t, rtx, iss+2*pktlen, stream[2*pktlen:])
}
// TestRingTx_RetransmitAfterDrainedUnsent pins the write-position recovery: when
// every octet written has been packetized the unsent region is empty, so the
// rewind must reconstruct where data ends from the sent region.
func TestRingTx_RetransmitAfterDrainedUnsent(t *testing.T) {
const iss, pktlen, npkt = Value(100), 4, 3
rtx, stream := newRetransmitQueue(t, 64, 4, npkt, pktlen, 0, iss)
if got := rtx.BufferedUnsent(); got != 0 {
t.Fatalf("unsent=%d, want 0: all written data was packetized", got)
}
rtx.RetransmitFrom(iss + pktlen)
testQueueSanity(t, rtx)
if got := rtx.BufferedUnsent(); got != 2*pktlen {
t.Fatalf("unsent=%d, want %d: rewind lost the end of the data", got, 2*pktlen)
}
mustRemake(t, rtx, iss+pktlen, stream[pktlen:2*pktlen])
testQueueSanity(t, rtx)
mustRemake(t, rtx, iss+2*pktlen, stream[2*pktlen:3*pktlen])
}
// TestRingTx_RetransmitWrapped exercises a rewind on a queue whose regions wrap
// the end of the ring buffer.
func TestRingTx_RetransmitWrapped(t *testing.T) {
const bufsize, pktlen = 16, 4
const iss = Value(100)
var rtx ringTx
if err := rtx.Reset(make([]byte, bufsize), 4, iss); err != nil {
t.Fatal(err)
}
// Push the queue most of the way around the ring, acking as we go.
seq := iss
scratch := make([]byte, pktlen)
for round := range 3 {
chunk := make([]byte, pktlen)
for i := range chunk {
chunk[i] = byte(round*pktlen + i + 1)
}
if _, err := rtx.Write(chunk); err != nil {
t.Fatal(err)
}
if _, err := rtx.MakePacket(scratch, seq); err != nil {
t.Fatal(err)
}
seq += Value(pktlen)
if round < 2 {
if err := rtx.RecvACK(seq); err != nil {
t.Fatal(err)
}
}
testQueueSanity(t, &rtx)
}
// Two packets outstanding, straddling the wrap. Rewind the newest.
rewindSeq := seq - Value(pktlen)
want := append([]byte(nil), scratch...)
rtx.RetransmitFrom(rewindSeq)
testQueueSanity(t, &rtx)
mustRemake(t, &rtx, rewindSeq, want)
testQueueSanity(t, &rtx)
}
+21
View File
@@ -53,6 +53,10 @@ type StackAsync struct {
lookup dns.Message
dnssv netip.Addr
// ephPort drives sequential ephemeral-port allocation (see
// [StackAsync.ephemeralPort]); zero means not yet seeded.
ephPort uint32
ntpUDP internet.StackUDPPort
ntp ntp.Client
@@ -352,6 +356,23 @@ func (s *StackAsync) Prand32() (randval uint32) {
return randval
}
// ephemeralPort returns the next port of the IANA dynamic range (49152-65535,
// RFC 6335 §6), allocated sequentially from a random per-stack start so a port is
// revisited only after the full 16384-port cycle. Random selection instead reuses
// a recent port at birthday-paradox rates, and a reused 4-tuple can collide with
// state the previous conversation left behind (a TIME-WAIT, a NAT flow entry)
// which swallows the new SYN.
func (s *StackAsync) ephemeralPort() uint16 {
s.mu.Lock()
if s.ephPort == 0 {
s.ephPort = s.prand32()%16384 | 1
}
port := 49152 + s.ephPort%16384
s.ephPort++
s.mu.Unlock()
return uint16(port)
}
func (s *StackAsync) prand32() uint32 {
/* Algorithm "xor" from p. 4 of Marsaglia, "Xorshift RNGs" */
seed := internal.Prand32(s.prng)
+3 -2
View File
@@ -102,8 +102,9 @@ func (s StackGo) SocketNetip(ctx context.Context, network string, family, sotype
isDial := raddr.IsValid() && !raddr.Addr().IsUnspecified()
if laddr.Port() == 0 {
// Auto-assign an ephemeral port for both outbound dials and for listeners
// that did not request a fixed port.
laddr = netip.AddrPortFrom(laddr.Addr(), uint16(49152+s.blk.async.Prand32()%16384))
// that did not request a fixed port. Sequential, not random: see
// [StackAsync.ephemeralPort] for why random selection breaks dial churn.
laddr = netip.AddrPortFrom(laddr.Addr(), s.blk.async.ephemeralPort())
}
if laddr.Addr().IsUnspecified() {
// Fill in the stack's configured address for the requested family.
+36
View File
@@ -1196,3 +1196,39 @@ func TestEgressIP_TCPMSSAdvertisesMTU(t *testing.T) {
t.Errorf("advertised MSS = %d, want %d (MTU %d - 40)", gotMSS, wantMSS, mtu)
}
}
// TestEphemeralPortSequence checks no ephemeral port is reused before the whole
// 16384-port dynamic range has cycled, which is what keeps a redial off the
// teardown state (TIME-WAIT, NAT flow entries) of the conversation before it.
func TestEphemeralPortSequence(t *testing.T) {
s := new(StackAsync)
err := s.Reset(StackConfig{
Hostname: "eph",
RandSeed: 42,
StaticAddress4: [4]byte{10, 0, 0, 50},
MaxActiveTCPPorts: 1,
HardwareAddress: [6]byte{0xbe, 0xef, 0, 0, 0, 50},
MTU: ethernet.MaxMTU,
ICMPQueueLimit: 2,
})
if err != nil {
t.Fatal(err)
}
const cycle = 16384
var seen [cycle]bool
for i := range cycle {
port := s.ephemeralPort()
if port < 49152 {
t.Fatalf("port %d below dynamic range (RFC 6335)", port)
}
idx := port - 49152
if seen[idx] {
t.Fatalf("port %d reused after only %d allocations (want full %d cycle)", port, i, cycle)
}
seen[idx] = true
}
// The cycle is exhausted: the next allocation may legitimately reuse.
if got := s.ephemeralPort(); got < 49152 {
t.Fatalf("post-cycle port %d below dynamic range", got)
}
}