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
836 lines
27 KiB
Go
836 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/kit/daemon"
|
|
)
|
|
|
|
func TestLiveDaemonRecordsFiltersDeadProcesses(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
|
|
// WriteDaemonRuntime stamps the record with this live test process PID.
|
|
host, port := testPingServer(t)
|
|
_, err := WriteDaemonRuntime(dir, host, port, "1.0.0", false)
|
|
require.NoError(t, err)
|
|
|
|
// A record for a dead process must be excluded.
|
|
dead := deadPID(t)
|
|
_, err = writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: dead,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
records := liveDaemonRecords(dir)
|
|
require.Len(t, records, 1)
|
|
assert.NotEqual(t, dead, records[0].PID)
|
|
assert.NotEmpty(t, records[0].SourcePath,
|
|
"List must populate SourcePath so stop can clean up the record")
|
|
}
|
|
|
|
func TestServeStatusLinesWritable(t *testing.T) {
|
|
rt := &DaemonRuntime{
|
|
Record: daemon.RuntimeRecord{
|
|
PID: 4242,
|
|
Version: "9.9.9",
|
|
StartedAt: time.Now().Add(-90 * time.Second),
|
|
},
|
|
Host: "127.0.0.1",
|
|
Port: 8080,
|
|
}
|
|
|
|
out := strings.Join(serveStatusLines(rt), "\n")
|
|
assert.Contains(t, out, "running at http://127.0.0.1:8080")
|
|
assert.Contains(t, out, "pid: 4242")
|
|
assert.Contains(t, out, "version: 9.9.9")
|
|
assert.Contains(t, out, "uptime:")
|
|
assert.NotContains(t, out, "read-only")
|
|
}
|
|
|
|
func TestServeStatusLinesReadOnly(t *testing.T) {
|
|
rt := &DaemonRuntime{
|
|
Record: daemon.RuntimeRecord{PID: 7},
|
|
Host: "127.0.0.1",
|
|
Port: 9000,
|
|
ReadOnly: true,
|
|
}
|
|
|
|
out := strings.Join(serveStatusLines(rt), "\n")
|
|
assert.Contains(t, out, "mode: read-only")
|
|
assert.NotContains(t, out, "uptime:", "zero StartedAt must omit uptime")
|
|
}
|
|
|
|
func TestRunServeStatusReportsIncompatibleWritableDaemon(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
writeRuntimeRecordFixture(t, dir, daemonRuntimeRecord(
|
|
host, port,
|
|
withRuntimeVersion("1.0.0"),
|
|
withRuntimeAPIVersion(0),
|
|
))
|
|
|
|
out := captureStdout(t, func() {
|
|
runServeStatus(config.Config{DataDir: dir})
|
|
})
|
|
|
|
assert.Contains(t, out, "incompatible")
|
|
assert.Contains(t, out, "running")
|
|
assert.Contains(t, out, fmt.Sprintf("http://%s:%d", host, port))
|
|
assert.Contains(t, out, strconv.Itoa(os.Getpid()))
|
|
assert.Contains(t, out, "daemon version")
|
|
assert.Contains(t, out, "binary version")
|
|
assert.Contains(t, out, "API version")
|
|
assert.Contains(t, out, "data version")
|
|
assert.Contains(t, out, "compatibility")
|
|
assert.Contains(t, out, "agentsview daemon restart")
|
|
assert.Contains(t, out, "agentsview daemon stop")
|
|
assert.NotContains(t, out, "not responding")
|
|
}
|
|
|
|
func TestRunServeStatusPrefersIncompatibleWritableOverReadOnly(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
readOnlyHost, readOnlyPort := testPingServer(t)
|
|
_, err := WriteDaemonRuntime(
|
|
dir, readOnlyHost, readOnlyPort, "1.0.0", true,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
writablePID := startSleepProcess(t)
|
|
writableEndpoint := newPingDaemonWithPID(t, writablePID)
|
|
_, err = writeRuntimeRecordForTest(dir, daemonRuntimeRecord(
|
|
writableEndpoint.Host, writableEndpoint.Port,
|
|
withRuntimePID(writablePID),
|
|
withRuntimeVersion("1.0.0"),
|
|
withRuntimeAPIVersion(0),
|
|
))
|
|
require.NoError(t, err)
|
|
|
|
out := captureStdout(t, func() {
|
|
runServeStatus(config.Config{DataDir: dir})
|
|
})
|
|
|
|
assert.Contains(t, out, "incompatible")
|
|
assert.Contains(t, out, strconv.Itoa(writablePID))
|
|
assert.Contains(t, out, "agentsview daemon restart")
|
|
assert.NotContains(t, out, "mode: read-only")
|
|
}
|
|
|
|
func TestRunServeStatusPrefersStartingOverReadOnly(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
readOnlyHost, readOnlyPort := testPingServer(t)
|
|
_, err := WriteDaemonRuntime(
|
|
dir, readOnlyHost, readOnlyPort, "1.0.0", true,
|
|
)
|
|
require.NoError(t, err)
|
|
MarkDaemonStarting(dir)
|
|
t.Cleanup(func() { UnmarkDaemonStarting(dir) })
|
|
|
|
out := captureStdout(t, func() {
|
|
runServeStatus(config.Config{DataDir: dir})
|
|
})
|
|
|
|
assert.Contains(t, out, "agentsview is starting up.")
|
|
assert.NotContains(t, out, "mode: read-only")
|
|
}
|
|
|
|
func TestRunServeStatusReportsStartupProgress(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
MarkDaemonStarting(dir)
|
|
t.Cleanup(func() { UnmarkDaemonStarting(dir) })
|
|
|
|
// Backdated started_at keeps the elapsed assertion stable.
|
|
state, err := json.Marshal(startupState{
|
|
PID: os.Getpid(),
|
|
StartedAt: time.Now().Add(-90 * time.Second),
|
|
Phase: "full resync",
|
|
Detail: "claude: 12/38 sessions (32%)",
|
|
LogPath: serveLogPath(dir),
|
|
UpdatedAt: time.Now(),
|
|
})
|
|
require.NoError(t, err, "marshal startup state")
|
|
require.NoError(t, os.WriteFile(startupStatePath(dir), state, 0o600),
|
|
"write startup state")
|
|
|
|
out := captureStdout(t, func() {
|
|
runServeStatus(config.Config{DataDir: dir})
|
|
})
|
|
|
|
assert.Contains(t, out, "agentsview is starting up.")
|
|
assert.Contains(t, out, fmt.Sprintf("pid: %d", os.Getpid()))
|
|
// runServeStatus computes elapsed from a live clock, so allow a
|
|
// few seconds of scheduler slack; exact rendering is covered by
|
|
// the fixed-clock serveStartingStatusLines unit test.
|
|
assert.Regexp(t, `elapsed: 1m3[0-5]s`, out)
|
|
assert.Contains(t, out,
|
|
"phase: full resync: claude: 12/38 sessions (32%)")
|
|
assert.Contains(t, out, "log: "+serveLogPath(dir))
|
|
}
|
|
|
|
func TestRunServeStatusStartingWithoutStateFallsBack(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
MarkDaemonStarting(dir)
|
|
t.Cleanup(func() { UnmarkDaemonStarting(dir) })
|
|
require.NoError(t, os.WriteFile(
|
|
startupStatePath(dir), []byte("{corrupt"), 0o600,
|
|
), "plant corrupt state file")
|
|
|
|
out := captureStdout(t, func() {
|
|
runServeStatus(config.Config{DataDir: dir})
|
|
})
|
|
|
|
assert.Contains(t, out, "agentsview is starting up.")
|
|
assert.NotContains(t, out, "phase:")
|
|
}
|
|
|
|
func TestRunServeStatus_StartupStateFallbackWithoutRuntimeRecord(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
writeStartupFallbackFixture(t, dir, host, port, os.Getpid(), strconv.FormatInt(createTime, 10))
|
|
|
|
out := captureStdout(t, func() {
|
|
runServeStatus(config.Config{DataDir: dir})
|
|
})
|
|
|
|
assert.Contains(t, out, "agentsview running at")
|
|
assert.Contains(t, out, fmt.Sprintf("http://%s:%d", host, port))
|
|
assert.Contains(t, out, "runtime record unwritten")
|
|
}
|
|
|
|
func TestRunServeStatus_StartupStateFallbackStaleStateDoesNotClaimRunning(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
writeStartupFallbackFixture(t, dir, host, port, os.Getpid(), "1")
|
|
|
|
out := captureStdout(t, func() {
|
|
runServeStatus(config.Config{DataDir: dir})
|
|
})
|
|
|
|
assert.NotContains(t, out, "agentsview running at")
|
|
assert.Contains(t, out, "agentsview is starting up.")
|
|
assert.Contains(t, out, "stale fallback")
|
|
}
|
|
|
|
func TestRunServeStop_StartupStateFallbackWithoutRuntimeRecord(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
writeStartupFallbackFixture(t, dir, host, port, os.Getpid(), strconv.FormatInt(createTime, 10))
|
|
|
|
rt := FindWritableDaemonRuntime(dir)
|
|
require.NotNil(t, rt)
|
|
assert.True(t, stopTargetConfirmed(rt.Record, ""))
|
|
}
|
|
|
|
func TestRunServeStop_StartupStateFallbackRequiresIdentity(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
writeStartupFallbackFixture(t, dir, host, port, os.Getpid(), "1")
|
|
|
|
assert.Nil(t, FindWritableDaemonRuntime(dir), "stale fallback must not authorize stop")
|
|
}
|
|
|
|
func TestUnmarkDaemonStartingRemovesStartupState(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
MarkDaemonStarting(dir)
|
|
newStartupStateWriter(dir, time.Now).SetPhase("opening database")
|
|
require.NotNil(t, readStartupState(dir), "state written during startup")
|
|
|
|
UnmarkDaemonStarting(dir)
|
|
assert.Nil(t, readStartupState(dir),
|
|
"state must not outlive the start lock")
|
|
}
|
|
|
|
func newPingDaemonWithPID(t *testing.T, pid int) testDaemonEndpoint {
|
|
t.Helper()
|
|
ts := httptest.NewServer(daemon.NewPingHandler(daemon.PingHandlerOptions{
|
|
Service: daemonService,
|
|
Version: "test",
|
|
PID: pid,
|
|
}))
|
|
t.Cleanup(ts.Close)
|
|
return serverEndpoint(t, ts)
|
|
}
|
|
|
|
func TestAcquireBackgroundLaunchLockSerializes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
first, ok := acquireBackgroundLaunchLock(dir)
|
|
require.True(t, ok, "first launch must acquire the lock")
|
|
|
|
_, ok = acquireBackgroundLaunchLock(dir)
|
|
assert.False(t, ok, "a concurrent launch must not acquire the lock")
|
|
|
|
require.NoError(t, first.Unlock())
|
|
|
|
third, ok := acquireBackgroundLaunchLock(dir)
|
|
require.True(t, ok, "lock must be reacquirable after release")
|
|
require.NoError(t, third.Unlock())
|
|
}
|
|
|
|
func TestServeCommandHasLifecycleSubcommands(t *testing.T) {
|
|
cmd := newServeCommand()
|
|
names := map[string]bool{}
|
|
for _, sub := range cmd.Commands() {
|
|
names[sub.Name()] = true
|
|
}
|
|
assert.True(t, names["status"], "serve must expose a status subcommand")
|
|
assert.True(t, names["stop"], "serve must expose a stop subcommand")
|
|
assert.True(t, names["restart"], "serve must expose a restart subcommand")
|
|
}
|
|
|
|
func TestServeRestartHelpExplainsWriterOnlyAsymmetry(t *testing.T) {
|
|
out, err := executeCommand(newRootCommand(), "serve", "restart", "--help")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "writable SQLite background daemon")
|
|
assert.Contains(t, out, "config.toml")
|
|
assert.Contains(t, out,
|
|
"Unlike `agentsview serve stop`, this command intentionally leaves "+
|
|
"read-only PostgreSQL and DuckDB servers running.")
|
|
}
|
|
|
|
func TestStopWritableDaemonsForUpdateStopsAllAndRestartsOne(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
secondPID := startSleepProcess(t)
|
|
|
|
now := time.Now()
|
|
_, err := writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:18080",
|
|
Service: daemonService,
|
|
Version: "1.0.0",
|
|
StartedAt: now.Add(-time.Minute),
|
|
Metadata: map[string]string{
|
|
runtimeHost: "127.0.0.1",
|
|
runtimePort: "18080",
|
|
runtimeReadOnly: "false",
|
|
runtimeRequireAuth: "true",
|
|
runtimeNoSync: "true",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: secondPID,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:18081",
|
|
Service: daemonService,
|
|
Version: "1.0.0",
|
|
StartedAt: now,
|
|
Metadata: map[string]string{
|
|
runtimeHost: "127.0.0.1",
|
|
runtimePort: "18081",
|
|
runtimeReadOnly: "false",
|
|
runtimeRequireAuth: "false",
|
|
runtimeNoSync: "false",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
oldStop := stopDaemonRuntimeForUpgrade
|
|
var stopped []int
|
|
stopDaemonRuntimeForUpgrade = func(
|
|
_ config.Config, rt *DaemonRuntime,
|
|
) error {
|
|
stopped = append(stopped, rt.Record.PID)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { stopDaemonRuntimeForUpgrade = oldStop })
|
|
|
|
result, err := stopWritableDaemonsForUpdate(config.Config{DataDir: dir})
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Stopped)
|
|
assert.Equal(t, "127.0.0.1", result.Host)
|
|
assert.Equal(t, 18080, result.Port)
|
|
assert.True(t, result.RequireAuth)
|
|
assert.True(t, result.RequireAuthKnown)
|
|
assert.True(t, result.NoSync)
|
|
assert.ElementsMatch(t, []int{os.Getpid(), secondPID}, stopped)
|
|
}
|
|
|
|
func TestStopWritableDaemonsForUpdateUsesStartupStateFallback(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
writeStartupFallbackFixture(t, dir, host, port, os.Getpid(), strconv.FormatInt(createTime, 10))
|
|
|
|
oldStop := stopDaemonRuntimeForUpgrade
|
|
var stopped *DaemonRuntime
|
|
stopDaemonRuntimeForUpgrade = func(_ config.Config, rt *DaemonRuntime) error {
|
|
stopped = rt
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { stopDaemonRuntimeForUpgrade = oldStop })
|
|
|
|
result, err := stopWritableDaemonsForUpdate(config.Config{DataDir: dir})
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Stopped)
|
|
assert.Equal(t, host, result.Host)
|
|
assert.Equal(t, port, result.Port)
|
|
require.NotNil(t, stopped)
|
|
assert.Equal(t, os.Getpid(), stopped.Record.PID)
|
|
}
|
|
|
|
func TestStopDaemonProcessTerminatesAndCleansRecord(t *testing.T) {
|
|
requirePOSIXSignals(t, "graceful SIGTERM termination is POSIX-specific")
|
|
dir := runtimeTestDir(t)
|
|
|
|
pid, reaped := startReapedSleepProcess(t)
|
|
|
|
_, err := writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: pid,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, stopDaemonProcess(onlyLiveRuntimeRecord(t, dir), 5*time.Second))
|
|
<-reaped
|
|
assert.False(t, daemon.ProcessAlive(pid))
|
|
assert.Empty(t, liveDaemonRecords(dir),
|
|
"runtime record must be removed after stop")
|
|
}
|
|
|
|
func TestStopDaemonProcessKeepsRecordWhenProcessSurvives(t *testing.T) {
|
|
requirePOSIXSignals(t, "relies on POSIX zombie semantics for ProcessAlive")
|
|
dir := runtimeTestDir(t)
|
|
|
|
// startSleepProcess does not reap until cleanup: once signalled, the child
|
|
// becomes a zombie, which daemon.ProcessAlive reports as still alive. That
|
|
// drives stopDaemonProcess down its "survived the kill" path.
|
|
pid := startSleepProcess(t)
|
|
|
|
_, err := writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: pid,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = stopDaemonProcess(onlyLiveRuntimeRecord(t, dir), 100*time.Millisecond)
|
|
require.Error(t, err, "must report failure when the process does not exit")
|
|
assert.NotEmpty(t, liveDaemonRecords(dir),
|
|
"runtime record must be kept when the daemon is still alive")
|
|
}
|
|
|
|
func TestStopDaemonProcessDoesNotForceKillUnknownIdentity(t *testing.T) {
|
|
requirePOSIXSignals(t, "SIGTERM-ignore and SIGKILL behavior is POSIX-specific")
|
|
setStartProbeTickForTest(t, 10*time.Millisecond)
|
|
|
|
tests := []struct {
|
|
name string
|
|
createTime string
|
|
}{
|
|
{name: "missing"},
|
|
{name: "malformed", createTime: "not-a-time"},
|
|
{name: "zero", createTime: "0"},
|
|
{name: "negative", createTime: "-1"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
pid, reaped := startReapedTERMIgnoringProcess(t)
|
|
path, err := writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: pid,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Metadata: map[string]string{runtimeCreateTime: tt.createTime},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = stopDaemonProcess(onlyLiveRuntimeRecord(t, dir), 50*time.Millisecond)
|
|
assert.Error(t, err)
|
|
assert.ErrorContains(t, err, "identity")
|
|
assert.True(t, daemon.ProcessAlive(pid),
|
|
"unknown identity must not authorize SIGKILL")
|
|
select {
|
|
case <-reaped:
|
|
assert.Fail(t, "unknown identity process was killed")
|
|
default:
|
|
}
|
|
assert.FileExists(t, path,
|
|
"unknown identity record must remain for manual recovery")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStopDaemonProcessForceKillsMatchedIdentity(t *testing.T) {
|
|
requirePOSIXSignals(t, "SIGTERM-ignore and SIGKILL behavior is POSIX-specific")
|
|
setStartProbeTickForTest(t, 10*time.Millisecond)
|
|
dir := runtimeTestDir(t)
|
|
pid, reaped := startReapedTERMIgnoringProcess(t)
|
|
createTime, ok := processCreateTimeMillis(pid)
|
|
require.True(t, ok)
|
|
path, err := writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: pid,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Metadata: map[string]string{
|
|
runtimeCreateTime: strconv.FormatInt(createTime, 10),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, stopDaemonProcess(
|
|
onlyLiveRuntimeRecord(t, dir), 50*time.Millisecond,
|
|
))
|
|
<-reaped
|
|
assert.False(t, daemon.ProcessAlive(pid))
|
|
assertPathRemoved(t, path,
|
|
"matched identity record should be removed after force kill")
|
|
}
|
|
|
|
func TestStopDaemonProcessKeepsRecordWhenIdentityBecomesUnknownAfterForceKill(
|
|
t *testing.T,
|
|
) {
|
|
requirePOSIXSignals(t, "relies on POSIX zombie semantics for ProcessAlive")
|
|
setStartProbeTickForTest(t, 10*time.Millisecond)
|
|
dir := runtimeTestDir(t)
|
|
pid := startTERMIgnoringProcess(t)
|
|
path, err := writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: pid,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Metadata: map[string]string{runtimeCreateTime: "1234"},
|
|
})
|
|
require.NoError(t, err)
|
|
rec := onlyLiveRuntimeRecord(t, dir)
|
|
|
|
identityCalls := 0
|
|
identityState := func(gotPID int, recorded string) processCreateTimeState {
|
|
assert.Equal(t, pid, gotPID)
|
|
assert.Equal(t, "1234", recorded)
|
|
identityCalls++
|
|
if identityCalls == 1 {
|
|
return processCreateTimeMatch
|
|
}
|
|
return processCreateTimeUnknown
|
|
}
|
|
|
|
err = stopDaemonProcessWithIdentity(
|
|
rec, 50*time.Millisecond, identityState,
|
|
)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "identity")
|
|
assert.ErrorContains(t, err, "after force kill")
|
|
assert.Equal(t, 2, identityCalls)
|
|
assert.FileExists(t, path,
|
|
"unknown post-kill identity must preserve the runtime record")
|
|
}
|
|
|
|
func TestStopDaemonProcessKeepsRecordWhenMatchedProcessSurvivesForceKill(
|
|
t *testing.T,
|
|
) {
|
|
requirePOSIXSignals(t, "relies on POSIX zombie semantics for ProcessAlive")
|
|
setStartProbeTickForTest(t, 10*time.Millisecond)
|
|
dir := runtimeTestDir(t)
|
|
pid := startTERMIgnoringProcess(t)
|
|
path, err := writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: pid,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Metadata: map[string]string{runtimeCreateTime: "1234"},
|
|
})
|
|
require.NoError(t, err)
|
|
rec := onlyLiveRuntimeRecord(t, dir)
|
|
|
|
identityCalls := 0
|
|
identityState := func(gotPID int, recorded string) processCreateTimeState {
|
|
assert.Equal(t, pid, gotPID)
|
|
assert.Equal(t, "1234", recorded)
|
|
identityCalls++
|
|
return processCreateTimeMatch
|
|
}
|
|
|
|
err = stopDaemonProcessWithIdentity(
|
|
rec, 50*time.Millisecond, identityState,
|
|
)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "still running after force kill")
|
|
assert.Equal(t, 2, identityCalls,
|
|
"identity must be confirmed before and after force kill")
|
|
assert.FileExists(t, path,
|
|
"surviving matched process must retain its ownership record")
|
|
}
|
|
|
|
func TestStopDaemonProcessRemovesRecordWhenPIDReused(t *testing.T) {
|
|
requirePOSIXSignals(t, "relies on POSIX zombie semantics for ProcessAlive")
|
|
dir := runtimeTestDir(t)
|
|
|
|
pid := startSleepProcess(t)
|
|
|
|
// A create time that cannot match the live process models the daemon
|
|
// having exited with the PID reused by something unrelated. The record
|
|
// must be removed (not kept) so later commands do not think the DB is
|
|
// still owned.
|
|
_, err := writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: pid,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Metadata: map[string]string{runtimeCreateTime: "1"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = stopDaemonProcess(onlyLiveRuntimeRecord(t, dir), 100*time.Millisecond)
|
|
require.NoError(t, err,
|
|
"a reused PID means the daemon exited; stop should succeed")
|
|
assert.Empty(t, liveDaemonRecords(dir),
|
|
"the stale record for a reused PID must be removed")
|
|
}
|
|
|
|
func TestStopDaemonProcessSparesReusedPIDBeforeForceKill(t *testing.T) {
|
|
requirePOSIXSignals(t, "relies on POSIX signal semantics")
|
|
dir := runtimeTestDir(t)
|
|
|
|
// A process that ignores SIGTERM stays alive through the grace wait,
|
|
// reaching the force-kill escalation. With a mismatched create time it
|
|
// stands in for a live process that reused the daemon's PID. The pre-kill
|
|
// identity check must spare it instead of escalating to SIGKILL.
|
|
pid := startTERMIgnoringProcess(t)
|
|
|
|
_, err := writeRuntimeRecordForTest(dir, daemon.RuntimeRecord{
|
|
PID: pid,
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Metadata: map[string]string{runtimeCreateTime: "1"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = stopDaemonProcess(onlyLiveRuntimeRecord(t, dir), 100*time.Millisecond)
|
|
require.NoError(t, err,
|
|
"a reused PID means the daemon exited; stop should succeed")
|
|
assert.Empty(t, liveDaemonRecords(dir),
|
|
"the stale record for a reused PID must be removed")
|
|
assert.True(t, daemon.ProcessAlive(pid),
|
|
"the reused PID must not be force-killed")
|
|
}
|
|
|
|
func TestWriteDaemonRuntimePersistsCaddyMetadata(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
// Use this process as a stand-in caddy child: it is alive with a readable
|
|
// create time. We never signal it here.
|
|
_, err := WriteDaemonRuntime(dir, "127.0.0.1", 65535, "test", false, os.Getpid())
|
|
require.NoError(t, err)
|
|
|
|
rec := onlyLiveRuntimeRecord(t, dir)
|
|
assert.Equal(t, strconv.Itoa(os.Getpid()), rec.Metadata[runtimeCaddyPID])
|
|
ct, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
assert.Equal(t,
|
|
strconv.FormatInt(ct, 10), rec.Metadata[runtimeCaddyCreateTime])
|
|
}
|
|
|
|
func TestWriteDaemonRuntimeOmitsCaddyMetadataWhenAbsent(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
_, err := WriteDaemonRuntime(dir, "127.0.0.1", 65535, "test", false)
|
|
require.NoError(t, err)
|
|
_, has := onlyLiveRuntimeRecord(t, dir).Metadata[runtimeCaddyPID]
|
|
assert.False(t, has, "no caddy pid means no caddy metadata")
|
|
|
|
// A zero caddy pid must also be omitted.
|
|
dir2 := runtimeTestDir(t)
|
|
_, err = WriteDaemonRuntime(dir2, "127.0.0.1", 65535, "test", false, 0)
|
|
require.NoError(t, err)
|
|
_, has = onlyLiveRuntimeRecord(t, dir2).Metadata[runtimeCaddyPID]
|
|
assert.False(t, has)
|
|
}
|
|
|
|
func TestStopOrphanedCaddyChildTerminatesConfirmed(t *testing.T) {
|
|
requirePOSIXSignals(t, "graceful SIGTERM termination is POSIX-specific")
|
|
pid, reaped := startReapedSleepProcess(t)
|
|
|
|
ct, ok := processCreateTimeMillis(pid)
|
|
require.True(t, ok)
|
|
rec := daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Metadata: map[string]string{
|
|
runtimeCaddyPID: strconv.Itoa(pid),
|
|
runtimeCaddyCreateTime: strconv.FormatInt(ct, 10),
|
|
},
|
|
}
|
|
|
|
stopOrphanedCaddyChild(rec)
|
|
<-reaped
|
|
assert.False(t, daemon.ProcessAlive(pid),
|
|
"a confirmed orphaned caddy child must be terminated")
|
|
}
|
|
|
|
func TestStopOrphanedCaddyChildSkipsMismatchedCreateTime(t *testing.T) {
|
|
requirePOSIXSignals(t, "graceful SIGTERM termination is POSIX-specific")
|
|
pid := startSleepProcess(t)
|
|
|
|
rec := daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Metadata: map[string]string{
|
|
runtimeCaddyPID: strconv.Itoa(pid),
|
|
runtimeCaddyCreateTime: "1", // deliberately wrong: models a reused PID
|
|
},
|
|
}
|
|
|
|
stopOrphanedCaddyChild(rec)
|
|
assert.True(t, daemon.ProcessAlive(pid),
|
|
"a reused caddy PID must not be signalled")
|
|
}
|
|
|
|
func TestCaddyStopRecordCarriesCreateTime(t *testing.T) {
|
|
rec := caddyStopRecord(4321, "1700000000000")
|
|
assert.Equal(t, 4321, rec.PID)
|
|
assert.Equal(t, "1700000000000", rec.Metadata[runtimeCreateTime],
|
|
"the caddy create time must be carried as runtimeCreateTime so the "+
|
|
"pre-force-kill identity check guards a reused caddy PID")
|
|
assert.Empty(t, rec.SourcePath,
|
|
"a caddy stop record has no source file to remove")
|
|
}
|
|
|
|
func TestStopOrphanedCaddyChildNoMetadataIsNoop(t *testing.T) {
|
|
assert.NotPanics(t, func() {
|
|
stopOrphanedCaddyChild(daemon.RuntimeRecord{PID: os.Getpid()})
|
|
})
|
|
}
|
|
|
|
func TestDaemonRecordPingConfirmedRespondingDaemon(t *testing.T) {
|
|
host, port := testPingServer(t)
|
|
rec := daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Network: daemon.NetworkTCP,
|
|
Address: net.JoinHostPort(host, strconv.Itoa(port)),
|
|
Service: daemonService,
|
|
}
|
|
assert.True(t, daemonRecordPingConfirmed(rec, ""))
|
|
}
|
|
|
|
func TestDaemonRecordPingConfirmedUnresponsivePID(t *testing.T) {
|
|
// A live PID (this process) but a record pointing at a port with no
|
|
// agentsview daemon. The probe must fail.
|
|
rec := daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Service: daemonService,
|
|
}
|
|
assert.False(t, daemonRecordPingConfirmed(rec, ""))
|
|
}
|
|
|
|
func TestDaemonRecordPingConfirmedRequiresAuthToken(t *testing.T) {
|
|
host, port := testAuthenticatedPingServer(t, "secret")
|
|
rec := daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Network: daemon.NetworkTCP,
|
|
Address: net.JoinHostPort(host, strconv.Itoa(port)),
|
|
Service: daemonService,
|
|
}
|
|
assert.False(t, daemonRecordPingConfirmed(rec, ""),
|
|
"a require_auth daemon must not be confirmed without the token")
|
|
assert.True(t, daemonRecordPingConfirmed(rec, "secret"))
|
|
}
|
|
|
|
func TestWriteDaemonRuntimePersistsCreateTimeForStop(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
_, err := WriteDaemonRuntime(dir, "127.0.0.1", 65535, "test", false)
|
|
require.NoError(t, err)
|
|
|
|
rec := onlyLiveRuntimeRecord(t, dir)
|
|
assert.NotEmpty(t, rec.Metadata[runtimeCreateTime],
|
|
"WriteDaemonRuntime must persist the process create time")
|
|
|
|
// No server answers at that port, so ping confirmation fails; the
|
|
// persisted create time must still confirm the record belongs to this
|
|
// live process so a wedged daemon is stoppable.
|
|
assert.False(t, daemonRecordPingConfirmed(rec, ""))
|
|
assert.True(t, stopTargetConfirmed(rec, ""))
|
|
}
|
|
|
|
func TestProcessIdentityConfirmed(t *testing.T) {
|
|
live, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok, "must be able to read this process's create time")
|
|
|
|
// Exact create-time match: the recorded daemon is still on this PID.
|
|
assert.True(t, processIdentityConfirmed(daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Metadata: map[string]string{
|
|
runtimeCreateTime: strconv.FormatInt(live, 10),
|
|
},
|
|
}))
|
|
|
|
// Different create time models a PID reused by another process: there is
|
|
// no slack window, so a mismatch is always rejected.
|
|
assert.False(t, processIdentityConfirmed(daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Metadata: map[string]string{
|
|
runtimeCreateTime: strconv.FormatInt(live+1, 10),
|
|
},
|
|
}))
|
|
|
|
// Missing or unparseable metadata cannot be confirmed this way.
|
|
assert.False(t, processIdentityConfirmed(daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
}))
|
|
assert.False(t, processIdentityConfirmed(daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Metadata: map[string]string{runtimeCreateTime: "not-a-number"},
|
|
}))
|
|
}
|
|
|
|
func TestStopTargetConfirmedHungDaemonByCreateTime(t *testing.T) {
|
|
// A daemon that is alive but no longer answers the ping probe (dead
|
|
// address) must still be confirmed by its persisted create time, so a
|
|
// wedged server remains stoppable.
|
|
live, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
rec := daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Service: daemonService,
|
|
Metadata: map[string]string{
|
|
runtimeCreateTime: strconv.FormatInt(live, 10),
|
|
},
|
|
}
|
|
assert.False(t, daemonRecordPingConfirmed(rec, ""),
|
|
"precondition: the dead address must not ping-confirm")
|
|
assert.True(t, stopTargetConfirmed(rec, ""),
|
|
"a hung-but-alive daemon must remain stoppable via create-time identity")
|
|
}
|
|
|
|
func TestStopTargetConfirmedRejectsReusedPID(t *testing.T) {
|
|
// No ping and a create time that does not match this process: neither
|
|
// check confirms, so stop must not signal the process holding the PID.
|
|
live, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
rec := daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Service: daemonService,
|
|
Metadata: map[string]string{
|
|
runtimeCreateTime: strconv.FormatInt(live+5000, 10),
|
|
},
|
|
}
|
|
assert.False(t, stopTargetConfirmed(rec, ""))
|
|
|
|
// A legacy record with no create time also cannot be confirmed.
|
|
assert.False(t, stopTargetConfirmed(daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Network: daemon.NetworkTCP,
|
|
Address: "127.0.0.1:1",
|
|
Service: daemonService,
|
|
}, ""))
|
|
}
|