mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
451 lines
14 KiB
Go
451 lines
14 KiB
Go
//go:build unix
|
|
|
|
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/application"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/openwrt"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/policy/faketime"
|
|
)
|
|
|
|
// recordingRunner stands in for the init script.
|
|
//
|
|
// What the tests need from it is not that it starts anything but that they can
|
|
// see exactly what was run and how often. "A status poll does not start the
|
|
// service" is a requirement; with a concrete Runner the only way to check it
|
|
// would be to read the code and believe it.
|
|
type recordingRunner struct {
|
|
mu sync.Mutex
|
|
calls [][]string
|
|
// onStart runs when the script is asked to start, so a test can decide
|
|
// whether the service actually comes up.
|
|
onStart func()
|
|
// onStop likewise.
|
|
onStop func()
|
|
err error
|
|
}
|
|
|
|
func (r *recordingRunner) Run(_ context.Context, program string,
|
|
args ...string) (openwrt.Result, error) {
|
|
r.mu.Lock()
|
|
r.calls = append(r.calls, append([]string{program}, args...))
|
|
onStart, onStop := r.onStart, r.onStop
|
|
err := r.err
|
|
r.mu.Unlock()
|
|
|
|
switch {
|
|
case len(args) > 0 && args[0] == "start" && onStart != nil:
|
|
onStart()
|
|
case len(args) > 0 && args[0] == "stop" && onStop != nil:
|
|
onStop()
|
|
}
|
|
return openwrt.Result{Program: program}, err
|
|
}
|
|
|
|
func (r *recordingRunner) invocations() [][]string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([][]string(nil), r.calls...)
|
|
}
|
|
|
|
func (r *recordingRunner) count(verb string) int {
|
|
seen := 0
|
|
for _, call := range r.invocations() {
|
|
if len(call) > 1 && call[1] == verb {
|
|
seen++
|
|
}
|
|
}
|
|
return seen
|
|
}
|
|
|
|
// drive keeps a fake clock moving while something waits on it, so a bounded
|
|
// wait finishes in test time rather than in real time. The returned function
|
|
// stops the driver; without a way out it would block forever on the last
|
|
// BlockUntil, because nothing tells it the waiter has stopped waiting.
|
|
func drive(clock *faketime.Clock) func() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var driver sync.WaitGroup
|
|
driver.Go(func() {
|
|
for {
|
|
if err := clock.BlockUntilContext(ctx, 1); err != nil {
|
|
return
|
|
}
|
|
clock.Advance(pollInterval)
|
|
}
|
|
})
|
|
return func() {
|
|
cancel()
|
|
driver.Wait()
|
|
}
|
|
}
|
|
|
|
// T25 -- a status poll never starts the service.
|
|
//
|
|
// A browser tab left open polls this every few seconds. If polling started the
|
|
// daemon, a user who stopped it would find it running again, and a status
|
|
// request would be changing the thing it reports on.
|
|
func TestAStatusPollNeverStartsTheService(t *testing.T) {
|
|
paths := tempPaths(t)
|
|
if err := MarkStopped(paths, true, 7, "2.0.0rc1"); err != nil {
|
|
t.Fatalf("seed snapshot: %v", err)
|
|
}
|
|
|
|
runner := &recordingRunner{}
|
|
lifecycle := Lifecycle{Paths: paths, Runner: runner}
|
|
|
|
for range 3 {
|
|
snapshot, err := lifecycle.Status(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if snapshot.Service != ServiceStopped {
|
|
t.Errorf("service = %q, want stopped", snapshot.Service)
|
|
}
|
|
if !snapshot.Enabled || snapshot.ConfigRevision != 7 {
|
|
t.Errorf("the stopped record lost its contents: %+v", snapshot)
|
|
}
|
|
}
|
|
if calls := runner.invocations(); len(calls) != 0 {
|
|
t.Errorf("a status poll ran %v", calls)
|
|
}
|
|
}
|
|
|
|
// A service that has never run has no snapshot, and that is still an answer.
|
|
func TestStatusWithNoRecordAtAllSaysStopped(t *testing.T) {
|
|
runner := &recordingRunner{}
|
|
lifecycle := Lifecycle{Paths: tempPaths(t), Runner: runner}
|
|
|
|
snapshot, err := lifecycle.Status(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if snapshot.Service != ServiceStopped {
|
|
t.Errorf("service = %q", snapshot.Service)
|
|
}
|
|
if len(runner.invocations()) != 0 {
|
|
t.Error("reading a missing snapshot ran something")
|
|
}
|
|
}
|
|
|
|
// The running service answers, and its own snapshot is what comes back.
|
|
func TestStatusPrefersTheRunningService(t *testing.T) {
|
|
service := start(t, nil)
|
|
runner := &recordingRunner{}
|
|
lifecycle := Lifecycle{Paths: service.paths, Runner: runner}
|
|
|
|
snapshot, err := lifecycle.Status(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if snapshot.Service != ServiceRunning || snapshot.PID != os.Getpid() {
|
|
t.Errorf("snapshot = %+v", snapshot)
|
|
}
|
|
if len(runner.invocations()) != 0 {
|
|
t.Error("reading a running service's status ran something")
|
|
}
|
|
}
|
|
|
|
// Ensuring a service that is already up runs nothing. Spec 02 is explicit that
|
|
// this is not "restart the daemon for every operation".
|
|
func TestEnsureRunningDoesNothingWhenTheServiceIsUp(t *testing.T) {
|
|
service := start(t, nil)
|
|
runner := &recordingRunner{}
|
|
lifecycle := Lifecycle{Paths: service.paths, Runner: runner}
|
|
|
|
if err := lifecycle.EnsureRunning(t.Context()); err != nil {
|
|
t.Fatalf("ensure-running: %v", err)
|
|
}
|
|
if calls := runner.invocations(); len(calls) != 0 {
|
|
t.Errorf("a running service was started again: %v", calls)
|
|
}
|
|
}
|
|
|
|
// T25 -- ensure-running starts the service once, waits, and gives up.
|
|
//
|
|
// Once, not in a loop: spec 02 forbids the loop because a service that cannot
|
|
// come up will not come up on the second try either, and turning one user
|
|
// action into a restart storm makes the router worse, not better.
|
|
func TestEnsureRunningStartsOnceAndThenGivesUp(t *testing.T) {
|
|
clock := faketime.New(time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC))
|
|
stopDriver := drive(clock)
|
|
defer stopDriver()
|
|
|
|
// A start that does not bring anything up, which is the case being tested.
|
|
runner := &recordingRunner{}
|
|
lifecycle := Lifecycle{Paths: tempPaths(t), Runner: runner, Clock: clock}
|
|
|
|
err := lifecycle.EnsureRunning(t.Context())
|
|
if err == nil {
|
|
t.Fatal("ensure-running reported success with nothing listening")
|
|
}
|
|
if code := codeOf(t, err); code != domain.CodeServiceStopped {
|
|
t.Errorf("code = %s, want ServiceStopped", code)
|
|
}
|
|
if got := runner.count("start"); got != 1 {
|
|
t.Errorf("the script was started %d times, want exactly 1", got)
|
|
}
|
|
if calls := runner.invocations(); len(calls) != 1 ||
|
|
calls[0][0] != DefaultInitScript {
|
|
t.Errorf("ran %v, want only %s start", calls, DefaultInitScript)
|
|
}
|
|
}
|
|
|
|
// And when the start works, ensure-running waits for the socket and returns.
|
|
func TestEnsureRunningWaitsForTheServiceToAnswer(t *testing.T) {
|
|
paths := tempPaths(t)
|
|
clock := faketime.New(time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC))
|
|
stopDriver := drive(clock)
|
|
defer stopDriver()
|
|
|
|
ready := make(chan struct{})
|
|
done := make(chan error, 1)
|
|
ctx, stopService := context.WithCancel(context.Background())
|
|
defer func() {
|
|
stopService()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(patience):
|
|
t.Error("the started service did not stop")
|
|
}
|
|
}()
|
|
|
|
runner := &recordingRunner{onStart: func() {
|
|
go func() {
|
|
done <- Run(ctx, Options{Paths: paths, Version: "2.0.0rc1",
|
|
Ready: sync.OnceFunc(func() { close(ready) })})
|
|
}()
|
|
// The init script returns once procd has the service; waiting for the
|
|
// socket here is what makes the test's timing deterministic rather than
|
|
// a race between a fake clock and a real bind.
|
|
select {
|
|
case <-ready:
|
|
case <-time.After(patience):
|
|
}
|
|
}}
|
|
|
|
lifecycle := Lifecycle{Paths: paths, Runner: runner, Clock: clock}
|
|
if err := lifecycle.EnsureRunning(t.Context()); err != nil {
|
|
t.Fatalf("ensure-running: %v", err)
|
|
}
|
|
if got := runner.count("start"); got != 1 {
|
|
t.Errorf("started %d times, want 1", got)
|
|
}
|
|
}
|
|
|
|
// T25 -- stopping cancels what is in flight first, then stops the service, and
|
|
// leaves a record that says so.
|
|
func TestStopCancelsInFlightWorkThenStops(t *testing.T) {
|
|
held := make(chan struct{})
|
|
entered := make(chan struct{}, 1)
|
|
service := start(t, func(options *Options) {
|
|
options.Runner = blockingRunner{entered: entered, release: held}
|
|
})
|
|
defer close(held)
|
|
|
|
var receipt SubmitResult
|
|
if err := json.Unmarshal(service.call("action.submit", SubmitParams{
|
|
Kind: "manual_login", AccountID: "campus", IdempotencyKey: "click-1"}),
|
|
&receipt); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(patience):
|
|
t.Fatal("the action never started")
|
|
}
|
|
|
|
clock := faketime.New(time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC))
|
|
stopDriver := drive(clock)
|
|
defer stopDriver()
|
|
|
|
runner := &recordingRunner{onStop: func() {
|
|
service.stop()
|
|
service.wait()
|
|
}}
|
|
lifecycle := Lifecycle{Paths: service.paths, Runner: runner, Clock: clock}
|
|
|
|
report, err := lifecycle.Stop(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("stop: %v", err)
|
|
}
|
|
if report.AlreadyStopped {
|
|
t.Error("a running service was reported as already stopped")
|
|
}
|
|
if report.CancelledActions != 1 {
|
|
t.Errorf("cancelled %d actions, want 1", report.CancelledActions)
|
|
}
|
|
if got := runner.count("stop"); got != 1 {
|
|
t.Errorf("the script was stopped %d times, want 1", got)
|
|
}
|
|
|
|
snapshot, err := ReadSnapshot(service.paths)
|
|
if err != nil {
|
|
t.Fatalf("read snapshot: %v", err)
|
|
}
|
|
if snapshot.Service != ServiceStopped {
|
|
t.Errorf("service = %q", snapshot.Service)
|
|
}
|
|
if _, err := os.Stat(service.paths.Socket()); !os.IsNotExist(err) {
|
|
t.Errorf("the socket survived the stop: %v", err)
|
|
}
|
|
|
|
// The action the user cancelled stays cancelled. Spec 02 forbids replaying
|
|
// it at the next start, and that depends on it not being relabelled here.
|
|
final := service.awaitTerminal(receipt.ActionID)
|
|
if final.State != application.StateCancelled {
|
|
t.Errorf("state = %s, want cancelled", final.State)
|
|
}
|
|
}
|
|
|
|
// Stopping something that is not running is not an error, and it still leaves a
|
|
// record saying so -- including the user's switch, untouched.
|
|
func TestStoppingSomethingThatIsNotRunningIsNotAnError(t *testing.T) {
|
|
paths := tempPaths(t)
|
|
if err := WriteSnapshot(paths, Snapshot{Service: ServiceRunning, PID: 999999,
|
|
Enabled: true, ConfigRevision: 4, Version: "2.0.0rc1"}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
clock := faketime.New(time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC))
|
|
stopDriver := drive(clock)
|
|
defer stopDriver()
|
|
|
|
runner := &recordingRunner{}
|
|
lifecycle := Lifecycle{Paths: paths, Runner: runner, Clock: clock}
|
|
|
|
// A socket file a killed daemon left behind. Nothing unlinked it, because
|
|
// SIGKILL closes nothing, and a caller that dials it waits for a connection
|
|
// that will never be accepted instead of seeing a stopped service.
|
|
if err := os.MkdirAll(paths.Runtime, RuntimeDirMode); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
stale, err := net.Listen("unix", paths.Socket())
|
|
if err != nil {
|
|
t.Fatalf("create a stale socket: %v", err)
|
|
}
|
|
stale.(*net.UnixListener).SetUnlinkOnClose(false)
|
|
stale.Close()
|
|
if _, err := os.Stat(paths.Socket()); err != nil {
|
|
t.Fatalf("the stale socket was not left in place: %v", err)
|
|
}
|
|
|
|
report, err := lifecycle.Stop(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("stop: %v", err)
|
|
}
|
|
if !report.AlreadyStopped || report.CancelledActions != 0 {
|
|
t.Errorf("report = %+v", report)
|
|
}
|
|
if _, err := os.Stat(paths.Socket()); !os.IsNotExist(err) {
|
|
t.Errorf("the stale socket survived the stop: %v", err)
|
|
}
|
|
// The script is still run: a snapshot that says "running" after a crash is
|
|
// exactly when procd may still have something to stop.
|
|
if got := runner.count("stop"); got != 1 {
|
|
t.Errorf("stopped %d times, want 1", got)
|
|
}
|
|
|
|
snapshot, err := ReadSnapshot(paths)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if snapshot.Service != ServiceStopped {
|
|
t.Errorf("service = %q", snapshot.Service)
|
|
}
|
|
if !snapshot.Enabled {
|
|
t.Error("stopping switched automatic authentication off")
|
|
}
|
|
if snapshot.ConfigRevision != 4 || snapshot.Version != "2.0.0rc1" {
|
|
t.Errorf("the record lost what the daemon had published: %+v", snapshot)
|
|
}
|
|
}
|
|
|
|
// A service that will not stop is reported rather than declared stopped.
|
|
//
|
|
// Writing "stopped" over something that is still authenticating would make the
|
|
// interface lie about the one thing a user opened it to check, and would leave
|
|
// the next start fighting the copy that never went away.
|
|
func TestAServiceThatRefusesToStopIsReported(t *testing.T) {
|
|
service := start(t, nil)
|
|
|
|
clock := faketime.New(time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC))
|
|
stopDriver := drive(clock)
|
|
defer stopDriver()
|
|
|
|
// A stop script that does nothing at all, which is what a wedged procd or a
|
|
// daemon ignoring SIGTERM looks like from here.
|
|
runner := &recordingRunner{}
|
|
lifecycle := Lifecycle{Paths: service.paths, Runner: runner, Clock: clock}
|
|
|
|
_, err := lifecycle.Stop(t.Context())
|
|
if err == nil {
|
|
t.Fatal("a service that never stopped was reported as stopped")
|
|
}
|
|
if code := codeOf(t, err); code != domain.CodeServiceStopped {
|
|
t.Errorf("code = %s, want ServiceStopped", code)
|
|
}
|
|
if got := runner.count("stop"); got != 1 {
|
|
t.Errorf("stopped %d times, want exactly 1 -- retrying a stop that did "+
|
|
"nothing will not make it work", got)
|
|
}
|
|
|
|
// And the record still says running, because it is.
|
|
snapshot, err := ReadSnapshot(service.paths)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if snapshot.Service != ServiceRunning {
|
|
t.Errorf("service = %q, want it left alone", snapshot.Service)
|
|
}
|
|
}
|
|
|
|
// The stopped record is not written while the daemon still holds its lock.
|
|
//
|
|
// The lock is the proof that it has gone. Writing "stopped" over a service that
|
|
// is still running would make the interface lie about the one thing a user
|
|
// looks at it for.
|
|
func TestTheStoppedRecordIsRefusedWhileTheLockIsHeld(t *testing.T) {
|
|
paths := tempPaths(t)
|
|
if err := EnsureRuntimeDir(paths); err != nil {
|
|
t.Fatalf("runtime dir: %v", err)
|
|
}
|
|
if err := WriteSnapshot(paths, Snapshot{Service: ServiceRunning,
|
|
PID: 1234, Enabled: true}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
held, err := Acquire(paths.Lock())
|
|
if err != nil {
|
|
t.Fatalf("acquire: %v", err)
|
|
}
|
|
defer held.Release()
|
|
|
|
clock := faketime.New(time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC))
|
|
stopDriver := drive(clock)
|
|
defer stopDriver()
|
|
|
|
lifecycle := Lifecycle{Paths: paths, Runner: &recordingRunner{}, Clock: clock}
|
|
if err := lifecycle.recordStopped(); err == nil {
|
|
t.Fatal("the record was written while the lock was held")
|
|
} else if code := codeOf(t, err); code != domain.CodeConflict {
|
|
t.Errorf("code = %s, want Conflict", code)
|
|
}
|
|
|
|
snapshot, err := ReadSnapshot(paths)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if snapshot.Service != ServiceRunning {
|
|
t.Errorf("service = %q, want it left alone", snapshot.Service)
|
|
}
|
|
}
|