hardlink/internal/dispenser/maintenance_test.go

363 lines
11 KiB
Go

package dispenser
import (
"context"
"errors"
"reflect"
"sync"
"testing"
"time"
)
func maintenanceClient(t *testing.T, p *scriptedTransport) (*Client, *time.Time) {
t.Helper()
transportAddress(t)
now := time.Unix(100, 0)
c := &Client{port: p, done: make(chan struct{}), activityWake: make(chan struct{}, 1), sequenceTiming: sequenceTiming{now: func() time.Time { return now }, wait: waitForSequence}}
t.Cleanup(c.Close)
return c, &now
}
func TestMaintenanceIdleScheduling(t *testing.T) {
c, now := maintenanceClient(t, &scriptedTransport{})
c.StartMaintenance()
first := c.activity.next
c.StartMaintenance()
if c.activity.next != first {
t.Fatal("repeated start reset the timer")
}
*now = now.Add(59 * time.Second)
c.maintainIdleCard()
if len(c.port.(*scriptedTransport).writes) != 0 {
t.Fatal("maintenance ran before one minute")
}
release1 := c.BeginForeground()
release2 := c.BeginForeground()
release1()
if _, enabled := c.maintenanceDelay(); enabled {
t.Fatal("timer enabled while another issuance active")
}
*now = now.Add(20 * time.Second)
release2()
release2()
if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute {
t.Fatalf("after last release delay=%v enabled=%t", delay, enabled)
}
c.StopMaintenance()
if _, enabled := c.maintenanceDelay(); enabled {
t.Fatal("stop left timer enabled")
}
}
func TestMaintenanceUsesCommonEligibility(t *testing.T) {
for _, tc := range []struct {
name string
st []byte
pending bool
age time.Duration
fc7 bool
}{
{"clear", status(0x30), false, 0, true},
{"staging", status(0x34), true, 3 * time.Second, true},
{"too early", status(0x30), true, time.Second, false},
{"encoder", status(0x33), true, time.Minute, false},
{"empty", status(0x38), false, 0, false},
{"uncertain", status(0x35), false, 0, false},
{"movement", []byte{0x31, 0x30, 0x30, 0x34}, true, time.Minute, false},
{"invalid", status(0x40), true, time.Minute, false},
} {
t.Run(tc.name, func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(tc.st), vendorACK}}
c, now := maintenanceClient(t, p)
c.StartMaintenance()
*now = now.Add(time.Minute)
c.deliveryPending = tc.pending
c.deliveryStarted = now.Add(-tc.age)
callbacks := 0
c.OnStockUpdate(func(string) { callbacks++ })
c.maintainIdleCard()
want := []string{"AP"}
if tc.fc7 {
want = append(want, "FC7")
}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("commands=%v want %v", got, want)
}
if callbacks != 0 {
t.Errorf("maintenance published %d stock callbacks", callbacks)
}
if tc.pending && !tc.fc7 && !c.deliveryPending {
t.Error("ineligible maintenance cleared pending")
}
if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute {
t.Errorf("next maintenance delay=%v enabled=%t", delay, enabled)
}
})
}
}
func TestMaintenanceForegroundBetweenAPAndFC7(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}}
c, now := maintenanceClient(t, p)
c.StartMaintenance()
*now = now.Add(time.Minute)
var release func()
p.afterRead = func() {
if len(p.chunks) == 1 && release == nil {
release = c.BeginForeground()
}
}
c.maintainIdleCard()
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
t.Errorf("foreground during AP commands=%v want AP only", got)
}
if release == nil {
t.Fatal("barrier not reached")
}
release()
if delay, _ := c.maintenanceDelay(); delay != time.Minute {
t.Errorf("foreground completion delay=%v", delay)
}
}
func TestMaintenanceAdmissionConditions(t *testing.T) {
c, _ := maintenanceClient(t, &scriptedTransport{})
c.StartMaintenance()
generation := c.activity.generation
ctx, cancel := context.WithCancel(context.Background())
if !c.admitMaintenance(ctx, generation) {
t.Fatal("idle admission rejected")
}
cancel()
if c.admitMaintenance(ctx, generation) {
t.Fatal("canceled admission accepted")
}
expired, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer stop()
if c.admitMaintenance(expired, generation) {
t.Fatal("expired admission accepted")
}
release := c.BeginForeground()
if c.admitMaintenance(context.Background(), generation) {
t.Fatal("foreground/stale admission accepted")
}
release()
if c.admitMaintenance(context.Background(), generation) {
t.Fatal("obsolete generation accepted")
}
generation = c.activity.generation
c.StopMaintenance()
if c.admitMaintenance(context.Background(), generation) {
t.Fatal("stopped admission accepted")
}
}
func TestPassivePollGenerationAtDispatch(t *testing.T) {
c, _ := maintenanceClient(t, &scriptedTransport{})
generation, _ := c.passiveGeneration()
release := c.BeginForeground()
for _, active := range []bool{true, false} {
if !active {
release()
}
response := make(chan cmdResp, 1)
c.handle(cmdReq{ctx: context.Background(), typ: cmdStatus, passive: true, generation: generation, respCh: response})
<-response
if len(c.port.(*scriptedTransport).writes) != 0 {
t.Errorf("active=%t stale passive request touched port", active)
}
}
}
func TestMaintenanceStopWaitsForAdmittedRead(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}}
c, now := maintenanceClient(t, p)
c.StartMaintenance()
*now = now.Add(time.Minute)
entered, resume := make(chan struct{}), make(chan struct{})
var once sync.Once
p.afterRead = func() { once.Do(func() { close(entered); <-resume }) }
finished := make(chan struct{})
go func() { c.maintainIdleCard(); close(finished) }()
<-entered
stopped := make(chan struct{})
go func() { c.StopMaintenance(); close(stopped) }()
// Wait until stop has disabled admission.
for {
c.activity.Lock()
enabled := c.activity.enabled
c.activity.Unlock()
if !enabled {
break
}
time.Sleep(time.Millisecond)
}
select {
case <-stopped:
t.Fatal("stop returned before admitted read finished")
default:
}
close(resume)
<-finished
<-stopped
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
t.Errorf("stop commands=%v want AP only", got)
}
c.StopMaintenance()
c.Close()
c.Close()
}
func TestMaintenanceWorkerTimerWake(t *testing.T) {
c := NewClient(nil, 1)
c.StartMaintenance()
release := c.BeginForeground()
// An immediate obsolete timer is harmless while foreground is registered.
c.activity.Lock()
c.activity.next = time.Now().Add(-time.Second)
c.activity.Unlock()
c.wakeWorker()
release()
if delay, enabled := c.maintenanceDelay(); !enabled || delay <= 59*time.Second {
t.Errorf("worker schedule delay=%v enabled=%t", delay, enabled)
}
c.Close()
if r := c.doResponse(context.Background(), cmdStatus); r.err == nil {
t.Fatal("closed client accepted a request")
}
}
func TestMaintenanceWorkerTimerDispatch(t *testing.T) {
transportAddress(t)
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}}
c := &Client{port: p, reqCh: make(chan cmdReq, 1), done: make(chan struct{}), activityWake: make(chan struct{}, 1), sequenceTiming: sequenceTiming{now: time.Now, wait: waitForSequence}}
c.StartMaintenance()
c.activity.Lock()
c.activity.next = time.Now().Add(-time.Second)
c.activity.Unlock()
workerDone := make(chan struct{})
go func() { defer close(workerDone); c.loop() }()
t.Cleanup(func() { c.Close(); <-workerDone })
dispatched := make(chan struct{})
// Observe completion through the activity guard, without touching the transport concurrently.
go func() {
for {
c.activity.Lock()
next := c.activity.next
c.activity.Unlock()
if time.Until(next) > 50*time.Second {
close(dispatched)
return
}
select {
case <-c.done:
return
case <-time.After(time.Millisecond):
}
}
}()
select {
case <-dispatched:
case <-time.After(4 * time.Second):
t.Fatal("worker timer did not finish maintenance")
}
c.StopMaintenance()
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "FC7"}) {
t.Errorf("timer commands=%v", got)
}
}
func TestForegroundEncoderClearanceCharacterization(t *testing.T) {
for _, movement := range []bool{false, true} {
st := status(0x33)
if movement {
st[0] = 0x31
}
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(st), vendorACK, apReply(st), vendorACK, apReply(st), vendorACK}}
c, now := maintenanceClient(t, p)
c.deliveryPending = true
c.deliveryStarted = now.Add(-time.Minute)
c.sequenceTiming.wait = func(context.Context, time.Duration) error { *now = now.Add(2 * time.Second); return nil }
r := workerRequest(c, context.Background(), cmdToEncoder)
want := []string{"AP", "AP", "AP"}
if movement {
if !errors.Is(r.err, context.DeadlineExceeded) || !c.deliveryPending {
t.Errorf("movement err=%v pending=%t", r.err, c.deliveryPending)
}
} else {
want = append(want, "FC7")
if r.err != nil || c.deliveryPending {
t.Errorf("internal fallback err=%v pending=%t", r.err, c.deliveryPending)
}
}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("movement=%t commands=%v want %v", movement, got, want)
}
}
}
func TestForegroundEncoderClearanceCallerDeadline(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x33)), vendorACK, apReply(status(0x33)), vendorACK, apReply(status(0x33))}}
c, now := maintenanceClient(t, p)
c.deliveryPending = true
c.deliveryStarted = *now
start := *now
samples := 0
p.afterRead = func() {
if len(p.chunks)%2 == 0 { // Only complete frame reads advance the observation clock.
if len(p.chunks) == 4 || len(p.chunks) == 2 || len(p.chunks) == 0 {
samples++
*now = now.Add(time.Second)
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
c.sequenceTiming.wait = func(context.Context, time.Duration) error {
if samples == 3 {
<-ctx.Done()
return ctx.Err()
}
*now = now.Add(time.Second)
return nil
}
r := workerRequest(c, ctx, cmdToEncoder)
if !errors.Is(r.err, context.DeadlineExceeded) || !c.deliveryPending {
t.Errorf("caller deadline err=%v pending=%t", r.err, c.deliveryPending)
}
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "AP", "AP"}) {
t.Errorf("caller deadline commands=%v want AP only", got)
}
if elapsed := now.Sub(start); elapsed != 5*time.Second {
t.Errorf("last observation elapsed=%v want 5s", elapsed)
}
}
func TestMaintenanceFailuresAreQuietAndRetryLater(t *testing.T) {
for _, mechanical := range []bool{false, true} {
p := &scriptedTransport{chunks: [][]byte{{0x10, 0x06, 0x30}}}
if mechanical {
p.chunks = [][]byte{vendorACK, apReply(status(0x30))}
}
c, now := maintenanceClient(t, p)
c.StartMaintenance()
*now = now.Add(time.Minute)
callbacks := 0
c.OnStockUpdate(func(string) { callbacks++ })
c.maintainIdleCard()
want := []string{"AP"}
if mechanical {
want = append(want, "FC7")
}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("mechanical=%t commands=%v want %v", mechanical, got, want)
}
if callbacks != 0 {
t.Errorf("maintenance failure callbacks=%d", callbacks)
}
if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute {
t.Errorf("retry delay=%v enabled=%t", delay, enabled)
}
}
}