f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
1153 lines
42 KiB
Go
1153 lines
42 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/kit/daemon"
|
|
)
|
|
|
|
type testDaemonLaunchLock struct {
|
|
unlocked bool
|
|
}
|
|
|
|
func (l *testDaemonLaunchLock) Unlock() error {
|
|
l.unlocked = true
|
|
return nil
|
|
}
|
|
|
|
func daemonCommandTestDeps(t *testing.T) (*daemonCommandDeps, *bytes.Buffer) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
out := new(bytes.Buffer)
|
|
deps := defaultDaemonCommandDeps()
|
|
deps.resolveDataDir = func() (string, error) { return dir, nil }
|
|
deps.mkdirAll = func(path string, mode os.FileMode) error {
|
|
assert.Equal(t, dir, path)
|
|
return nil
|
|
}
|
|
deps.loadConfig = func() (config.Config, error) {
|
|
return config.Config{DataDir: dir, DBPath: filepath.Join(dir, "sessions.db")}, nil
|
|
}
|
|
deps.loadReadOnlyConfig = deps.loadConfig
|
|
deps.acquireLaunchLockWithError = func(string) (daemonLaunchLock, bool, error) {
|
|
return &testDaemonLaunchLock{}, true, nil
|
|
}
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) { return nil, nil }
|
|
deps.statusRecords = func(string, string) ([]daemon.RuntimeRecord, error) { return nil, nil }
|
|
deps.isStarting = func(string) bool { return false }
|
|
deps.readStartupState = func(string) *startupState { return nil }
|
|
deps.startBackground = func(
|
|
cfg config.Config, args []string, opts serveReplacementOptions,
|
|
policy backgroundLaunchPolicy,
|
|
) (backgroundLaunchResult, error) {
|
|
return backgroundLaunchResult{
|
|
Runtime: &DaemonRuntime{
|
|
Record: daemon.RuntimeRecord{PID: 314},
|
|
Host: "127.0.0.1", Port: 8080,
|
|
},
|
|
Started: true, LogPath: filepath.Join(dir, "serve.log"), childPID: 314,
|
|
}, nil
|
|
}
|
|
deps.waitContendedLaunch = func(string) daemonLaunchObservation {
|
|
return daemonLaunchObservation{LockHeld: true}
|
|
}
|
|
deps.stopTargetConfirmed = func(daemon.RuntimeRecord, string) bool { return true }
|
|
deps.stopProcess = func(daemon.RuntimeRecord, time.Duration) error { return nil }
|
|
deps.stopCaddy = func(io.Writer, daemon.RuntimeRecord) error { return nil }
|
|
deps.validateConfig = func(config.Config) error { return nil }
|
|
deps.checkDataVersion = func(string) error { return nil }
|
|
deps.probeRecord = func(rec daemon.RuntimeRecord, _ string) (daemon.PingInfo, bool) {
|
|
return daemon.PingInfo{PID: rec.PID}, true
|
|
}
|
|
deps.now = func() time.Time { return time.Unix(200, 0) }
|
|
return &deps, out
|
|
}
|
|
|
|
func executeDaemonCommand(
|
|
t *testing.T, deps daemonCommandDeps, out io.Writer, args ...string,
|
|
) error {
|
|
t.Helper()
|
|
cmd := newDaemonCommandWithDeps(deps)
|
|
cmd.SetOut(out)
|
|
cmd.SetErr(out)
|
|
cmd.SetArgs(args)
|
|
return cmd.Execute()
|
|
}
|
|
|
|
func executeServeCommand(
|
|
t *testing.T, deps daemonCommandDeps, out io.Writer, args ...string,
|
|
) error {
|
|
t.Helper()
|
|
cmd := newServeCommandWithDaemonDeps(deps)
|
|
cmd.SetOut(out)
|
|
cmd.SetErr(out)
|
|
cmd.SetArgs(args)
|
|
return cmd.Execute()
|
|
}
|
|
|
|
func testWritableRecord(pid int, path string) daemon.RuntimeRecord {
|
|
return daemon.RuntimeRecord{
|
|
PID: pid, Service: daemonService, Version: "1.2.3",
|
|
Network: daemon.NetworkTCP, Address: fmt.Sprintf("127.0.0.1:%d", 8000+pid),
|
|
SourcePath: path,
|
|
StartedAt: time.Unix(100, 0),
|
|
Metadata: map[string]string{
|
|
runtimeHost: "127.0.0.1", runtimePort: fmt.Sprint(8000 + pid),
|
|
runtimeAPIVersion: fmt.Sprint(daemonAPIVersion),
|
|
runtimeDataVersion: fmt.Sprint(db.CurrentDataVersion()),
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestDaemonCommandRegistrationAndSurface(t *testing.T) {
|
|
root := newRootCommand()
|
|
daemonCmd, _, err := root.Find([]string{"daemon"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "daemon", daemonCmd.Name())
|
|
assert.Equal(t, groupCore, daemonCmd.GroupID)
|
|
|
|
names := make(map[string]bool)
|
|
for _, cmd := range daemonCmd.Commands() {
|
|
names[cmd.Name()] = true
|
|
assert.Nil(t, cmd.Flags().Lookup("host"))
|
|
assert.Nil(t, cmd.Flags().Lookup("no-sync"))
|
|
}
|
|
assert.Equal(t, map[string]bool{
|
|
"restart": true, "start": true, "status": true, "stop": true,
|
|
}, names)
|
|
|
|
help, err := executeCommand(root, "--help")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, help, "daemon")
|
|
}
|
|
|
|
func TestDaemonCommandRejectsArgumentsAndServeFlags(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
for _, subcommand := range []string{"start", "status", "stop", "restart"} {
|
|
t.Run(subcommand+" arg", func(t *testing.T) {
|
|
out.Reset()
|
|
err := executeDaemonCommand(t, *deps, out, subcommand, "extra")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "unknown command")
|
|
})
|
|
for _, flag := range []string{"--host", "--no-sync"} {
|
|
t.Run(subcommand+" "+flag, func(t *testing.T) {
|
|
out.Reset()
|
|
err := executeDaemonCommand(t, *deps, out, subcommand, flag)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "unknown flag")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDaemonStartUsesConfigOnlyPolicyAndIsIdempotent(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
var gotArgs []string
|
|
var gotPolicy backgroundLaunchPolicy
|
|
deps.startBackground = func(
|
|
cfg config.Config, args []string, _ serveReplacementOptions,
|
|
policy backgroundLaunchPolicy,
|
|
) (backgroundLaunchResult, error) {
|
|
gotArgs = append([]string(nil), args...)
|
|
gotPolicy = policy
|
|
return backgroundLaunchResult{
|
|
Runtime: &DaemonRuntime{Record: daemon.RuntimeRecord{PID: 41}, Host: "127.0.0.1", Port: 9090},
|
|
}, nil
|
|
}
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "start"))
|
|
assert.Equal(t, []string{"serve"}, gotArgs)
|
|
assert.True(t, gotPolicy.ConfigOnly)
|
|
assert.Equal(t, "daemon start", gotPolicy.Operation)
|
|
assert.False(t, gotPolicy.Attached)
|
|
assert.Nil(t, gotPolicy.Context)
|
|
assert.Nil(t, gotPolicy.OnLaunch)
|
|
assert.Nil(t, gotPolicy.OnProgress)
|
|
assert.Contains(t, out.String(), "already running")
|
|
assert.Contains(t, out.String(), "pid 41")
|
|
}
|
|
|
|
func TestDaemonStopUsesStartupStateFallbackWhileStarting(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.isStarting = func(string) bool { return true }
|
|
deps.readStartupState = func(string) *startupState {
|
|
return &startupState{PID: 77, Phase: "starting HTTP server"}
|
|
}
|
|
var stopped daemon.RuntimeRecord
|
|
deps.writableRuntime = func(string, string) *DaemonRuntime {
|
|
return &DaemonRuntime{Record: testWritableRecord(77, ""), RuntimeFallback: true}
|
|
}
|
|
deps.stopProcess = func(rec daemon.RuntimeRecord, _ time.Duration) error {
|
|
stopped = rec
|
|
return nil
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, "stop")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 77, stopped.PID)
|
|
assert.Contains(t, out.String(), "Stopped agentsview (pid 77).")
|
|
assert.NotContains(t, out.String(), "starting up")
|
|
}
|
|
|
|
func TestDaemonStopUsesStartupStateFallbackWhenRuntimeStoreInspectionFails(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return nil, errors.New("store unavailable")
|
|
}
|
|
deps.writableRuntime = func(string, string) *DaemonRuntime {
|
|
return &DaemonRuntime{Record: testWritableRecord(76, ""), RuntimeFallback: true}
|
|
}
|
|
var stopped daemon.RuntimeRecord
|
|
deps.stopProcess = func(rec daemon.RuntimeRecord, _ time.Duration) error {
|
|
stopped = rec
|
|
return nil
|
|
}
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "stop"))
|
|
assert.Equal(t, 76, stopped.PID)
|
|
assert.Contains(t, out.String(), "Stopped agentsview (pid 76).")
|
|
}
|
|
|
|
func TestDaemonStopRegisteredRuntimePreservesStartupGuard(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.isStarting = func(string) bool { return true }
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{testWritableRecord(78, "runtime.json")}, nil
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, "stop")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "startup is still in progress")
|
|
}
|
|
|
|
func TestDaemonStartUsesStartupStateFallbackWhileStarting(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.isStarting = func(string) bool { return true }
|
|
deps.writableRuntime = func(string, string) *DaemonRuntime {
|
|
return &DaemonRuntime{
|
|
Record: testWritableRecord(79, ""),
|
|
Host: "127.0.0.1",
|
|
Port: 8079,
|
|
RuntimeFallback: true,
|
|
}
|
|
}
|
|
starts := 0
|
|
deps.startBackground = func(
|
|
config.Config, []string, serveReplacementOptions, backgroundLaunchPolicy,
|
|
) (backgroundLaunchResult, error) {
|
|
starts++
|
|
return backgroundLaunchResult{}, nil
|
|
}
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "start"))
|
|
assert.Zero(t, starts)
|
|
assert.Contains(t, out.String(), "already running")
|
|
assert.Contains(t, out.String(), "8079")
|
|
}
|
|
|
|
func TestDaemonStartPersistentStartupNeverLaunches(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.isStarting = func(string) bool { return true }
|
|
deps.readStartupState = func(string) *startupState {
|
|
return &startupState{PID: 77, StartedAt: time.Unix(100, 0), LogPath: "/tmp/serve.log", Phase: "sync"}
|
|
}
|
|
started := 0
|
|
deps.startBackground = func(config.Config, []string, serveReplacementOptions, backgroundLaunchPolicy) (backgroundLaunchResult, error) {
|
|
started++
|
|
return backgroundLaunchResult{}, nil
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, "start")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "startup is still in progress")
|
|
assert.ErrorContains(t, err, "pid 77")
|
|
assert.ErrorContains(t, err, "/tmp/serve.log")
|
|
assert.ErrorContains(t, err, "verify")
|
|
assert.Equal(t, 0, started)
|
|
}
|
|
|
|
func TestDaemonStartLateStartupLockDoesNotReportSuccess(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
starting := false
|
|
deps.isStarting = func(string) bool { return starting }
|
|
deps.readStartupState = func(string) *startupState {
|
|
return &startupState{PID: 78, LogPath: "/tmp/late-serve.log"}
|
|
}
|
|
deps.startBackground = func(config.Config, []string, serveReplacementOptions, backgroundLaunchPolicy) (backgroundLaunchResult, error) {
|
|
starting = true
|
|
return backgroundLaunchResult{}, nil
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, "start")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "startup is still in progress")
|
|
assert.ErrorContains(t, err, "pid 78")
|
|
assert.NotContains(t, out.String(), "pid 0")
|
|
}
|
|
|
|
func TestDaemonStartLaunchContentionTerminalStates(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
observation daemonLaunchObservation
|
|
wantErr string
|
|
wantOut string
|
|
}{
|
|
{
|
|
name: "published writable",
|
|
observation: daemonLaunchObservation{Records: []daemon.RuntimeRecord{
|
|
testWritableRecord(21, "/runtime/21.json"),
|
|
}},
|
|
wantOut: "already running",
|
|
},
|
|
{
|
|
name: "multiple published writables",
|
|
observation: daemonLaunchObservation{Records: []daemon.RuntimeRecord{
|
|
testWritableRecord(31, "/runtime/31.json"),
|
|
testWritableRecord(32, "/runtime/32.json"),
|
|
}},
|
|
wantErr: "multiple writable agentsview daemons",
|
|
},
|
|
{
|
|
name: "persistent startup snapshot",
|
|
observation: daemonLaunchObservation{LockHeld: true, Starting: true, Snapshot: &startupState{
|
|
PID: 22, StartedAt: time.Unix(100, 0), LogPath: "/tmp/serve.log", Phase: "opening database",
|
|
}},
|
|
wantErr: "startup is still in progress",
|
|
},
|
|
{
|
|
name: "persistent launch lock without snapshot",
|
|
observation: daemonLaunchObservation{LockHeld: true},
|
|
wantErr: "launch is still in progress",
|
|
},
|
|
{
|
|
name: "owner failed without publishing",
|
|
observation: daemonLaunchObservation{},
|
|
wantErr: "startup failed",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
loadCalls, startCalls := 0, 0
|
|
deps.acquireLaunchLockWithError = func(string) (daemonLaunchLock, bool, error) { return nil, false, nil }
|
|
deps.waitContendedLaunch = func(string) daemonLaunchObservation { return tt.observation }
|
|
deps.loadConfig = func() (config.Config, error) { loadCalls++; return config.Config{}, nil }
|
|
deps.startBackground = func(config.Config, []string, serveReplacementOptions, backgroundLaunchPolicy) (backgroundLaunchResult, error) {
|
|
startCalls++
|
|
return backgroundLaunchResult{}, nil
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, "start")
|
|
if tt.wantErr == "" {
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out.String(), tt.wantOut)
|
|
} else {
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, tt.wantErr)
|
|
}
|
|
assert.Equal(t, 0, loadCalls, "contender must not read or write config")
|
|
assert.Equal(t, 0, startCalls, "contender must never start a second writer")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDaemonStatusRendersStoppedStartingReadOnlyAndIncompatible(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(*daemonCommandDeps)
|
|
wanted []string
|
|
unwanted []string
|
|
}{
|
|
{name: "stopped", setup: func(*daemonCommandDeps) {}, wanted: []string{"No agentsview daemon is running"}},
|
|
{name: "starting", setup: func(d *daemonCommandDeps) {
|
|
d.isStarting = func(string) bool { return true }
|
|
d.readStartupState = func(string) *startupState {
|
|
return &startupState{PID: 9, StartedAt: time.Unix(100, 0), Phase: "sync", LogPath: "/tmp/log"}
|
|
}
|
|
}, wanted: []string{"starting", "pid: 9", "phase: sync", "/tmp/log"}},
|
|
{name: "read only", setup: func(d *daemonCommandDeps) {
|
|
d.statusRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
rec := testWritableRecord(10, "/runtime/10.json")
|
|
rec.Metadata[runtimeReadOnly] = "true"
|
|
return []daemon.RuntimeRecord{rec}, nil
|
|
}
|
|
}, wanted: []string{"No agentsview daemon is running."}, unwanted: []string{"running at", "mode: read-only"}},
|
|
{name: "incompatible", setup: func(d *daemonCommandDeps) {
|
|
d.statusRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
rec := testWritableRecord(11, "/runtime/11.json")
|
|
rec.Metadata[runtimeAPIVersion] = "0"
|
|
return []daemon.RuntimeRecord{rec}, nil
|
|
}
|
|
d.probeRecord = func(rec daemon.RuntimeRecord, _ string) (daemon.PingInfo, bool) {
|
|
return daemon.PingInfo{PID: rec.PID}, true
|
|
}
|
|
}, wanted: []string{"incompatible", "pid: 11", "API version"}},
|
|
{name: "running", setup: func(d *daemonCommandDeps) {
|
|
d.statusRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{testWritableRecord(12, "/runtime/12.json")}, nil
|
|
}
|
|
d.probeRecord = func(rec daemon.RuntimeRecord, _ string) (daemon.PingInfo, bool) {
|
|
return daemon.PingInfo{PID: rec.PID}, true
|
|
}
|
|
}, wanted: []string{"running at http://127.0.0.1:8012", "pid: 12", "version: 1.2.3", "uptime:"}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
tt.setup(deps)
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "status"))
|
|
for _, wanted := range tt.wanted {
|
|
assert.Contains(t, out.String(), wanted)
|
|
}
|
|
for _, unwanted := range tt.unwanted {
|
|
assert.NotContains(t, out.String(), unwanted)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDaemonStartContentionStartLockWithoutSnapshotUsesRecoveryError(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.acquireLaunchLockWithError = func(string) (daemonLaunchLock, bool, error) { return nil, false, nil }
|
|
deps.waitContendedLaunch = func(string) daemonLaunchObservation {
|
|
return daemonLaunchObservation{LockHeld: true, Starting: true}
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, "start")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "startup is still in progress")
|
|
assert.ErrorContains(t, err, "runtime publication may have failed")
|
|
assert.ErrorContains(t, err, startupStateFileName)
|
|
assert.NotContains(t, err.Error(), "launch is still in progress")
|
|
}
|
|
|
|
func TestDaemonStartContentionHeldStartLockWinsOverPreexistingRecord(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.acquireLaunchLockWithError = func(string) (daemonLaunchLock, bool, error) { return nil, false, nil }
|
|
deps.waitContendedLaunch = func(string) daemonLaunchObservation {
|
|
return daemonLaunchObservation{
|
|
LockHeld: true,
|
|
Starting: true,
|
|
Snapshot: &startupState{PID: 140, LogPath: "/tmp/serve.log"},
|
|
Records: []daemon.RuntimeRecord{
|
|
testWritableRecord(141, "/runtime/141.json"),
|
|
},
|
|
}
|
|
}
|
|
configLoads, starts := 0, 0
|
|
deps.loadConfig = func() (config.Config, error) {
|
|
configLoads++
|
|
return config.Config{}, nil
|
|
}
|
|
deps.startBackground = func(config.Config, []string, serveReplacementOptions, backgroundLaunchPolicy) (backgroundLaunchResult, error) {
|
|
starts++
|
|
return backgroundLaunchResult{}, nil
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, "start")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "startup is still in progress")
|
|
assert.ErrorContains(t, err, "pid 140")
|
|
assert.NotContains(t, out.String(), "already running")
|
|
assert.Zero(t, configLoads)
|
|
assert.Zero(t, starts)
|
|
}
|
|
|
|
func TestDaemonStatusListsEveryWriterAndSurfacesInspectionErrors(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.statusRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{
|
|
testWritableRecord(31, "/runtime/31.json"),
|
|
testWritableRecord(32, "/runtime/32.json"),
|
|
}, nil
|
|
}
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "status"))
|
|
assert.Contains(t, out.String(), "single-writer invariant")
|
|
assert.Contains(t, out.String(), "pid: 31")
|
|
assert.Contains(t, out.String(), "pid: 32")
|
|
|
|
out.Reset()
|
|
deps.statusRecords = func(string, string) ([]daemon.RuntimeRecord, error) { return nil, errors.New("store unavailable") }
|
|
err := executeDaemonCommand(t, *deps, out, "status")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "store unavailable")
|
|
|
|
deps.loadReadOnlyConfig = func() (config.Config, error) { return config.Config{}, errors.New("bad config") }
|
|
err = executeDaemonCommand(t, *deps, out, "status")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "bad config")
|
|
}
|
|
|
|
func TestDaemonStatusUsesStartupStateFallbackWhenRuntimeStoreInspectionFails(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.statusRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return nil, errors.New("store unavailable")
|
|
}
|
|
deps.writableRuntime = func(string, string) *DaemonRuntime {
|
|
return &DaemonRuntime{
|
|
Record: testWritableRecord(54, ""),
|
|
Host: "127.0.0.1",
|
|
Port: 8054,
|
|
RuntimeFallback: true,
|
|
}
|
|
}
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "status"))
|
|
assert.Contains(t, out.String(), "pid: 54")
|
|
assert.Contains(t, out.String(), "runtime record unwritten")
|
|
}
|
|
|
|
func TestDaemonStatusNotRespondingIsUseful(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.statusRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{testWritableRecord(55, "/runtime/55.json")}, nil
|
|
}
|
|
deps.probeRecord = func(daemon.RuntimeRecord, string) (daemon.PingInfo, bool) {
|
|
return daemon.PingInfo{}, false
|
|
}
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "status"))
|
|
assert.Contains(t, out.String(), "not responding")
|
|
assert.Contains(t, out.String(), "pid: 55")
|
|
assert.Contains(t, out.String(), "/runtime/55.json")
|
|
}
|
|
|
|
func TestDaemonStatusUsesPingVersionAsAuthoritative(t *testing.T) {
|
|
endpoint := newPingDaemon(t)
|
|
for _, tt := range []struct {
|
|
name string
|
|
recordVersion string
|
|
}{
|
|
{name: "missing runtime version"},
|
|
{name: "stale runtime version", recordVersion: "stale-record-version"},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rec := testWritableRecord(os.Getpid(), "")
|
|
rec.Version = tt.recordVersion
|
|
rec.Address = endpoint.Addr
|
|
rec.Metadata[runtimeHost] = endpoint.Host
|
|
rec.Metadata[runtimePort] = strconv.Itoa(endpoint.Port)
|
|
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.statusRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{rec}, nil
|
|
}
|
|
deps.probeRecord = probeDaemonRecord
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "status"))
|
|
assert.Contains(t, out.String(), "version: test")
|
|
assert.NotContains(t, out.String(), "stale-record-version")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDaemonStatusDiscoversAuthenticatedLegacyDaemon(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
const token = "status-secret"
|
|
endpoint, legacyPath := writeAuthenticatedProbeableLegacyRuntime(
|
|
t, dir, token, legacyStateFile{Version: version},
|
|
)
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.loadReadOnlyConfig = func() (config.Config, error) {
|
|
return config.Config{DataDir: dir, AuthToken: token}, nil
|
|
}
|
|
deps.statusRecords = daemonStatusRecords
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "status"))
|
|
assert.Contains(t, out.String(), "running at")
|
|
assert.Contains(t, out.String(), fmt.Sprintf("pid: %d", os.Getpid()))
|
|
assert.Contains(t, out.String(), fmt.Sprintf(":%d", endpoint.Port))
|
|
assertPathRemoved(t, legacyPath, "authenticated legacy state should migrate")
|
|
}
|
|
|
|
func TestDaemonStopDiscoversAuthenticatedLegacyDaemon(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
const token = "stop-secret"
|
|
_, legacyPath := writeAuthenticatedProbeableLegacyRuntime(
|
|
t, dir, token, legacyStateFile{Version: version},
|
|
)
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.resolveDataDir = func() (string, error) { return dir, nil }
|
|
deps.mkdirAll = func(path string, _ os.FileMode) error {
|
|
assert.Equal(t, dir, path)
|
|
return nil
|
|
}
|
|
deps.loadConfig = func() (config.Config, error) {
|
|
return config.Config{DataDir: dir, AuthToken: token}, nil
|
|
}
|
|
deps.writableRecords = writableDaemonRecords
|
|
var stopped []int
|
|
deps.stopProcess = func(rec daemon.RuntimeRecord, _ time.Duration) error {
|
|
stopped = append(stopped, rec.PID)
|
|
return nil
|
|
}
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "stop"))
|
|
assert.Equal(t, []int{os.Getpid()}, stopped)
|
|
assert.Contains(t, out.String(), fmt.Sprintf("pid %d", os.Getpid()))
|
|
assertPathRemoved(t, legacyPath, "authenticated legacy state should migrate")
|
|
}
|
|
|
|
func TestDaemonStopPrevalidatesEveryWriterBeforeSignalling(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
records := []daemon.RuntimeRecord{
|
|
testWritableRecord(61, "/runtime/61.json"),
|
|
testWritableRecord(62, "/runtime/62.json"),
|
|
}
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) { return records, nil }
|
|
var prevalidated []int
|
|
deps.stopTargetConfirmed = func(rec daemon.RuntimeRecord, _ string) bool {
|
|
prevalidated = append(prevalidated, rec.PID)
|
|
return rec.PID != 61
|
|
}
|
|
var signalled []int
|
|
deps.stopProcess = func(rec daemon.RuntimeRecord, _ time.Duration) error {
|
|
signalled = append(signalled, rec.PID)
|
|
return nil
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, "stop")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "pid 61")
|
|
assert.ErrorContains(t, err, "/runtime/61.json")
|
|
assert.ErrorContains(t, err, "verify")
|
|
assert.Equal(t, []int{61, 62}, prevalidated, "every target must be checked before stop can return")
|
|
assert.Empty(t, signalled)
|
|
}
|
|
|
|
func TestDaemonStopStartingConfigFailureAndContentionSignalNobody(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(*daemonCommandDeps)
|
|
want string
|
|
}{
|
|
{name: "launch contention", setup: func(d *daemonCommandDeps) {
|
|
d.acquireLaunchLockWithError = func(string) (daemonLaunchLock, bool, error) { return nil, false, nil }
|
|
}, want: "launch lock"},
|
|
{name: "config failure", setup: func(d *daemonCommandDeps) {
|
|
d.loadConfig = func() (config.Config, error) { return config.Config{}, errors.New("bad config") }
|
|
}, want: "bad config"},
|
|
{name: "start lock with confirmed record", setup: func(d *daemonCommandDeps) {
|
|
d.isStarting = func(string) bool { return true }
|
|
d.readStartupState = func(string) *startupState { return &startupState{PID: 71, LogPath: "/tmp/serve.log"} }
|
|
d.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{testWritableRecord(72, "/runtime/72.json")}, nil
|
|
}
|
|
}, want: "startup is still in progress"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
tt.setup(deps)
|
|
signals := 0
|
|
deps.stopProcess = func(daemon.RuntimeRecord, time.Duration) error { signals++; return nil }
|
|
err := executeDaemonCommand(t, *deps, out, "stop")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, tt.want)
|
|
assert.Zero(t, signals)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDaemonStopMultipleWritersAndRepeatIsIdempotent(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
records := []daemon.RuntimeRecord{
|
|
testWritableRecord(81, "/runtime/81.json"),
|
|
testWritableRecord(82, "/runtime/82.json"),
|
|
}
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) { return records, nil }
|
|
var stopped, caddy []int
|
|
deps.stopProcess = func(rec daemon.RuntimeRecord, _ time.Duration) error { stopped = append(stopped, rec.PID); return nil }
|
|
deps.stopCaddy = func(_ io.Writer, rec daemon.RuntimeRecord) error {
|
|
caddy = append(caddy, rec.PID)
|
|
return nil
|
|
}
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "stop"))
|
|
assert.Equal(t, []int{81, 82}, stopped)
|
|
assert.Equal(t, []int{81, 82}, caddy)
|
|
assert.Contains(t, out.String(), "pid 81")
|
|
assert.Contains(t, out.String(), "pid 82")
|
|
|
|
records = nil
|
|
out.Reset()
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "stop"))
|
|
assert.Contains(t, out.String(), "not running")
|
|
}
|
|
|
|
func TestDaemonRestartValidatesBeforeStoppingAndUsesFreshConfig(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
dataDir := t.TempDir()
|
|
deps.resolveDataDir = func() (string, error) { return dataDir, nil }
|
|
deps.mkdirAll = func(path string, _ os.FileMode) error {
|
|
assert.Equal(t, dataDir, path)
|
|
return nil
|
|
}
|
|
rec := testWritableRecord(91, "/runtime/91.json")
|
|
rec.Metadata[runtimeNoSync] = "true"
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) { return []daemon.RuntimeRecord{rec}, nil }
|
|
var order []string
|
|
deps.loadConfig = func() (config.Config, error) {
|
|
order = append(order, "config")
|
|
return config.Config{DataDir: dataDir, DBPath: "/fresh/sessions.db", NoSync: true}, nil
|
|
}
|
|
deps.checkDataVersion = func(path string) error {
|
|
order = append(order, "data")
|
|
assert.Equal(t, "/fresh/sessions.db", path)
|
|
return nil
|
|
}
|
|
deps.stopProcess = func(daemon.RuntimeRecord, time.Duration) error { order = append(order, "stop"); return nil }
|
|
deps.startBackground = func(cfg config.Config, args []string, _ serveReplacementOptions, policy backgroundLaunchPolicy) (backgroundLaunchResult, error) {
|
|
order = append(order, "start")
|
|
assert.True(t, cfg.NoSync, "loader result reaches config-only lower layer, which clears runtime NoSync")
|
|
assert.Equal(t, []string{"serve"}, args)
|
|
assert.True(t, policy.ConfigOnly)
|
|
assert.Equal(t, "daemon restart", policy.Operation)
|
|
return backgroundLaunchResult{Runtime: &DaemonRuntime{Record: daemon.RuntimeRecord{PID: 92}, Host: "127.0.0.1", Port: 8080}, Started: true, LogPath: "/fresh/serve.log"}, nil
|
|
}
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "restart"))
|
|
assert.Equal(t, []string{"config", "data", "stop", "start"}, order)
|
|
assert.Contains(t, out.String(), "restarted")
|
|
}
|
|
|
|
func TestDaemonRestartStreamsProgressUntilReady(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{testWritableRecord(91, "/runtime/91.json")}, nil
|
|
}
|
|
deps.startBackground = func(
|
|
_ config.Config, _ []string, _ serveReplacementOptions,
|
|
policy backgroundLaunchPolicy,
|
|
) (backgroundLaunchResult, error) {
|
|
assert.True(t, policy.Attached)
|
|
require.NotNil(t, policy.Context)
|
|
require.NotNil(t, policy.OnLaunch)
|
|
require.NotNil(t, policy.OnProgress)
|
|
|
|
policy.OnLaunch(92, "/data/serve.log")
|
|
policy.OnProgress(&startupState{Phase: "opening database"}, 2*time.Second)
|
|
policy.OnProgress(&startupState{Phase: "opening database"}, 4*time.Second)
|
|
policy.OnProgress(&startupState{
|
|
Phase: "initial sync", Detail: "claude: 120/450 sessions (27%)",
|
|
}, 6*time.Second)
|
|
policy.OnProgress(&startupState{
|
|
Phase: "initial sync", Detail: "claude: 120/450 sessions (27%)",
|
|
}, 8*time.Second)
|
|
policy.OnProgress(&startupState{
|
|
Phase: "initial sync", Detail: "claude: 120/450 sessions (27%)",
|
|
}, 11*time.Second)
|
|
policy.OnProgress(nil, 12*time.Second)
|
|
policy.OnProgress(&startupState{Detail: "incomplete snapshot"}, 13*time.Second)
|
|
policy.OnProgress(&startupState{Phase: "starting HTTP server"}, 18*time.Second)
|
|
return backgroundLaunchResult{
|
|
Runtime: &DaemonRuntime{
|
|
Record: daemon.RuntimeRecord{PID: 92},
|
|
Host: "127.0.0.1", Port: 8080,
|
|
},
|
|
Started: true, childPID: 92, LogPath: "/data/serve.log",
|
|
}, nil
|
|
}
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "restart"))
|
|
assert.Equal(t, "Stopped agentsview (pid 91).\n"+
|
|
"Starting agentsview (pid 92)...\n"+
|
|
" log: /data/serve.log\n"+
|
|
" opening database (2s)\n"+
|
|
" initial sync: claude: 120/450 sessions (27%) (6s)\n"+
|
|
" initial sync: claude: 120/450 sessions (27%) (11s)\n"+
|
|
" starting HTTP server (18s)\n"+
|
|
"agentsview restarted at http://127.0.0.1:8080 (pid 92)\n", out.String())
|
|
}
|
|
|
|
func TestDaemonRestartProgressHeartbeatUsesUnroundedElapsed(t *testing.T) {
|
|
var out bytes.Buffer
|
|
progress := daemonRestartProgressWriter{w: &out}
|
|
startedAt := time.Unix(100, 0)
|
|
state := &startupState{StartedAt: startedAt, Phase: "initial sync"}
|
|
|
|
progress.progress(state, startupSnapshotElapsed(
|
|
state, startedAt, startedAt.Add(1400*time.Millisecond),
|
|
))
|
|
progress.progress(state, startupSnapshotElapsed(
|
|
state, startedAt, startedAt.Add(5600*time.Millisecond),
|
|
))
|
|
assert.Equal(t, " initial sync (1s)\n", out.String(),
|
|
"4.2 seconds of unchanged progress must not emit a heartbeat")
|
|
progress.progress(state, startupSnapshotElapsed(
|
|
state, startedAt, startedAt.Add(6400*time.Millisecond),
|
|
))
|
|
|
|
assert.Equal(t,
|
|
" initial sync (1s)\n"+
|
|
" initial sync (6s)\n",
|
|
out.String(),
|
|
)
|
|
}
|
|
|
|
func TestDaemonRestartCancellationLeavesReplacementRunning(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{testWritableRecord(91, "/runtime/91.json")}, nil
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var stopped []int
|
|
deps.stopProcess = func(rec daemon.RuntimeRecord, _ time.Duration) error {
|
|
stopped = append(stopped, rec.PID)
|
|
return nil
|
|
}
|
|
deps.startBackground = func(
|
|
_ config.Config, _ []string, _ serveReplacementOptions,
|
|
policy backgroundLaunchPolicy,
|
|
) (backgroundLaunchResult, error) {
|
|
policy.OnLaunch(92, "/data/serve.log")
|
|
cancel()
|
|
return backgroundLaunchResult{
|
|
Started: true, childPID: 92, LogPath: "/data/serve.log",
|
|
}, context.Canceled
|
|
}
|
|
|
|
cmd := newDaemonCommandWithDeps(*deps)
|
|
cmd.SetContext(ctx)
|
|
cmd.SetOut(out)
|
|
cmd.SetErr(out)
|
|
cmd.SetArgs([]string{"restart"})
|
|
err := cmd.Execute()
|
|
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "pid 92")
|
|
assert.ErrorContains(t, err, "/data/serve.log")
|
|
assert.ErrorContains(t, err, "child continues running")
|
|
assert.ErrorContains(t, err, "agentsview daemon status")
|
|
assert.Equal(t, []int{91}, stopped,
|
|
"cancellation must not stop the replacement child")
|
|
assert.Contains(t, out.String(), "Starting agentsview (pid 92)...")
|
|
}
|
|
|
|
func TestDaemonRestartStoppedStartsAndReadOnlySurvives(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
dir := runtimeTestDir(t)
|
|
rec := testWritableRecord(os.Getpid(), "")
|
|
rec.Metadata[runtimeReadOnly] = "true"
|
|
path, err := writeRuntimeRecordForTest(dir, rec)
|
|
require.NoError(t, err)
|
|
deps.resolveDataDir = func() (string, error) { return dir, nil }
|
|
deps.mkdirAll = func(path string, mode os.FileMode) error {
|
|
assert.Equal(t, dir, path)
|
|
return nil
|
|
}
|
|
deps.loadConfig = func() (config.Config, error) {
|
|
return config.Config{DataDir: dir, DBPath: filepath.Join(dir, "sessions.db")}, nil
|
|
}
|
|
deps.writableRecords = writableDaemonRecords
|
|
stopCalls := 0
|
|
deps.stopProcess = func(daemon.RuntimeRecord, time.Duration) error { stopCalls++; return nil }
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "restart"))
|
|
assert.Zero(t, stopCalls)
|
|
assert.FileExists(t, path, "restart must preserve the read-only runtime")
|
|
assert.Contains(t, out.String(), "started (was not running)")
|
|
}
|
|
|
|
func TestServeRestartDelegatesToCanonicalWriterOnlyRestart(t *testing.T) {
|
|
requirePOSIXSignals(t, "requires POSIX sleep/process signals")
|
|
deps, out := daemonCommandTestDeps(t)
|
|
dir := runtimeTestDir(t)
|
|
pid, _ := startReapedSleepProcess(t)
|
|
createTime, ok := processCreateTimeMillis(pid)
|
|
require.True(t, ok, "read-only child create time must be available")
|
|
rec := testWritableRecord(pid, "")
|
|
rec.Metadata[runtimeReadOnly] = "true"
|
|
rec.Metadata[runtimeCreateTime] = strconv.FormatInt(createTime, 10)
|
|
path, err := writeRuntimeRecordForTest(dir, rec)
|
|
require.NoError(t, err)
|
|
deps.resolveDataDir = func() (string, error) { return dir, nil }
|
|
deps.mkdirAll = func(path string, _ os.FileMode) error {
|
|
assert.Equal(t, dir, path)
|
|
return nil
|
|
}
|
|
deps.loadConfig = func() (config.Config, error) {
|
|
return config.Config{DataDir: dir, DBPath: filepath.Join(dir, "sessions.db")}, nil
|
|
}
|
|
deps.writableRecords = writableDaemonRecords
|
|
stopCalls := 0
|
|
deps.stopProcess = func(daemon.RuntimeRecord, time.Duration) error {
|
|
stopCalls++
|
|
return nil
|
|
}
|
|
var gotPolicy backgroundLaunchPolicy
|
|
deps.startBackground = func(
|
|
_ config.Config, args []string, _ serveReplacementOptions,
|
|
policy backgroundLaunchPolicy,
|
|
) (backgroundLaunchResult, error) {
|
|
assert.Equal(t, []string{"serve"}, args)
|
|
gotPolicy = policy
|
|
return backgroundLaunchResult{
|
|
Runtime: &DaemonRuntime{
|
|
Record: daemon.RuntimeRecord{PID: 315},
|
|
Host: "127.0.0.1", Port: 8080,
|
|
},
|
|
Started: true,
|
|
}, nil
|
|
}
|
|
|
|
require.NoError(t, executeServeCommand(t, *deps, out, "restart"))
|
|
assert.Zero(t, stopCalls, "read-only servers must not be stopped")
|
|
assert.True(t, daemon.ProcessAlive(pid), "read-only server must remain alive")
|
|
assert.FileExists(t, path, "read-only runtime record must survive restart")
|
|
assert.True(t, gotPolicy.ConfigOnly)
|
|
assert.Equal(t, "daemon restart", gotPolicy.Operation)
|
|
assert.False(t, gotPolicy.Attached)
|
|
assert.Nil(t, gotPolicy.Context)
|
|
assert.Nil(t, gotPolicy.OnLaunch)
|
|
assert.Nil(t, gotPolicy.OnProgress)
|
|
assert.Contains(t, out.String(), "started (was not running)")
|
|
}
|
|
|
|
func TestServeRestartRejectsServeFlagsAndArguments(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{name: "host", args: []string{"restart", "--host", "0.0.0.0"}},
|
|
{name: "no sync", args: []string{"restart", "--no-sync"}},
|
|
{name: "host before subcommand", args: []string{"--host", "0.0.0.0", "restart"}},
|
|
{name: "no sync before subcommand", args: []string{"--no-sync", "restart"}},
|
|
{name: "argument", args: []string{"restart", "extra"}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
starts := 0
|
|
deps.startBackground = func(
|
|
config.Config, []string, serveReplacementOptions,
|
|
backgroundLaunchPolicy,
|
|
) (backgroundLaunchResult, error) {
|
|
starts++
|
|
return backgroundLaunchResult{}, nil
|
|
}
|
|
|
|
err := executeServeCommand(t, *deps, out, tt.args...)
|
|
require.Error(t, err)
|
|
assert.Zero(t, starts)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDaemonRestartFailurePathsSignalNobody(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(*daemonCommandDeps)
|
|
want string
|
|
}{
|
|
{name: "data version", setup: func(d *daemonCommandDeps) {
|
|
d.checkDataVersion = func(string) error { return errors.New("data too new") }
|
|
}, want: "data too new"},
|
|
{name: "persistent start", setup: func(d *daemonCommandDeps) {
|
|
d.isStarting = func(string) bool { return true }
|
|
d.readStartupState = func(string) *startupState { return &startupState{PID: 111, LogPath: "/tmp/log"} }
|
|
}, want: "startup is still in progress"},
|
|
{name: "unconfirmed", setup: func(d *daemonCommandDeps) {
|
|
d.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{testWritableRecord(112, "/runtime/112.json")}, nil
|
|
}
|
|
d.stopTargetConfirmed = func(daemon.RuntimeRecord, string) bool { return false }
|
|
}, want: "pid 112"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
tt.setup(deps)
|
|
signals := 0
|
|
deps.stopProcess = func(daemon.RuntimeRecord, time.Duration) error { signals++; return nil }
|
|
err := executeDaemonCommand(t, *deps, out, "restart")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, tt.want)
|
|
assert.Zero(t, signals)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDaemonRestartUsesStartupStateFallbackWhileStarting(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.isStarting = func(string) bool { return true }
|
|
deps.writableRuntime = func(string, string) *DaemonRuntime {
|
|
return &DaemonRuntime{
|
|
Record: testWritableRecord(113, ""),
|
|
Host: "127.0.0.1",
|
|
Port: 8113,
|
|
RuntimeFallback: true,
|
|
}
|
|
}
|
|
var stopped daemon.RuntimeRecord
|
|
deps.stopProcess = func(rec daemon.RuntimeRecord, _ time.Duration) error {
|
|
stopped = rec
|
|
return nil
|
|
}
|
|
deps.startBackground = func(
|
|
_ config.Config, args []string, _ serveReplacementOptions,
|
|
policy backgroundLaunchPolicy,
|
|
) (backgroundLaunchResult, error) {
|
|
assert.Equal(t, []string{"serve"}, args)
|
|
assert.Equal(t, "daemon restart", policy.Operation)
|
|
return backgroundLaunchResult{
|
|
Runtime: &DaemonRuntime{
|
|
Record: daemon.RuntimeRecord{PID: 315},
|
|
Host: "127.0.0.1", Port: 8080,
|
|
},
|
|
Started: true,
|
|
}, nil
|
|
}
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "restart"))
|
|
assert.Equal(t, 113, stopped.PID)
|
|
assert.Contains(t, out.String(), "restarted")
|
|
assert.NotContains(t, out.String(), "startup is still in progress")
|
|
}
|
|
|
|
func TestDaemonRestartUsesStartupStateFallbackWhenRuntimeStoreInspectionFails(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return nil, errors.New("store unavailable")
|
|
}
|
|
deps.writableRuntime = func(string, string) *DaemonRuntime {
|
|
return &DaemonRuntime{Record: testWritableRecord(114, ""), RuntimeFallback: true}
|
|
}
|
|
var stopped daemon.RuntimeRecord
|
|
deps.stopProcess = func(rec daemon.RuntimeRecord, _ time.Duration) error {
|
|
stopped = rec
|
|
return nil
|
|
}
|
|
|
|
require.NoError(t, executeDaemonCommand(t, *deps, out, "restart"))
|
|
assert.Equal(t, 114, stopped.PID)
|
|
assert.Contains(t, out.String(), "restarted")
|
|
}
|
|
|
|
func TestDaemonMutationsPreserveRuntimeStoreInspectionErrorWithoutFallback(t *testing.T) {
|
|
for _, command := range []string{"stop", "restart"} {
|
|
t.Run(command, func(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return nil, errors.New("store unavailable")
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, command)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "store unavailable")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDaemonRestartInvalidServeConfigDoesNotStopOrStart(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg config.Config
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "non-loopback host without auth",
|
|
cfg: config.Config{Host: "0.0.0.0", Port: 8080},
|
|
wantErr: "require_auth",
|
|
},
|
|
{
|
|
name: "unsupported proxy",
|
|
cfg: config.Config{Host: "127.0.0.1", Port: 8080, Proxy: config.ProxyConfig{Mode: "nginx"}},
|
|
wantErr: "unsupported proxy mode",
|
|
},
|
|
{
|
|
name: "https proxy without TLS files",
|
|
cfg: config.Config{
|
|
Host: "127.0.0.1", Port: 8080,
|
|
PublicURL: "https://viewer.example.test",
|
|
Proxy: config.ProxyConfig{Mode: "caddy", Bin: os.Args[0]},
|
|
},
|
|
wantErr: "requires both tls_cert and tls_key",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
tt.cfg.DataDir = t.TempDir()
|
|
tt.cfg.DBPath = filepath.Join(tt.cfg.DataDir, "sessions.db")
|
|
deps.resolveDataDir = func() (string, error) { return tt.cfg.DataDir, nil }
|
|
deps.mkdirAll = func(path string, _ os.FileMode) error {
|
|
assert.Equal(t, tt.cfg.DataDir, path)
|
|
return nil
|
|
}
|
|
deps.loadConfig = func() (config.Config, error) { return tt.cfg, nil }
|
|
deps.validateConfig = validateServeConfig
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{testWritableRecord(121, "/runtime/121.json")}, nil
|
|
}
|
|
dataChecks, signals, starts := 0, 0, 0
|
|
deps.checkDataVersion = func(string) error { dataChecks++; return nil }
|
|
deps.stopProcess = func(daemon.RuntimeRecord, time.Duration) error { signals++; return nil }
|
|
deps.startBackground = func(config.Config, []string, serveReplacementOptions, backgroundLaunchPolicy) (backgroundLaunchResult, error) {
|
|
starts++
|
|
return backgroundLaunchResult{}, nil
|
|
}
|
|
|
|
err := executeDaemonCommand(t, *deps, out, "restart")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "daemon restart: invalid config")
|
|
assert.ErrorContains(t, err, tt.wantErr)
|
|
assert.Zero(t, dataChecks)
|
|
assert.Zero(t, signals)
|
|
assert.Zero(t, starts)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDaemonRestartChildFailureIncludesLogAndHeldLock(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
lock := &testDaemonLaunchLock{}
|
|
lockAcquisitions := 0
|
|
deps.acquireLaunchLockWithError = func(string) (daemonLaunchLock, bool, error) {
|
|
lockAcquisitions++
|
|
return lock, true, nil
|
|
}
|
|
deps.writableRecords = func(string, string) ([]daemon.RuntimeRecord, error) {
|
|
return []daemon.RuntimeRecord{testWritableRecord(131, "/runtime/131.json")}, nil
|
|
}
|
|
var order []string
|
|
deps.stopProcess = func(rec daemon.RuntimeRecord, _ time.Duration) error {
|
|
assert.Equal(t, 131, rec.PID)
|
|
assert.False(t, lock.unlocked, "restart must hold the launch lock while stopping")
|
|
order = append(order, "stop")
|
|
return nil
|
|
}
|
|
deps.startBackground = func(config.Config, []string, serveReplacementOptions, backgroundLaunchPolicy) (backgroundLaunchResult, error) {
|
|
assert.False(t, lock.unlocked, "restart must retain launch lock across stop/start gap")
|
|
order = append(order, "start")
|
|
return backgroundLaunchResult{LogPath: "/tmp/serve.log"}, errors.New("child exited")
|
|
}
|
|
err := executeDaemonCommand(t, *deps, out, "restart")
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "child exited")
|
|
assert.ErrorContains(t, err, "/tmp/serve.log")
|
|
assert.Equal(t, []string{"stop", "start"}, order)
|
|
assert.Equal(t, 1, lockAcquisitions)
|
|
assert.True(t, lock.unlocked)
|
|
}
|
|
|
|
func TestDaemonStartConfigFailureReturnsError(t *testing.T) {
|
|
deps, out := daemonCommandTestDeps(t)
|
|
deps.loadConfig = func() (config.Config, error) { return config.Config{}, errors.New("config invalid") }
|
|
err := executeDaemonCommand(t, *deps, out, "start")
|
|
require.Error(t, err)
|
|
assert.True(t, strings.Contains(err.Error(), "config invalid"))
|
|
}
|