mirror of
https://github.com/soypat/lneto.git
synced 2026-09-09 00:09:08 +00:00
97b625de47
* fix(xnet): install a retransmission timer on TCP connections No connection created through x/xnet had one. Neither NewTCPPool nor the dial path in StackGo set ConnConfig.LossRecovery and ConnConfig.Nanotime, so tcp.Handler ran with loss recovery disabled: nothing noticed a lost segment, and the connection simply stopped: the sender waiting for an ACK that cannot arrive, the receiver for data nobody will resend. TCPPoolConfig.NanoTime already documents itself as "passed to each tcp.Conn for retransmission timing (RFC 6298)", including the time.Now fallback when it is nil; this makes that true. Each connection gets its own tcp.RTO, which shadows that connection's send sequence space and so cannot be shared. That is 80 bytes per connection, allocated once at pool construction, against buffers measured in kilobytes. The test drops exactly one data segment and requires the byte to arrive anyway. It depends on the FIN-WAIT-1 retransmission fix, since the server closes after writing. * merge main fixes * claude: fix up test to use ltesto.Sched and enable ltesto.Sched multigoro * move test and simplify top comment --------- Co-authored-by: Derek den Haas <i.pestano@easyflor.nl>
228 lines
7.8 KiB
Go
228 lines
7.8 KiB
Go
package ltesto
|
|
|
|
import (
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/soypat/lneto"
|
|
)
|
|
|
|
// NewSched creates a cooperative two-goroutine scheduler modelling a
|
|
// coroutine handoff: the scheduled (stack) goroutine drives the [SchedGoro] handle
|
|
// while the controlling test thread drives the [SchedDriver] handle. Splitting the
|
|
// API across two handles makes it impossible to call a goroutine-side method
|
|
// from the test thread, or vice versa.
|
|
func NewSched(t testing.TB) *Sched {
|
|
return &Sched{
|
|
t: t,
|
|
timeout: time.Second,
|
|
}
|
|
}
|
|
|
|
// 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{}
|
|
// when main goroutine is ready for more information this channel is written to to signal waiting on stack activity.
|
|
continueSignal 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]
|
|
}
|
|
|
|
// 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 <-time.After(ss.timeout):
|
|
ss.t.Fatal("timeout waiting for stack to backoff")
|
|
}
|
|
}
|
|
|
|
// AwaitGoroYieldOrDone blocks until the coroutine either parks itself via
|
|
// [SchedGoro.Yield] (returning done=false) or terminates via [SchedGoro.FinishWithErr]
|
|
// /[SchedGoro.Finish] (returning done=true and the terminal error). It lets a driver
|
|
// loop service an a-priori-unknown number of yields and still observe completion in
|
|
// 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
|
|
return false, nil
|
|
case err = <-g.finishChan:
|
|
g.finished, g.err = true, err
|
|
return true, err
|
|
case <-time.After(ss.timeout):
|
|
ss.t.Fatal("timeout waiting for stack to yield or finish")
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
// 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 <-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
|
|
}
|
|
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].
|
|
func (ss *Sched) Goro() SchedGoro {
|
|
g := &schedGoro{
|
|
yieldSignal: make(chan struct{}),
|
|
continueSignal: make(chan struct{}),
|
|
finishChan: make(chan error, 1),
|
|
}
|
|
ss.goros = append(ss.goros, g)
|
|
return SchedGoro{ss: ss, g: g}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Yield suspends the goroutine at a backoff point and parks until the driver
|
|
// calls [SchedDriver.YieldToGoro]. Its signature satisfies [lneto.BackoffStrategy] so it
|
|
// can be passed directly as the stack's backoff strategy.
|
|
func (c SchedGoro) Yield(consecutiveBackoffs uint) time.Duration {
|
|
ss := c.ss
|
|
timeout := time.After(ss.timeout)
|
|
select {
|
|
case c.g.yieldSignal <- 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 <-timeout:
|
|
ss.t.Fatal("timeout waiting for continue")
|
|
}
|
|
return lneto.BackoffFlagNop // backoff yield implemented on our side.
|
|
}
|
|
|
|
// FinishWithErr terminates the coroutine, handing err to the driver's [SchedDriver.Done]
|
|
// channel. It must be called at most once.
|
|
func (c SchedGoro) FinishWithErr(err error) {
|
|
ss := c.ss
|
|
if len(c.g.finishChan) != 0 {
|
|
ss.t.Fatal("Coro.FinishWithErr can be called once only")
|
|
}
|
|
c.g.finishChan <- err
|
|
}
|
|
|
|
// Finish is just shorthand for c.FinishWithErr(nil).
|
|
func (c SchedGoro) Finish() {
|
|
c.FinishWithErr(nil)
|
|
}
|