Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

601 lines
19 KiB
Go

// ABOUTME: serve status and serve stop inspect and terminate a running
// ABOUTME: agentsview server using its kit daemon runtime record.
package main
import (
"context"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/kit/daemon"
)
// serveStopGraceTimeout bounds how long serve stop waits for a graceful
// shutdown after signalling before escalating to a forced kill.
const serveStopGraceTimeout = 10 * time.Second
var stopDaemonRuntimeForUpgrade = stopDaemonRuntimeForUpgradeImpl
type daemonStopOperations struct {
confirmed func(daemon.RuntimeRecord, string) bool
stop func(daemon.RuntimeRecord, time.Duration) error
cleanup func(io.Writer, daemon.RuntimeRecord) error
}
// stopWritableDaemonRecordsSafely prevalidates the identity of every target
// before signalling any of them. This prevents a corrupt multi-writer runtime
// set from being only partially stopped when one record is stale or its PID
// has been reused.
func stopWritableDaemonRecordsSafely(
w io.Writer,
cfg config.Config,
records []daemon.RuntimeRecord,
ops daemonStopOperations,
) error {
_, unconfirmedRecords := partitionConfirmedDaemonRecords(
records, cfg.AuthToken, ops.confirmed,
)
var unconfirmed []string
for _, rec := range unconfirmedRecords {
detail := fmt.Sprintf("pid %d", rec.PID)
if rec.SourcePath != "" {
detail += " (runtime record " + rec.SourcePath + ")"
}
unconfirmed = append(unconfirmed, detail)
}
if len(unconfirmed) > 0 {
return fmt.Errorf(
"cannot confirm every writable agentsview daemon: %s; no process was signalled; verify each process and terminate it manually before retrying",
strings.Join(unconfirmed, ", "),
)
}
stopped := make([]int, 0, len(records))
for i, rec := range records {
if !ops.confirmed(rec, cfg.AuthToken) {
if len(stopped) == 0 {
return fmt.Errorf(
"stop aborted before signaling; no process was signalled; remaining pids %s; pid %d identity changed; verify remaining processes and terminate them manually before retrying",
formatRecordPIDList(records[i:]), rec.PID,
)
}
return partialDaemonStopError(
stopped, records[i:],
fmt.Errorf("pid %d identity changed before signaling", rec.PID),
)
}
if err := ops.stop(rec, serveStopGraceTimeout); err != nil {
return partialDaemonStopError(
stopped, records[i:], fmt.Errorf("stopping pid %d: %w", rec.PID, err),
)
}
stopped = append(stopped, rec.PID)
fmt.Fprintf(w, "Stopped agentsview (pid %d).\n", rec.PID)
if err := ops.cleanup(w, rec); err != nil {
return partialDaemonStopError(
stopped, records[i+1:],
fmt.Errorf("managed caddy cleanup for pid %d: %w", rec.PID, err),
)
}
}
return nil
}
func partitionConfirmedDaemonRecords(
records []daemon.RuntimeRecord,
authToken string,
confirmed func(daemon.RuntimeRecord, string) bool,
) (confirmedRecords, unconfirmedRecords []daemon.RuntimeRecord) {
for _, rec := range records {
if confirmed(rec, authToken) {
confirmedRecords = append(confirmedRecords, rec)
} else {
unconfirmedRecords = append(unconfirmedRecords, rec)
}
}
return confirmedRecords, unconfirmedRecords
}
func formatRecordPIDList(records []daemon.RuntimeRecord) string {
pids := make([]int, 0, len(records))
for _, rec := range records {
pids = append(pids, rec.PID)
}
return formatPIDList(pids)
}
func partialDaemonStopError(
stopped []int, remaining []daemon.RuntimeRecord, cause error,
) error {
stoppedText := "none"
if len(stopped) > 0 {
stoppedText = formatPIDList(stopped)
}
remainingPIDs := make([]int, 0, len(remaining))
for _, rec := range remaining {
remainingPIDs = append(remainingPIDs, rec.PID)
}
remainingText := "none"
if len(remainingPIDs) > 0 {
remainingText = formatPIDList(remainingPIDs)
}
return fmt.Errorf(
"partial stop: stopped pid%s %s; remaining pids %s; %w; verify remaining processes and terminate them manually before retrying",
pluralSuffix(len(stopped)), stoppedText, remainingText, cause,
)
}
func formatPIDList(pids []int) string {
parts := make([]string, 0, len(pids))
for _, pid := range pids {
parts = append(parts, strconv.Itoa(pid))
}
return strings.Join(parts, ", ")
}
func pluralSuffix(count int) string {
if count == 1 {
return ""
}
return "s"
}
// runServeStatus reports whether a server owns this data dir, and where to
// reach it. It always exits zero; the output distinguishes the states.
func runServeStatus(cfg config.Config) {
var readOnly *DaemonRuntime
if rt := FindDaemonRuntime(cfg.DataDir, cfg.AuthToken); rt != nil {
if rt.ReadOnly {
readOnly = rt
} else {
for _, line := range serveStatusLines(rt) {
fmt.Println(line)
}
return
}
}
if rt, compatErr := findIncompatibleWritableDaemonRuntime(
cfg.DataDir, cfg.AuthToken,
); rt != nil {
for _, line := range serveIncompatibleDaemonStatusLines(rt, compatErr) {
fmt.Println(line)
}
return
}
if IsDaemonStarting(cfg.DataDir) {
fmt.Println("agentsview is starting up.")
st := readStartupState(cfg.DataDir)
for _, line := range serveStartingStatusLines(st, time.Now()) {
fmt.Println(line)
}
if st != nil && st.Host != "" && st.Port > 0 && st.RuntimeError != "" {
fmt.Println(" stale fallback: endpoint or process identity could not be confirmed")
}
return
}
if readOnly != nil {
for _, line := range serveStatusLines(readOnly) {
fmt.Println(line)
}
return
}
if recs := liveDaemonRecords(cfg.DataDir); len(recs) > 0 {
fmt.Printf(
"agentsview process running (pid %d) but not responding "+
"to health checks.\n",
recs[0].PID,
)
return
}
fmt.Println("No agentsview server is running.")
}
// serveStatusLines renders the human-readable status of a discovered daemon.
func serveStatusLines(rt *DaemonRuntime) []string {
lines := []string{
fmt.Sprintf("agentsview running at %s", urlFromDaemonRuntime(rt)),
fmt.Sprintf(" pid: %d", rt.Record.PID),
}
if rt.Record.Version != "" {
lines = append(lines, fmt.Sprintf(" version: %s", rt.Record.Version))
}
if !rt.Record.StartedAt.IsZero() {
uptime := time.Since(rt.Record.StartedAt).Round(time.Second)
lines = append(lines, fmt.Sprintf(" uptime: %s", uptime))
}
if rt.ReadOnly {
lines = append(lines, " mode: read-only")
}
if rt.RuntimeFallback {
lines = append(lines, " runtime record unwritten: "+rt.RuntimeError)
}
return lines
}
// serveStartingStatusLines renders the detail lines for a daemon that
// holds the start lock, from its published startup state. A nil state
// (legacy daemon version, unreadable or mid-write file) yields no
// extra lines; the state is only trusted while the start lock is
// held, so staleness needs no handling here.
func serveStartingStatusLines(st *startupState, now time.Time) []string {
if st == nil {
return nil
}
var lines []string
if st.PID > 0 {
lines = append(lines, fmt.Sprintf(" pid: %d", st.PID))
}
if !st.StartedAt.IsZero() {
if elapsed := now.Sub(st.StartedAt).Round(time.Second); elapsed >= 0 {
lines = append(lines, fmt.Sprintf(" elapsed: %s", elapsed))
}
}
if st.Phase != "" {
phase := st.Phase
if st.Detail != "" {
phase += ": " + st.Detail
}
lines = append(lines, " phase: "+phase)
}
if st.LogPath != "" {
lines = append(lines, " log: "+st.LogPath)
}
return lines
}
func serveIncompatibleDaemonStatusLines(
rt *DaemonRuntime, compatErr error,
) []string {
decision := serveReplacementDecision{
Action: serveReplacementRefuse,
Runtime: rt,
CompatibilityErr: compatErr,
Reason: serveDaemonRefusalReason(rt, compatErr),
}
lines := serveDaemonDecisionLines(
"agentsview found an incompatible running writable daemon.",
decision,
)
return append(lines,
"Run `agentsview daemon restart` to replace it, or "+
"`agentsview daemon stop` to stop it first.",
)
}
// runServeStop terminates every agentsview server owning this data dir whose
// identity it can confirm. A record is signalled only once its PID is confirmed
// to be the recorded daemon -- either it answers the ping probe, or its process
// start time predates the record (proving the PID was not reused by an
// unrelated process). This keeps a hung-but-alive daemon stoppable while never
// signalling a stale record whose PID belongs to something else.
func runServeStop(cfg config.Config) {
records, _ := localWritableDaemonRecordsWithFallback(
cfg.DataDir, cfg.AuthToken,
)
if len(records) == 0 {
if IsDaemonStarting(cfg.DataDir) {
fatal("serve stop: a server is starting; retry once it is ready")
}
fmt.Println("No agentsview server is running.")
return
}
stopped, skipped := 0, 0
for _, rec := range records {
if !stopTargetConfirmed(rec, cfg.AuthToken) {
fmt.Printf(
"Skipping pid %d: cannot confirm it is the recorded "+
"agentsview daemon (stale record or reused pid).\n",
rec.PID,
)
skipped++
continue
}
if err := stopDaemonProcess(rec, serveStopGraceTimeout); err != nil {
fatal("serve stop: stopping pid %d: %v", rec.PID, err)
}
stopOrphanedCaddyChild(rec)
fmt.Printf("Stopped agentsview (pid %d).\n", rec.PID)
stopped++
}
if stopped == 0 && skipped > 0 {
fmt.Println(
"No agentsview server was stopped; runtime records may be stale.",
)
}
}
func stopDaemonRuntimeForUpgradeImpl(
cfg config.Config, rt *DaemonRuntime,
) error {
if rt == nil {
return nil
}
if !stopTargetConfirmed(rt.Record, cfg.AuthToken) {
return fmt.Errorf(
"cannot confirm pid %d is the recorded agentsview daemon",
rt.Record.PID,
)
}
if err := stopDaemonProcess(rt.Record, serveStopGraceTimeout); err != nil {
return fmt.Errorf("stopping pid %d: %w", rt.Record.PID, err)
}
stopOrphanedCaddyChild(rt.Record)
return nil
}
func stopWritableDaemonsForUpdate(
cfg config.Config,
) (updateDaemonStopResult, error) {
records, _ := localWritableDaemonRecordsWithFallback(
cfg.DataDir, cfg.AuthToken,
)
var result updateDaemonStopResult
for _, rec := range records {
rt := daemonRuntimeFromRecord(rec)
if rt.ReadOnly {
continue
}
// A data dir supports one writable daemon. If multiple live writable
// records somehow exist, stop every old writer before the update but
// restart one replacement using the first runtime's externally visible
// settings. Restarting multiple writers would recreate the invalid
// state this path is trying to collapse.
if !result.Stopped {
result.Host = rt.Host
result.Port = rt.Port
result.RequireAuth = rt.RequireAuth
result.RequireAuthKnown = rt.RequireAuthKnown
result.NoSync = rt.NoSync
}
if err := stopDaemonRuntimeForUpgrade(cfg, rt); err != nil {
return result, err
}
result.Stopped = true
}
if !result.Stopped && IsDaemonStarting(cfg.DataDir) {
return result, fmt.Errorf(
"agentsview server is starting; retry the update once it is ready",
)
}
return result, nil
}
// stopTargetConfirmed reports whether rec's live PID is safe to signal as the
// recorded agentsview daemon. It accepts the target when the daemon answers the
// ping probe, or, for a daemon that is alive but no longer answering, when the
// process create time exactly matches the one recorded at startup. Either check
// rules out a PID that an unrelated process reused after the record was
// written.
func stopTargetConfirmed(rec daemon.RuntimeRecord, authToken string) bool {
return daemonRecordPingConfirmed(rec, authToken) ||
processIdentityConfirmed(rec)
}
// daemonRecordPingConfirmed reports whether rec's PID answers the kit ping
// probe as the agentsview daemon it claims to be.
func daemonRecordPingConfirmed(
rec daemon.RuntimeRecord, authToken string,
) bool {
_, confirmed := probeDaemonRecord(rec, authToken)
return confirmed
}
// probeDaemonRecord returns the ping identity when rec's PID answers as the
// agentsview daemon it claims to be.
func probeDaemonRecord(
rec daemon.RuntimeRecord, authToken string,
) (daemon.PingInfo, bool) {
info, err := probeRuntime(
context.Background(), rec, authToken, daemon.ProbeOptions{
ExpectedService: daemonService,
Timeout: 500 * time.Millisecond,
},
)
return info, err == nil && info.PID == rec.PID
}
// processIdentityConfirmed reports whether the process now holding rec.PID is
// the same one that wrote the record, by matching the OS create time persisted
// at startup against the live process's current create time.
func processIdentityConfirmed(rec daemon.RuntimeRecord) bool {
return processCreateTimeMatches(rec.PID, rec.Metadata[runtimeCreateTime])
}
// processCreateTimeMatches reports whether pid's current OS create time equals
// recordedMillis. The match is exact: the create time is fixed for a given
// process, so a PID reused by a different process yields a different value and
// is rejected -- there is no slack window an impostor could fall into. An empty
// or unparseable recordedMillis (legacy, or unreadable at write time) returns
// false.
func processCreateTimeMatches(pid int, recordedMillis string) bool {
return processCreateTimeStateForPID(
pid, recordedMillis,
) == processCreateTimeMatch
}
// stopOrphanedCaddyChild terminates a managed Caddy child recorded in rec if it
// is still alive after the server stopped. When the server shuts down
// gracefully it stops Caddy itself, so by here Caddy is already gone and this
// is a no-op. When the server had to be force-killed (it ignored SIGTERM until
// the grace timeout, then took SIGKILL), its cleanup never ran and Caddy would
// otherwise keep holding the public port. The create time is matched exactly
// before signalling, so a reused Caddy PID is never touched.
func stopOrphanedCaddyChild(rec daemon.RuntimeRecord) {
if err := stopOrphanedCaddyChildWithWriter(os.Stdout, rec); err != nil {
raw := rec.Metadata[runtimeCaddyPID]
fmt.Printf(
"warning: could not stop managed caddy (pid %s): %v\n", raw, err,
)
}
}
// stopOrphanedCaddyChildWithWriter is the canonical, error-returning managed
// Caddy cleanup path. Callers choose the output writer and decide whether a
// cleanup failure is fatal to a larger lifecycle transition.
func stopOrphanedCaddyChildWithWriter(
w io.Writer, rec daemon.RuntimeRecord,
) error {
raw := rec.Metadata[runtimeCaddyPID]
if raw == "" {
return nil
}
pid, err := strconv.Atoi(raw)
if err != nil || pid <= 0 {
return nil
}
if !daemon.ProcessAlive(pid) {
return nil
}
caddyCreateTime := rec.Metadata[runtimeCaddyCreateTime]
switch processCreateTimeStateForPID(pid, caddyCreateTime) {
case processCreateTimeMismatch:
return nil
case processCreateTimeUnknown:
return fmt.Errorf(
"managed caddy pid %d identity could not be confirmed; refusing to signal it",
pid,
)
case processCreateTimeMatch:
// Exact identity authorizes shutdown below.
default:
return fmt.Errorf(
"managed caddy pid %d identity could not be confirmed; refusing to signal it",
pid,
)
}
if err := stopDaemonProcess(
caddyStopRecord(pid, caddyCreateTime), serveStopGraceTimeout,
); err != nil {
return err
}
fmt.Fprintf(w, "Stopped managed caddy (pid %d).\n", pid)
return nil
}
// caddyStopRecord builds the record used to stop a managed Caddy child. It has
// no SourcePath, so stopDaemonProcess only signals and waits and removes no
// record file. The Caddy create time is carried as runtimeCreateTime so
// stopDaemonProcess's pre-force-kill identity check guards a Caddy PID that was
// reused during the grace wait.
func caddyStopRecord(pid int, createTime string) daemon.RuntimeRecord {
return daemon.RuntimeRecord{
PID: pid,
Metadata: map[string]string{runtimeCreateTime: createTime},
}
}
// stopDaemonProcess signals the daemon to shut down, waits up to grace for it
// to exit, then escalates to a forced kill. Before escalating it re-checks that
// the live PID is still the recorded daemon, so a PID reused during the grace
// wait is never killed. It cleans up the runtime record if the process leaves
// one behind.
func stopDaemonProcess(rec daemon.RuntimeRecord, grace time.Duration) error {
return stopDaemonProcessWithIdentity(
rec, grace, processCreateTimeStateForPID,
)
}
func stopDaemonProcessWithIdentity(
rec daemon.RuntimeRecord,
grace time.Duration,
identityStateForPID func(int, string) processCreateTimeState,
) error {
proc, err := os.FindProcess(rec.PID)
if err != nil {
return fmt.Errorf("finding process: %w", err)
}
if err := terminateProcess(proc); err != nil {
return fmt.Errorf("signalling shutdown: %w", err)
}
if waitForProcessExit(rec.PID, grace) {
removeRuntimeRecordFile(rec)
return nil
}
identityState := identityStateForPID(
rec.PID, rec.Metadata[runtimeCreateTime],
)
switch identityState {
case processCreateTimeMismatch:
// The PID is alive but its identity no longer matches the record: the
// daemon exited during the grace wait and the PID was reused by an
// unrelated process. Do not force-kill the impostor; just drop the
// stale record.
removeRuntimeRecordFile(rec)
return nil
case processCreateTimeUnknown:
return processIdentityUnconfirmedError(rec, "after graceful shutdown")
case processCreateTimeMatch:
// Only an exact identity match authorizes force-kill escalation.
default:
return processIdentityUnconfirmedError(rec, "after graceful shutdown")
}
if err := proc.Kill(); err != nil {
return fmt.Errorf("force killing: %w", err)
}
if waitForProcessExit(rec.PID, grace) {
removeRuntimeRecordFile(rec)
return nil
}
identityState = identityStateForPID(
rec.PID, rec.Metadata[runtimeCreateTime],
)
switch identityState {
case processCreateTimeMismatch:
// The daemon exited after SIGKILL and the PID was reused. The record
// is now proven stale, so remove it without signalling the new owner.
removeRuntimeRecordFile(rec)
return nil
case processCreateTimeUnknown:
return processIdentityUnconfirmedError(rec, "after force kill")
case processCreateTimeMatch:
// The recorded daemon genuinely outlived even SIGKILL. Keep the
// runtime record so other commands still see it owns the DB rather
// than racing it.
return fmt.Errorf("process %d still running after force kill", rec.PID)
default:
return processIdentityUnconfirmedError(rec, "after force kill")
}
}
func processIdentityUnconfirmedError(
rec daemon.RuntimeRecord, phase string,
) error {
if rec.SourcePath != "" {
return fmt.Errorf(
"process %d identity could not be confirmed %s; refusing further signaling; runtime record preserved at %s for manual recovery",
rec.PID, phase, rec.SourcePath,
)
}
return fmt.Errorf(
"process %d identity could not be confirmed %s; refusing further signaling; inspect the process manually",
rec.PID, phase,
)
}
// waitForProcessExit polls until pid is gone or timeout elapses. It reports
// whether the process exited.
func waitForProcessExit(pid int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !daemon.ProcessAlive(pid) {
return true
}
time.Sleep(startProbeTick())
}
return !daemon.ProcessAlive(pid)
}
// removeRuntimeRecordFile deletes the daemon's runtime record. A graceful
// shutdown removes its own record; a forced kill does not, so clean up the
// stale file to keep discovery accurate.
func removeRuntimeRecordFile(rec daemon.RuntimeRecord) {
if rec.SourcePath == "" {
return
}
_ = os.Remove(rec.SourcePath)
}