diff --git a/modules/power/cmd/power/draw.go b/modules/power/cmd/power/draw.go index eae75998..c6fe7fa9 100644 --- a/modules/power/cmd/power/draw.go +++ b/modules/power/cmd/power/draw.go @@ -271,13 +271,12 @@ func (m *Machine) Publish(r Reading) error { // RunDraw samples and publishes until the context ends. func RunDraw(ctx context.Context, m *Machine) error { - return runDraw(ctx, m, SampleEvery, m.Publish) + return runDraw(ctx, m, SampleEvery, sleepFor(ctx), m.Publish) } -// runDraw publishes one reading every `every`. A battery reading is instant, so it waits the interval -// itself; a counter reading waits inside its sample. It returns as soon as the context ends. -func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(Reading) error) error { - wait := func(d time.Duration) bool { +// sleepFor waits d on the real clock, or less when ctx ends first; it reports whether the full wait passed. +func sleepFor(ctx context.Context) func(time.Duration) bool { + return func(d time.Duration) bool { select { case <-ctx.Done(): return false @@ -285,6 +284,12 @@ func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func( return true } } +} + +// runDraw publishes one reading every `every`. A battery reading is instant, so it waits the interval +// itself; a counter reading waits inside its sample. The wait is injected so a test drives the clock; +// it returns false when the loop must stop. It returns as soon as the context ends. +func runDraw(ctx context.Context, m *Machine, every time.Duration, wait func(time.Duration) bool, publish func(Reading) error) error { for ctx.Err() == nil { d := m.Sample(ctx, every, wait) if ctx.Err() != nil { diff --git a/modules/power/cmd/power/draw_test.go b/modules/power/cmd/power/draw_test.go index 6b06a449..f8a30afa 100644 --- a/modules/power/cmd/power/draw_test.go +++ b/modules/power/cmd/power/draw_test.go @@ -187,24 +187,25 @@ func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) { "/sys/class/power_supply/BAT0/status": "Discharging", "/sys/class/power_supply/BAT0/power_now": "12000000", }) + // A fake clock: each wait returns at once; the tenth cancels the context, as the end of the run. ctx, cancel := context.WithCancel(context.Background()) + defer cancel() var lines []string - done := make(chan error) - go func() { - done <- runDraw(ctx, m, 20*time.Millisecond, func(r Reading) error { lines = append(lines, r.Text); return nil }) - }() - time.Sleep(210 * time.Millisecond) - cancel() - select { - case err := <-done: - if err != nil { - t.Fatal(err) + waits := 0 + wait := func(d time.Duration) bool { + if d != 20*time.Millisecond { + t.Errorf("waited %v, want the 20 ms interval", d) } - case <-time.After(time.Second): - t.Fatal("the loop did not stop when cancelled") + if waits++; waits == 10 { + cancel() + } + return ctx.Err() == nil } - // One at once, then one every 20 ms: about eleven in 210 ms, never a burst, never a stall. - if len(lines) < 6 || len(lines) > 12 || lines[0] != "12.0 W machine" { + if err := runDraw(ctx, m, 20*time.Millisecond, wait, func(r Reading) error { lines = append(lines, r.Text); return nil }); err != nil { + t.Fatal(err) + } + // One at once, then one after each of the nine waits that passed; the tenth wait ended the loop. + if len(lines) != 10 || lines[0] != "12.0 W machine" { t.Fatalf("%d lines: %v", len(lines), lines) } } diff --git a/modules/power/cmd/power/watcher_test.go b/modules/power/cmd/power/watcher_test.go index 03df7546..a4d7d424 100644 --- a/modules/power/cmd/power/watcher_test.go +++ b/modules/power/cmd/power/watcher_test.go @@ -213,11 +213,24 @@ func TestAMachineWithoutMainsSaysNothingAboutSupplies(t *testing.T) { func TestRunWithoutLogindStillSaysTheBoot(t *testing.T) { m := machine(t, map[string]string{"/proc/sys/kernel/random/boot_id": "xyz\n"}) b := &bus{} - w := NewWatcher(m, b.emit, func() (Login1, error) { return nil, errors.New("no system bus") }) - w.state = filepath.Join(t.TempDir(), "b") - w.poll = 10 * time.Millisecond - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + // No timeout racing the loop: the run ends once logind was tried and the boot was published, + // both of which happen in Run's own goroutine. + ctx, cancel := context.WithCancel(context.Background()) defer cancel() + dialed, emitted := false, false + stopWhenBoth := func() { + if dialed && emitted { + cancel() + } + } + w := NewWatcher(m, func(typ string, body any) error { + err := b.emit(typ, body) + emitted = true + stopWhenBoth() + return err + }, func() (Login1, error) { dialed = true; defer stopWhenBoth(); return nil, errors.New("no system bus") }) + w.state = filepath.Join(t.TempDir(), "b") + w.poll = time.Millisecond w.Run(ctx) if got := b.seen(); len(got) == 0 || got[0] != Booted || w.Snapshot().Problem == "" { t.Fatalf("%v %+v", got, w.Snapshot())