carlos / platform Public

cmd/moveprobe: a dead or silent worker fails /ready

The main now records the worker's exit (from Wait, or the status pipe
closing first) and the time of its latest status line. /ready answers 500
once the worker has exited or did not start, and when it has sent no
status for 3 s; /report carries worker_exit and worker_last_status_at. The
README's Strings section lists the new bodies and fields. Final review I2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d7a870848b5b319b15706977aa162ef1cb62d9c6 parent 356cf05
3 files changed, +104 −8
  • cmd/moveprobe/README.md +17 −4
  • cmd/moveprobe/main.go +39 −4
  • cmd/moveprobe/main_test.go +48 −0
diff --git a/cmd/moveprobe/README.md b/cmd/moveprobe/README.md
index a64800515..e729077aa 100644
--- a/cmd/moveprobe/README.md
+++ b/cmd/moveprobe/README.md
@@ -111,9 +111,11 @@ seconds and then exits by itself.
The worker tells the app whether each write landed. `/report` shows how many
committed and how many failed since the app started, how many of the latest
-failed in a row, and the last error. `/ready` fails until a worker row has
-committed since the app started, and fails again whenever the worker's
-latest write failed.
+failed in a row, the last error, when it last heard from the worker, and
+why the worker is gone if it is. `/ready` fails until a worker row has
+committed since the app started, whenever the worker's latest write failed,
+if the worker has sent nothing for 3 seconds, and once the worker has
+exited.
## Strings
@@ -148,6 +150,13 @@ On `/` and `/ready`:
(500).
- `store: <key> reads back different bytes`: the object came back changed
(500).
+- `worker: exited (<how>)`: the worker has exited. `<how>` is how it
+ ended, such as `exit status 1` or `signal: killed`, or `its status pipe
+ closed` when the app saw it go before learning how (500).
+- `worker: did not start: <error>`: the app could not start the worker
+ (500).
+- `worker: no status for <n>s`: the worker has not reported a write for 3
+ seconds or more, for example because it is stuck (500).
- `worker: no row committed since start`: the worker has not written a row
since the app started, for example in its first moments or if it did not
start (500).
@@ -164,7 +173,8 @@ On `/report`:
`count`), `file_key_sha256`, `platform_key_sha256`, `object` (with
`key`, `body` and, when it could not be read, `error`), `has_secret`,
`worker_pid`, `worker_writes_committed`, `worker_writes_failed`,
- `worker_failed_in_a_row` and `worker_last_error`.
+ `worker_failed_in_a_row`, `worker_last_error`, `worker_exit` and
+ `worker_last_status_at` (unix seconds, 0 before the first).
- `report: <error>`: a database read failed (500).
The values the app writes into the report itself:
@@ -186,6 +196,9 @@ The values the app writes into the report itself:
missing, or `get <key>: <error>` when the object cannot be read back.
- `worker_last_error`: the database's own error text for the worker's
latest failed write. The app adds no words of its own.
+- `worker_exit`: empty while the worker runs; otherwise
+ `exited (<how>)` or `did not start: <error>`, as on `/ready` without the
+ `worker: ` prefix.
On `/arm-once`:
diff --git a/cmd/moveprobe/main.go b/cmd/moveprobe/main.go
index e8d8e7380..a548d257c 100644
--- a/cmd/moveprobe/main.go
+++ b/cmd/moveprobe/main.go
@@ -73,6 +73,9 @@ const (
// evidence (seconds); short enough that an ordinary sleep, which stops
// only the main, does not leave it writing for ever.
workerGrace = 60 * time.Second
+ // workerStale is how long the main waits for the worker's next status
+ // line (one a second) before readiness fails.
+ workerStale = 3 * time.Second
storeWait = 10 * time.Second
modeNormal = "normal"
@@ -222,6 +225,8 @@ type app struct {
workerFailed int64
workerInARow int64
workerLastErr string
+ workerLastAt time.Time // the latest status line
+ workerExit string // why the worker is gone; empty while it runs
// control is the one client for the instance control socket, nil when
// the agent delivered none.
@@ -586,12 +591,24 @@ func (a *app) forkWorker() {
if err != nil {
status.Close()
logf("worker: %v", err)
+ a.mu.Lock()
+ a.workerExit = "did not start: " + err.Error()
+ a.mu.Unlock()
return
}
a.mu.Lock()
a.workerPID = cmd.Process.Pid
a.mu.Unlock()
- go cmd.Wait()
+ go func() {
+ err := cmd.Wait()
+ how := "exit status 0"
+ if err != nil {
+ how = err.Error()
+ }
+ a.mu.Lock()
+ a.workerExit = "exited (" + how + ")"
+ a.mu.Unlock()
+ }()
go a.readWorkerStatus(status)
}
@@ -602,6 +619,7 @@ func (a *app) readWorkerStatus(r *os.File) {
for sc.Scan() {
line := sc.Text()
a.mu.Lock()
+ a.workerLastAt = time.Now()
if msg, failed := strings.CutPrefix(line, "fail "); failed {
a.workerFailed++
a.workerInARow++
@@ -612,15 +630,26 @@ func (a *app) readWorkerStatus(r *os.File) {
}
a.mu.Unlock()
}
+ // The pipe closes when the worker exits; Wait's own account, when it
+ // comes, replaces this one.
+ a.mu.Lock()
+ if a.workerExit == "" {
+ a.workerExit = "exited (its status pipe closed)"
+ }
+ a.mu.Unlock()
}
-// checkWorker holds readiness to the worker: at least one of its rows has
-// committed since this start, and its latest write succeeded. A worker that
-// never started has committed nothing, so it fails too.
+// checkWorker holds readiness to the worker: it is still running and has
+// sent a status line within workerStale, at least one of its rows has
+// committed since this start, and its latest write succeeded.
func (a *app) checkWorker() error {
a.mu.Lock()
defer a.mu.Unlock()
switch {
+ case a.workerExit != "":
+ return errors.New("worker: " + a.workerExit)
+ case !a.workerLastAt.IsZero() && time.Since(a.workerLastAt) > workerStale:
+ return fmt.Errorf("worker: no status for %ds", int(time.Since(a.workerLastAt).Seconds()))
case a.workerCommitted == 0 && a.workerInARow > 0:
return fmt.Errorf("worker: no row committed since start; last error: %s", a.workerLastErr)
case a.workerCommitted == 0:
@@ -851,6 +880,8 @@ type report struct {
WorkerFailed int64 `json:"worker_writes_failed"`
WorkerInARow int64 `json:"worker_failed_in_a_row"`
WorkerLastError string `json:"worker_last_error"`
+ WorkerExit string `json:"worker_exit"`
+ WorkerLastStatus int64 `json:"worker_last_status_at"`
}
// counts reads the row counts and the timer fires. Any failed query, scan
@@ -909,6 +940,10 @@ func (a *app) handleReport(w http.ResponseWriter, r *http.Request) {
rep.WorkerPID = a.workerPID
rep.WorkerCommitted, rep.WorkerFailed = a.workerCommitted, a.workerFailed
rep.WorkerInARow, rep.WorkerLastError = a.workerInARow, a.workerLastErr
+ rep.WorkerExit = a.workerExit
+ if !a.workerLastAt.IsZero() {
+ rep.WorkerLastStatus = a.workerLastAt.Unix()
+ }
a.mu.Unlock()
if k, err := a.fileKeyNow(); err == nil {
rep.FileKeySHA256 = fingerprint(k)
diff --git a/cmd/moveprobe/main_test.go b/cmd/moveprobe/main_test.go
index 35ec45757..ff7624a30 100644
--- a/cmd/moveprobe/main_test.go
+++ b/cmd/moveprobe/main_test.go
@@ -376,6 +376,8 @@ type reportDoc struct {
WorkerWritesCommitted int64 `json:"worker_writes_committed"`
WorkerFailedInARow int64 `json:"worker_failed_in_a_row"`
WorkerLastError string `json:"worker_last_error"`
+ WorkerExit string `json:"worker_exit"`
+ WorkerLastStatusAt int64 `json:"worker_last_status_at"`
}
func (r reportDoc) count(kind, region string) int64 {
@@ -1245,3 +1247,49 @@ func TestArmOnceReusesOneControlConnection(t *testing.T) {
}
p.stop()
}
+
+// TestReadyFailsWhenTheWorkerStops: a worker that has died, or has sent no
+// status for 3 s, fails readiness, and the report says so.
+func TestReadyFailsWhenTheWorkerStops(t *testing.T) {
+ t.Run("the worker dies after its first good write", func(t *testing.T) {
+ f := newFixture(t)
+ p := f.start(f.env)
+ p.wait("/ready", http.StatusOK)
+ r := p.report()
+ if r.WorkerWritesCommitted == 0 || r.WorkerExit != "" || r.WorkerLastStatusAt == 0 {
+ t.Fatalf("before: committed %d, exit %q, last status %d", r.WorkerWritesCommitted, r.WorkerExit, r.WorkerLastStatusAt)
+ }
+ if err := syscall.Kill(r.WorkerPID, syscall.SIGKILL); err != nil {
+ t.Fatal(err)
+ }
+ body := p.wait("/ready", http.StatusInternalServerError)
+ if !strings.Contains(body, "worker: exited") {
+ t.Errorf("/ready after the worker died: %q", body)
+ }
+ if r := p.report(); !strings.Contains(r.WorkerExit, "exited") {
+ t.Errorf("report worker_exit %q after the worker died", r.WorkerExit)
+ }
+ p.stop()
+ })
+
+ t.Run("the worker sends no status for 3 s", func(t *testing.T) {
+ f := newFixture(t)
+ p := f.start(f.env)
+ p.wait("/ready", http.StatusOK)
+ pid := p.report().WorkerPID
+ if err := syscall.Kill(pid, syscall.SIGSTOP); err != nil {
+ t.Fatal(err)
+ }
+ stopped := time.Now()
+ body := p.wait("/ready", http.StatusInternalServerError)
+ if waited := time.Since(stopped); waited < 2*time.Second {
+ t.Errorf("/ready failed %s after the worker stopped, before 3 s without status", waited)
+ }
+ if !strings.Contains(body, "worker: no status for") {
+ t.Errorf("/ready with a stalled worker: %q", body)
+ }
+ syscall.Kill(pid, syscall.SIGCONT)
+ p.wait("/ready", http.StatusOK)
+ p.stop()
+ })
+}