mirror of
https://github.com/soypat/lneto.git
synced 2026-09-08 07:49:05 +00:00
claude: fix up test to use ltesto.Sched and enable ltesto.Sched multigoro
This commit is contained in:
+136
-89
@@ -2,29 +2,44 @@ package xnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"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: without a [tcp.Policy] installed the
|
||||
// loss is terminal, which is what this test asserts against.
|
||||
// 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{
|
||||
@@ -52,26 +67,45 @@ func TestTCPRetransmitsLostSegment(t *testing.T) {
|
||||
client.SetGatewayHardwareAddr(sv.HardwareAddr())
|
||||
sv.SetGatewayHardwareAddr(client.HardwareAddr())
|
||||
|
||||
pool := TCPPoolConfig{
|
||||
PoolSize: 2, QueueSize: 4,
|
||||
TxBufSize: bufSize, RxBufSize: bufSize,
|
||||
EstablishedTimeout: 30 * time.Second,
|
||||
ClosingTimeout: 30 * time.Second,
|
||||
NewBackoff: func() lneto.BackoffStrategy { return backoffYield },
|
||||
NewPolicy: func() tcp.Policy {
|
||||
timer := new(rto.Timer)
|
||||
if err := timer.Configure(func() int64 { return time.Now().UnixNano() }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return timer
|
||||
},
|
||||
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(backoffYield).StackGo(StackGoConfig{ListenerPoolConfig: pool})
|
||||
clGo := client.StackBlocking(backoffYield).StackGo(StackGoConfig{
|
||||
ListenerPoolConfig: pool,
|
||||
TCPDialTimeout: 2 * time.Second,
|
||||
TCPDialRetries: 2,
|
||||
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{})
|
||||
@@ -81,85 +115,98 @@ func TestTCPRetransmitsLostSegment(t *testing.T) {
|
||||
listener := lsAny.(net.Listener)
|
||||
defer listener.Close()
|
||||
|
||||
// dropNext arms the pump to swallow the next server→client data frame.
|
||||
var dropNext, dropped atomic.Bool
|
||||
stopPump := make(chan struct{})
|
||||
defer close(stopPump)
|
||||
go func() {
|
||||
buf := make([]byte, MTU+ethernet.MaxOverheadSize)
|
||||
for {
|
||||
select {
|
||||
case <-stopPump:
|
||||
return
|
||||
default:
|
||||
}
|
||||
moved := false
|
||||
if n, err := client.EgressEthernet(buf); err == nil && n > 0 {
|
||||
sv.IngressEthernet(buf[:n])
|
||||
moved = true
|
||||
}
|
||||
if n, err := sv.EgressEthernet(buf); err == nil && n > 0 {
|
||||
// Drop only a data-carrying frame: headers total 54 bytes, so
|
||||
// anything larger has payload. Dropping a bare ACK would test the
|
||||
// other direction's recovery instead.
|
||||
if dropNext.Load() && n > 14+20+20+8 {
|
||||
dropNext.Store(false)
|
||||
dropped.Store(true)
|
||||
} else {
|
||||
client.IngressEthernet(buf[:n])
|
||||
}
|
||||
moved = true
|
||||
}
|
||||
if !moved {
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
}()
|
||||
// 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
|
||||
|
||||
served := make(chan error, 1)
|
||||
go func() {
|
||||
c, err := listener.Accept()
|
||||
if err != nil {
|
||||
served <- err
|
||||
svGoro.FinishWithErr(err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
dropNext.Store(true) // the very next data frame is lost
|
||||
_, err = c.Write([]byte("this segment is lost in transit"))
|
||||
served <- err
|
||||
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)
|
||||
cAny, err := clGo.SocketNetip(context.Background(), "tcp", syscall.AF_INET, sockSTREAM,
|
||||
netip.AddrPort{}, raddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn := cAny.(net.Conn)
|
||||
defer conn.Close()
|
||||
// Generous on purpose: the first RTO is one second (RFC 6298 §2.1) and may
|
||||
// back off once. What is under test is that recovery happens at all.
|
||||
conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
|
||||
want := "this segment is lost in transit"
|
||||
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]...)
|
||||
go func() {
|
||||
cAny, err := clGo.SocketNetip(context.Background(), "tcp", syscall.AF_INET, sockSTREAM,
|
||||
netip.AddrPort{}, raddr)
|
||||
if err != nil {
|
||||
t.Fatalf("read %d/%d bytes after losing one segment (dropped=%v): %v (no retransmission timer?)",
|
||||
len(got), len(want), dropped.Load(), err)
|
||||
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
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("read %q, want %q", got, want)
|
||||
|
||||
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.Load() {
|
||||
if !dropped {
|
||||
t.Fatal("no frame was dropped, so the test did not exercise retransmission")
|
||||
}
|
||||
if err := <-served; err != nil {
|
||||
t.Fatalf("server side: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user