mirror of
https://github.com/soypat/lneto.git
synced 2026-09-08 07:49:05 +00:00
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)
|
|
}
|