1 Commits

Author SHA1 Message Date
Pat Whittingslow 80ea9256e7 fix(xnet): make blocking waits deadline-driven instead of iteration-capped (#178 rebased) (#198)
* fix(xnet): make blocking waits deadline-driven instead of iteration-capped

All six StackBlocking wait loops run 'for range maxIter' (1000). With a
non-sleeping backoff like BackoffFlagGosched, the sensible choice on
GOMAXPROCS=1 targets where sleeping starves the NIC poll, those 1000
iterations complete in ~20ms of wall time and silently replace the
caller's timeout: a dial with the default 2s timeout fails after ~21ms
with a spurious deadline error against any peer slower than that.
Observed dialing github.com from a single-core bare-metal (tamago)
target through go-net.

The deadline check inside every loop is the real guard, so it becomes the
loop condition itself and keeps the bounded lifetime visible on the for
statement:

  for ok := true; ok; ok = s.checkDeadline(deadline) == nil {

DoDHCPv4 loses its separate deadline branch as a result. It used to check
only on iterations without a state change, which without the iteration
cap would let a peer that keeps feeding state transitions hold the loop
past any deadline; from the header the check runs every iteration.

The regression test drives a dial through 4000 no-progress wait
iterations on simulated time (<5% of the deadline elapsed) and requires
establishment once the handshake is finally serviced; on current main it
fails at exactly iteration 1000.

* go fix

---------

Co-authored-by: Derek den Haas <d.haas@directcode.com>
2026-09-07 14:02:02 -07:00
6 changed files with 142 additions and 394 deletions
+22 -118
View File
@@ -16,6 +16,9 @@ import (
func NewSched(t testing.TB) *Sched {
return &Sched{
t: t,
goroYieldSignal: make(chan struct{}),
goroContinueSignal: make(chan struct{}),
finishChan: make(chan error, 1),
timeout: time.Second,
}
}
@@ -23,49 +26,22 @@ func NewSched(t testing.TB) *Sched {
// Sched is the shared state behind a [SchedGoro]/[SchedDriver] pair. It exposes no
// handoff methods directly; obtain a handle with [Sched.Goro] (for the
// scheduled goroutine) or [Sched.Driver] (for the test thread).
//
// A Sched may schedule more than one goroutine: call [Sched.Goro] once per
// goroutine and drive them as a barrier with [Sched.AwaitAllParked] and
// [Sched.YieldToAllParked]. The single-goroutine methods ([Sched.AwaitGoroYield],
// [Sched.AwaitGoroYieldOrDone], [Sched.YieldToGoro] and [Sched.Done]) address the
// first handle handed out and are the right tool when there is only one.
type Sched struct {
t testing.TB
goros []*schedGoro
finishcalled atomic.Bool
timeout time.Duration
}
// schedGoro is the per-goroutine handoff state. The channels are shared with the
// scheduled goroutine; parked, finished and err are driver-side bookkeeping and
// must only ever be touched from the test thread.
type schedGoro struct {
// when stack backs off it signals here and waits until channel read or timeout.
yieldSignal chan struct{}
goroYieldSignal chan struct{}
// when main goroutine is ready for more information this channel is written to to signal waiting on stack activity.
continueSignal chan struct{}
goroContinueSignal chan struct{}
finishChan chan error
parked bool // goroutine is suspended inside Yield, awaiting a continue.
finished bool // goroutine terminated via FinishWithErr.
err error
}
// goro0 returns the first handed-out goroutine state, which the single-goroutine
// driver methods address.
func (ss *Sched) goro0() *schedGoro {
if len(ss.goros) == 0 {
panic("Sched.Goro must be called before driving the scheduler")
}
return ss.goros[0]
finishcalled atomic.Bool
coroCalls atomic.Int32
timeout time.Duration
}
// AwaitGoroYield blocks until the coroutine suspends itself via [SchedGoro.Yield].
func (ss *Sched) AwaitGoroYield() {
g := ss.goro0()
select {
case <-g.yieldSignal:
g.parked = true
case <-ss.goroYieldSignal:
case <-time.After(ss.timeout):
ss.t.Fatal("timeout waiting for stack to backoff")
}
@@ -78,13 +54,10 @@ func (ss *Sched) AwaitGoroYield() {
// the same select, avoiding the deadlock of guessing whether the goroutine will yield
// again. Do not mix with [Sched.Done] on the same scheduler.
func (ss *Sched) AwaitGoroYieldOrDone() (done bool, err error) {
g := ss.goro0()
select {
case <-g.yieldSignal:
g.parked = true
case <-ss.goroYieldSignal:
return false, nil
case err = <-g.finishChan:
g.finished, g.err = true, err
case err = <-ss.finishChan:
return true, err
case <-time.After(ss.timeout):
ss.t.Fatal("timeout waiting for stack to yield or finish")
@@ -94,103 +67,34 @@ func (ss *Sched) AwaitGoroYieldOrDone() (done bool, err error) {
// YieldToGoro wakes a coroutine parked in [SchedGoro.Yield], letting the goroutine run on.
func (ss *Sched) YieldToGoro() {
g := ss.goro0()
select {
case g.continueSignal <- struct{}{}:
g.parked = false
case ss.goroContinueSignal <- struct{}{}:
case <-time.After(ss.timeout):
ss.t.Fatal("timeout while trying to yield to stack")
}
}
// AwaitAllParked blocks until every scheduled goroutine has either suspended
// itself in [SchedGoro.Yield] or terminated via [SchedGoro.FinishWithErr]. Once it
// returns, no scheduled goroutine is runnable, so the driver may touch state they
// share — pumping frames between stacks, advancing a simulated clock — without
// racing them. Pair it with [Sched.YieldToAllParked] to step the whole set.
//
// allFinished reports that every goroutine has terminated, which is the loop's
// exit condition; err is the first non-nil terminal error handed over so far.
func (ss *Sched) AwaitAllParked() (allFinished bool, err error) {
ss.goro0() // Panics if the scheduler has no goroutines to drive.
for _, g := range ss.goros {
if g.parked || g.finished {
continue // Already accounted for; waiting again would deadlock.
}
select {
case <-g.yieldSignal:
g.parked = true
case gerr := <-g.finishChan:
g.finished, g.err = true, gerr
case <-time.After(ss.timeout):
ss.t.Fatal("timeout waiting for scheduled goroutines to park or finish")
return true, nil
}
}
allFinished = true
for _, g := range ss.goros {
if !g.finished {
allFinished = false
}
if err == nil {
err = g.err
}
}
return allFinished, err
}
// YieldToAllParked wakes every goroutine currently parked in [SchedGoro.Yield],
// letting them all run on until they park again. Goroutines that have already
// terminated are skipped, so it is safe to call until [Sched.AwaitAllParked]
// reports every goroutine finished.
func (ss *Sched) YieldToAllParked() {
ss.goro0() // Panics if the scheduler has no goroutines to drive.
for _, g := range ss.goros {
if !g.parked {
continue
}
select {
case g.continueSignal <- struct{}{}:
g.parked = false
case <-time.After(ss.timeout):
ss.t.Fatal("timeout while trying to yield to scheduled goroutine")
}
}
}
// Done returns the channel that receives the coroutine's terminal error from
// [SchedGoro.FinishWithErr]. It may only be called once.
func (ss *Sched) Done() <-chan error {
g := ss.goro0()
if ss.finishcalled.CompareAndSwap(false, true) {
return g.finishChan
return ss.finishChan
}
panic("Done called twice")
}
// Goro returns the handle whose methods must be called from inside the
// scheduled (stack) goroutine. Call it once per goroutine to be scheduled, from
// the test thread and before those goroutines start: the handles are handed out
// unsynchronized. The first handle is the one the single-goroutine driver methods
// address; drive two or more with [Sched.AwaitAllParked] and [Sched.YieldToAllParked].
// scheduled (stack) goroutine.
func (ss *Sched) Goro() SchedGoro {
g := &schedGoro{
yieldSignal: make(chan struct{}),
continueSignal: make(chan struct{}),
finishChan: make(chan error, 1),
if !ss.coroCalls.CompareAndSwap(0, 1) {
panic("only one goroutine supported for now")
}
ss.goros = append(ss.goros, g)
return SchedGoro{ss: ss, g: g}
return SchedGoro{ss: ss}
}
// SchedGoro is the coroutine-side handle of a [Sched]. Every method MUST be
// called from inside the scheduled goroutine and never from the test thread.
// It holds its own handoff state directly so the goroutine never reads the
// scheduler's handle list, which the test thread may still be appending to.
type SchedGoro struct {
ss *Sched
g *schedGoro
}
type SchedGoro struct{ ss *Sched }
// Yield suspends the goroutine at a backoff point and parks until the driver
// calls [SchedDriver.YieldToGoro]. Its signature satisfies [lneto.BackoffStrategy] so it
@@ -199,12 +103,12 @@ func (c SchedGoro) Yield(consecutiveBackoffs uint) time.Duration {
ss := c.ss
timeout := time.After(ss.timeout)
select {
case c.g.yieldSignal <- struct{}{}:
case ss.goroYieldSignal <- struct{}{}:
case <-timeout:
ss.t.Fatal("timeout backing off, possible race condition? Multiple stacks using same backoff is unexpected pattern")
}
select {
case <-c.g.continueSignal:
case <-ss.goroContinueSignal:
case <-timeout:
ss.t.Fatal("timeout waiting for continue")
}
@@ -215,10 +119,10 @@ func (c SchedGoro) Yield(consecutiveBackoffs uint) time.Duration {
// channel. It must be called at most once.
func (c SchedGoro) FinishWithErr(err error) {
ss := c.ss
if len(c.g.finishChan) != 0 {
if len(ss.finishChan) != 0 {
ss.t.Fatal("Coro.FinishWithErr can be called once only")
}
c.g.finishChan <- err
ss.finishChan <- err
}
// Finish is just shorthand for c.FinishWithErr(nil).
-212
View File
@@ -1,212 +0,0 @@
package xnet
import (
"context"
"fmt"
"net"
"net/netip"
"syscall"
"testing"
"time"
"github.com/soypat/lneto"
"github.com/soypat/lneto/ethernet"
"github.com/soypat/lneto/internal/ltesto"
"github.com/soypat/lneto/tcp"
"github.com/soypat/lneto/tcp/rto"
)
// TestTCPRetransmitsLostSegment drops exactly one data segment and requires the
// bytes to arrive anyway. It covers [TCPPoolConfig.NewPolicy] reaching the pooled
// and dialed connections alike: with no [tcp.Policy] installed nothing notices the
// loss, no retransmission is ever sent and the read below never completes.
//
// The server and the client each get an [ltesto.Sched] goroutine and the test
// thread drives them as a barrier: it only moves frames or advances the clock
// once both are parked, so the stacks are never touched concurrently. Time is
// simulated, so waiting out the one-second initial RTO (RFC 6298 §2.1) costs
// nothing and the outcome does not depend on how fast the machine is.
func TestTCPRetransmitsLostSegment(t *testing.T) {
const (
MTU = ethernet.MaxMTU
svPort = 80
bufSize = 2 << 10
want = "this segment is lost in transit"
// A quiet round means both sides are waiting on the network, which is
// what a lost segment looks like: only then does the clock move, so the
// RTO expires in a bounded number of rounds instead of in real time.
quietStep = 100 * time.Millisecond
maxRounds = 600
// Headers total 54 bytes, so a larger frame carries payload. Dropping a
// bare ACK would exercise the other direction's recovery instead.
minDataFrame = 14 + 20 + 20 + 8
)
client, sv := new(StackAsync), new(StackAsync)
if err := client.Reset(StackConfig{
Hostname: "rtx-client",
RandSeed: 11,
StaticAddress4: [4]byte{10, 0, 0, 90},
MaxActiveTCPPorts: 2,
HardwareAddress: [6]byte{0xbe, 0xef, 0, 0, 0, 90},
MTU: MTU,
ICMPQueueLimit: 2,
}); err != nil {
t.Fatal(err)
}
if err := sv.Reset(StackConfig{
Hostname: "rtx-server",
RandSeed: ^int64(11),
StaticAddress4: [4]byte{10, 0, 0, 91},
MaxActiveTCPPorts: 2,
HardwareAddress: [6]byte{0xbe, 0xef, 0, 0, 0, 91},
MTU: MTU,
ICMPQueueLimit: 2,
}); err != nil {
t.Fatal(err)
}
client.SetGatewayHardwareAddr(sv.HardwareAddr())
sv.SetGatewayHardwareAddr(client.HardwareAddr())
tsched := ltesto.NewSched(t)
svGoro, clGoro := tsched.Goro(), tsched.Goro()
// Simulated monotonic clock. Only the driver writes it, and only while every
// scheduled goroutine is parked, so it needs no synchronization of its own.
var now int64
nanotime := func() int64 { return now }
// Each side backs off into its own scheduler handle, so the driver can park
// and resume the two independently.
newPool := func(yield lneto.BackoffStrategy) TCPPoolConfig {
return TCPPoolConfig{
PoolSize: 2, QueueSize: 4,
TxBufSize: bufSize, RxBufSize: bufSize,
// Well past the simulated time this test spends, so the pool never
// reaps a connection out from under the retransmission.
EstablishedTimeout: 120 * time.Second,
ClosingTimeout: 120 * time.Second,
NanoTime: nanotime,
NewBackoff: func() lneto.BackoffStrategy { return yield },
NewPolicy: func() tcp.Policy {
timer := new(rto.Timer)
if err := timer.Configure(nanotime); err != nil {
t.Error(err)
}
return timer
},
}
}
svGo := sv.StackBlocking(svGoro.Yield).StackGo(StackGoConfig{
ListenerPoolConfig: newPool(svGoro.Yield),
})
clGo := client.StackBlocking(clGoro.Yield).StackGo(StackGoConfig{
ListenerPoolConfig: newPool(clGoro.Yield),
TCPDialTimeout: 60 * time.Second,
TCPDialRetries: 1,
})
svGo.blk._nanotime = nanotime
clGo.blk._nanotime = nanotime
lsAny, err := svGo.SocketNetip(context.Background(), "tcp", syscall.AF_INET, sockSTREAM,
netip.AddrPortFrom(netip.AddrFrom4(sv.Addr4()), svPort), netip.AddrPort{})
if err != nil {
t.Fatal(err)
}
listener := lsAny.(net.Listener)
defer listener.Close()
// dropNext arms the driver to swallow the next server→client data frame. It
// is handed between the server goroutine and the driver by the scheduler
// handoff, which orders every access to it.
var dropNext, dropped bool
go func() {
c, err := listener.Accept()
if err != nil {
svGoro.FinishWithErr(err)
return
}
dropNext = true // The very next data frame is lost in transit.
_, err = c.Write([]byte(want))
c.Close() // Closing here is what makes #182's FIN-WAIT-1 retransmit matter.
svGoro.FinishWithErr(err)
}()
raddr := netip.AddrPortFrom(netip.AddrFrom4(sv.Addr4()), svPort)
go func() {
cAny, err := clGo.SocketNetip(context.Background(), "tcp", syscall.AF_INET, sockSTREAM,
netip.AddrPort{}, raddr)
if err != nil {
clGoro.FinishWithErr(err)
return
}
conn := cAny.(net.Conn)
got := make([]byte, 0, len(want))
rb := make([]byte, 64)
for len(got) < len(want) {
n, err := conn.Read(rb)
got = append(got, rb[:n]...)
if err != nil {
clGoro.FinishWithErr(fmt.Errorf("read %d/%d bytes: %w", len(got), len(want), err))
return
}
}
if string(got) != want {
clGoro.FinishWithErr(fmt.Errorf("read %q, want %q", got, want))
return
}
// Closed before finishing: a Yield after FinishWithErr would never be
// serviced, since the driver stops resuming a goroutine it has reaped.
conn.Close()
clGoro.Finish()
}()
var buf [MTU + ethernet.MaxOverheadSize]byte
// pump moves one frame each way, dropping the armed one. Only ever called
// with both goroutines parked.
// Ingress errors are not fatal here: once a segment is dropped the frames
// behind it arrive past rcv.nxt and are rejected, which is precisely the
// stall the retransmission has to break. Egress errors are real faults.
pump := func() (moved bool) {
n, err := client.EgressEthernet(buf[:])
if err != nil {
t.Fatal("client egress:", err)
} else if n > 0 {
sv.IngressEthernet(buf[:n])
moved = true
}
n, err = sv.EgressEthernet(buf[:])
if err != nil {
t.Fatal("server egress:", err)
} else if n > 0 {
if dropNext && n > minDataFrame {
dropNext, dropped = false, true
} else {
client.IngressEthernet(buf[:n])
}
moved = true
}
return moved
}
for round := 0; ; round++ {
if round == maxRounds {
t.Fatalf("no retransmission after %d rounds and %v of simulated time (dropped=%v): is a Policy installed?",
maxRounds, time.Duration(now), dropped)
}
allFinished, err := tsched.AwaitAllParked()
if err != nil {
t.Fatalf("after losing one segment (dropped=%v): %v", dropped, err)
}
if allFinished {
break
}
if !pump() {
now += int64(quietStep) // Both sides idle: let the RTO age.
}
tsched.YieldToAllParked()
}
if !dropped {
t.Fatal("no frame was dropped, so the test did not exercise retransmission")
}
}
+20 -34
View File
@@ -12,9 +12,10 @@ import (
"github.com/soypat/lneto/tcp"
)
const (
maxIter = 1000
)
// Blocking waits below are bounded by their deadline instead of an iteration
// count: with a non-sleeping backoff such as [lneto.BackoffFlagGosched] a cap
// of N spins expires well before the caller's timeout (~20ms for the former
// 1000 iterations), so the timeout argument had no effect.
var (
errDeadlineExceed = errors.New("cywnet: deadline exceeded")
@@ -56,17 +57,15 @@ func (s StackBlocking) DoDHCPv4(reqAddr [4]byte, timeout time.Duration) (*DHCPRe
deadline := s.deadlineTO(timeout)
requested := false
var lastState dhcpv4.ClientState
for range maxIter {
for ok := true; ok; ok = s.checkDeadline(deadline) == nil {
s.async.mu.Lock()
state := s.async.dhcp.State()
s.async.mu.Unlock()
if state == lastState {
if err = s.checkDeadline(deadline); err != nil {
return nil, err
}
s.backoff(backoffs)
backoffs++
} else {
continue
}
// State change indicates something happened.
backoffs = 0
lastState = state
@@ -74,11 +73,10 @@ func (s StackBlocking) DoDHCPv4(reqAddr [4]byte, timeout time.Duration) (*DHCPRe
if requested && state == dhcpv4.StateInit {
return nil, errors.New("DHCP NACK")
} else if state == dhcpv4.StateBound {
break // DHCP done succesfully.
return s.async.ResultDHCP() // DHCP done succesfully.
}
}
}
return s.async.ResultDHCP()
return nil, errDeadlineExceed
}
func (s StackBlocking) DoPing(hostAddr netip.Addr, timeout time.Duration) (roundtrip time.Duration, err error) {
@@ -95,18 +93,14 @@ func (s StackBlocking) DoPing(hostAddr netip.Addr, timeout time.Duration) (round
}
start := time.Now()
var backoffs uint
for range maxIter {
for ok := true; ok; ok = time.Since(start) <= timeout {
s.async.mu.Lock()
completed, exists := s.async.icmp.PingPop(key)
s.async.mu.Unlock()
if !exists {
return 0, net.ErrClosed // lneto.ErrAborted
}
elapsed := time.Since(start)
if completed {
return elapsed, nil
} else if elapsed > timeout {
break
} else if completed {
return time.Since(start), nil
}
s.backoff(backoffs)
backoffs++
@@ -123,12 +117,10 @@ func (s StackBlocking) DoNTP(hostAddr netip.Addr, timeout time.Duration) (offset
deadline := s.deadlineTO(timeout)
var done bool
var backoffs uint
for range maxIter {
for ok := true; ok; ok = s.checkDeadline(deadline) == nil {
offset, done = s.async.ResultNTPOffset()
if done {
return offset, nil
} else if err = s.checkDeadline(deadline); err != nil {
return -1, err
}
s.backoff(backoffs)
backoffs++
@@ -143,16 +135,16 @@ func (s StackBlocking) DoResolveHardwareAddress6(addr netip.Addr, timeout time.D
}
var backoffs uint
deadline := s.deadlineTO(timeout)
for range maxIter {
for ok := true; ok; ok = s.checkDeadline(deadline) == nil {
hw, err = s.async.ResultResolveHardwareAddress6(addr)
if err == nil {
break
} else if err = s.checkDeadline(deadline); err != nil {
break
}
s.backoff(backoffs)
backoffs++
err = errDeadlineExceed // Ensure that if iterations done error is returned.
}
if err != nil {
err = errDeadlineExceed // Loop only ends on the deadline; err is stale.
}
ip4 := addr.As4()
s.async.arp.CacheRemove(ip4[:])
@@ -173,12 +165,10 @@ func (s StackBlocking) DoLookupIPType(host string, timeout time.Duration, qtype
deadline := s.deadlineTO(timeout)
var backoffs uint
for range maxIter {
for ok := true; ok; ok = s.checkDeadline(deadline) == nil {
addrs, completed, err := s.async.ResultLookupIP(host)
if completed {
return addrs, err
} else if err = s.checkDeadline(deadline); err != nil {
return nil, err
}
s.backoff(backoffs)
backoffs++
@@ -203,15 +193,11 @@ func (s StackBlocking) DoDialTCP(conn *tcp.Conn, localPort uint16, addrp netip.A
func (s StackBlocking) waitDialTCP(conn *tcp.Conn, timeout time.Duration) (err error) {
deadline := s.deadlineTO(timeout)
var backoffs uint
for range maxIter {
for ok := true; ok; ok = s.checkDeadline(deadline) == nil {
state := conn.State()
if state == tcp.StateEstablished {
return nil
} else if state == tcp.StateSynSent || state == tcp.StateSynRcvd || conn.AwaitingSynSend() {
if err = s.checkDeadline(deadline); err != nil {
return err
}
} else {
} else if state != tcp.StateSynSent && state != tcp.StateSynRcvd && !conn.AwaitingSynSend() {
// Unexpected state, abort and terminate connection.
return errTCPFailedToConnect
}
+2 -8
View File
@@ -176,19 +176,13 @@ func (s StackGo) SocketNetip(ctx context.Context, network string, family, sotype
if isDial {
var conn tcp.Conn
// DIAL TCP: active connection a.k.a TCP Client branch.
conncfg := tcp.ConnConfig{
err = conn.Configure(tcp.ConnConfig{
// TODO(pato): Eventually add UDP configuration. we use TCP for now for simplicity's sake.
TxBuf: make([]byte, s.plcfg.TxBufSize),
RxBuf: make([]byte, s.plcfg.RxBufSize),
TxPacketQueueSize: s.plcfg.QueueSize,
RWBackoff: s.plcfg.NewBackoff(),
}
if s.plcfg.NewPolicy != nil {
// A dialed connection needs loss recovery as much as a pooled
// one. See [TCPPoolConfig.NewPolicy].
conncfg.Policy = s.plcfg.NewPolicy()
}
err = conn.Configure(conncfg)
})
if err != nil {
return nil, err
}
+2 -11
View File
@@ -42,9 +42,8 @@ type TCPPoolConfig struct {
ConnLogger *slog.Logger
// NanoTime returns the current monotonic time in nanoseconds.
// Used for pool timeout tracking. If nil, defaults to time.Now().UnixNano().
// Retransmission timing is not driven by this clock: a [tcp.Policy] carries
// its own. See NewPolicy.
// Used for pool timeout tracking and passed to each [tcp.Conn] for
// retransmission timing (RFC 6298). If nil, defaults to time.Now().UnixNano().
NanoTime func() int64
// EstablishedTimeout sets the timeout for a TCP connection since it is acquired until it is established.
// If the connection does not establish in this time it will be closed by the pool.
@@ -57,9 +56,6 @@ type TCPPoolConfig struct {
// NewBackoff returns the backoff to use for every newly configured TCP connection. Must be non-nil.
// This should always return a static(non-method) function unless you know what you are doing.
NewBackoff func() lneto.BackoffStrategy
// NewPolicy if non-nil creates a [tcp.Policy] for each [tcp.Conn] used by the configured Listener.
// NewPolicy should not return reused policies unless the algorithm is stateless. See [tcp.Policy] for more information.
NewPolicy func() tcp.Policy
}
func NewTCPPool(cfg TCPPoolConfig) (*TCPPool, error) {
@@ -92,11 +88,6 @@ func NewTCPPool(cfg TCPPoolConfig) (*TCPPool, error) {
Logger: cfg.ConnLogger,
RWBackoff: cfg.NewBackoff(),
}
if cfg.NewPolicy != nil {
// One Policy per connection: it shadows that connection's send
// sequence space and so cannot be shared.
conncfg.Policy = cfg.NewPolicy()
}
err := pool.conns[i].Configure(conncfg)
if err != nil {
return nil, err
+85
View File
@@ -1232,3 +1232,88 @@ func TestEphemeralPortSequence(t *testing.T) {
t.Fatalf("post-cycle port %d below dynamic range", got)
}
}
// TestStackGoTCPDialSurvivesManyWaitIterations checks the dial wait ends on its
// deadline and not on an iteration count: the peer stays silent for several
// times the former maxIter while simulated time advances by a fraction of the
// timeout, and the dial must still establish once the handshake is serviced.
func TestStackGoTCPDialSurvivesManyWaitIterations(t *testing.T) {
const seed = 91011
const MTU = ethernet.MaxMTU
const tcptimeout = time.Second
const quietIters = 4000 // well past the former iteration cap
client, sv, _, svconn := newTCPStacks(t, seed, MTU)
err := sv.ListenTCP4(svconn, 22)
if err != nil {
t.Fatal(err)
}
tsched := ltesto.NewSched(t)
tgoro := tsched.Goro()
sg := client.StackBlocking(tgoro.Yield).StackGo(StackGoConfig{
ListenerPoolConfig: TCPPoolConfig{
QueueSize: 4,
TxBufSize: MTU,
RxBufSize: MTU,
NewBackoff: func() lneto.BackoffStrategy {
return backoffYield
},
},
TCPDialTimeout: tcptimeout,
TCPDialRetries: 1,
})
// Simulated clock: the quiet phase below advances less than 5% of the dial
// timeout, so a timeout error there can only come from iteration counting.
var now time.Duration
sg.blk._nanotime = func() int64 { return int64(now) }
laddr := netip.AddrPortFrom(netip.AddrFrom4(client.Addr4()), 1234)
raddr := netip.AddrPortFrom(netip.AddrFrom4(sv.Addr4()), 22)
go func() {
_, err := sg.SocketNetip(context.Background(), "tcp", syscall.AF_INET, sockSTREAM, laddr, raddr)
tgoro.FinishWithErr(err)
}()
// Quiet phase: no packets serviced, so the dialer only spins.
for i := range quietIters {
done, err := tsched.AwaitGoroYieldOrDone()
if done {
t.Fatalf("dial gave up during quiet phase after %d iterations: %v", i, err)
}
now += tcptimeout / 100000
tsched.YieldToGoro()
}
// Handshake phase: pump packets until the dial completes, bounded rounds so
// a broken handshake fails loudly.
var buf [ethernet.MaxMTU + ethernet.MaxOverheadSize]byte
for range 64 {
done, err := tsched.AwaitGoroYieldOrDone()
if done {
if err != nil {
t.Fatalf("dial failed after handshake serviced: %v", err)
}
return // Established under deadline: test success.
}
n, err := client.EgressEthernet(buf[:])
if err != nil {
t.Fatal(err)
}
if n > 0 {
if err := sv.IngressEthernet(buf[:n]); err != nil {
t.Fatal(err)
}
}
n, err = sv.EgressEthernet(buf[:])
if err != nil {
t.Fatal(err)
}
if n > 0 {
if err := client.IngressEthernet(buf[:n]); err != nil {
t.Fatal(err)
}
}
now += tcptimeout / 100000
tsched.YieldToGoro()
}
t.Fatal("dial did not establish within handshake rounds")
}