carlos / platform Public

Supervise standalone sidecars: settle window, crash restart, no panic

Paul Campbell pushed by paul@keymail.dev 715df60ca9f044194e3566b33053f5226dd81bbf parent 46ca6f6
2 files changed, +546 −42
  • internal/activator/activator.go +252 −40
  • internal/activator/activator_test.go +294 −2
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index de4a994f0..2c4d8730c 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -309,18 +309,27 @@ type actEntry struct {
// proc, not a passenger on someone else's: the co-spawn fields above stay
// zero for it, and it never becomes a co-spawn parent either.
//
- // worker, workerIdle, workerMaxRun and workerToken are set once, at entry
+ // worker, workerIdle, workerMaxRun and readyc are set once, at entry
// construction, BEFORE the wake goroutine is launched, and never written
// again — so wake/doWake read them off-lock safely (the `go` statement is
- // the happens-before edge). spawnedAt and workerVersion are written under
- // a.mu like every other post-wake field.
+ // the happens-before edge). Everything else below is written under a.mu,
+ // like every other post-wake field: workerToken and spawnedAt at the awake
+ // transition and on a supervised restart, the rest by the agent socket.
worker bool // this entry is a sidecar: no socket, no requests
workerIdle time.Duration // 0 = fall back to cfg.Idle
workerMaxRun time.Duration // 0 = no ceiling
- workerToken string // the per-wake agent-socket token
+ readyc chan struct{} // closed when the child reports ready (ends the settle window)
+ readyNoted bool // guards readyc's one close
- spawnedAt time.Time // when this wake's child spawned — the max_run clock
+ workerToken string // the per-wake agent-socket token
+ spawnedAt time.Time // when this child spawned — the max_run and healthy-run clock
workerVersion string // the build the child reported over the agent socket
+
+ // Crash-restart pacing for an always-on worker, the co-spawn supervisor's
+ // rule applied to a standalone member: doubling, capped, and reset by a
+ // run that lasted sidecarHealthyRun.
+ workerBackoff time.Duration
+ workerRetryAt time.Time
}
// Sidecar restart pacing — Restart=always semantics without a supervisor: the
@@ -338,6 +347,30 @@ const (
// fallback behind this timeout is insurance.
const sidecarStopTimeout = 5 * time.Second
+// sidecarSettle is the standalone worker's readiness window — what stands in
+// for the health probe a child that binds nothing can never answer. A worker
+// is up once it either reports ready over the agent socket (NoteWorkerReady)
+// or survives this long without exiting; a child that exits INSIDE the window
+// is a failed wake, released and surfaced exactly like a failed instance
+// start. A var, not a const, so tests can shrink it — spawnHealthTimeout's
+// precedent in this package (backend_exec.go).
+var sidecarSettle = 10 * time.Second
+
+// settlePoll derives the settle window's liveness poll: Proc reports death
+// through Exited(), not a channel, so the window polls. Scaled off the window
+// so a shrunken test window still gets several looks, and capped so the
+// production window doesn't spin.
+func settlePoll(window time.Duration) time.Duration {
+ poll := window / 8
+ if poll > 250*time.Millisecond {
+ poll = 250 * time.Millisecond
+ }
+ if poll < time.Millisecond {
+ poll = time.Millisecond
+ }
+ return poll
+}
+
// Build convergence: how long an awake child may serve a symlink-stale binary
// before the sweep gracefully cycles it, and how many to cycle per tick.
// - ConvergeGrace comfortably exceeds one sweep interval (≤30s) and a
@@ -682,6 +715,7 @@ func (a *Service) wake(e *actEntry) {
// (it costs a field write) but read only for workers: an instance's
// run length is bounded by its idle window and its members.
e.spawnedAt = a.now()
+ e.workerToken = w.workerToken
// The build this child actually execed, captured at Start() inside the
// backend (not re-read here — a ship during the wake window would make a
// re-read name the new target while the process runs the old binary, and
@@ -713,6 +747,10 @@ type wokenInstance struct {
sidecar Proc
sidecarWanted bool
spawnVersion string // live/<host> target read at exec — for build convergence
+ // workerToken is this wake's agent-socket credential, minted inside the
+ // wake and carried out here so it is published onto the entry under a.mu,
+ // at the awake transition, and never for a wake that failed.
+ workerToken string
}
// doWake runs the side effects of one wake. It takes the ENTRY, not the bare
@@ -777,9 +815,20 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
var (
proc Proc
spawnVer string
+ token string
)
if e.worker {
- proc, spawnVer, err = a.backend.StartWorker(ctx, rt, a.workerEnv(e))
+ // Mint this wake's token here — under the lease, after the restore,
+ // immediately before the spawn (design §7.2's order). A mint failure
+ // is a wake failure like any other: release and report. It stays a
+ // local until the wake succeeds, so nothing publishes a credential for
+ // a child that never came up.
+ token, err = newWorkerToken()
+ if err != nil {
+ _ = lease.Release(ctx)
+ return nil, fmt.Errorf("start %s: %w", host, err)
+ }
+ proc, spawnVer, err = a.backend.StartWorker(ctx, rt, workerEnv(a.agentSocket(), host, token))
} else {
proc, spawnVer, err = a.backend.Start(ctx, rt)
}
@@ -788,6 +837,21 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
return nil, fmt.Errorf("start %s: %w", host, err)
}
+ // 4b. The settle window, workers only: the readiness check for a child
+ // with nothing to probe. A child that dies inside it never becomes an
+ // awake entry — the lease goes back and the wake reports, exactly as a
+ // failed Start does, so a sidecar that crashes on boot doesn't sit in the
+ // map holding a lease until someone notices.
+ if e.worker {
+ if serr := a.awaitWorkerSettle(ctx, e, proc); serr != nil {
+ stopCtx, cancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
+ _ = proc.Stop(stopCtx)
+ cancel()
+ _ = lease.Release(ctx)
+ return nil, fmt.Errorf("start %s: %w", host, serr)
+ }
+ }
+
// 5. Trade the held lease for running replication, at the route's
// replication-class sync interval (the per-plan durability ladder).
// The DB path rides the route; when empty the backend defaults it to
@@ -807,7 +871,7 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
// marked requests pass through the edge while we're awake). A spawn
// failure is loud but never fails the wake: members come before bots, and
// the sweeper retries with backoff.
- w := &wokenInstance{rep: rep, proc: proc, spawnVersion: spawnVer}
+ w := &wokenInstance{rep: rep, proc: proc, spawnVersion: spawnVer, workerToken: token}
// A worker is nobody's parent: it IS a sidecar, so it never co-spawns one.
// Skipped outright rather than left to HasSidecar's answer — the staging
// file is keyed by host, and a host that happened to have one staged must
@@ -898,24 +962,69 @@ func (a *Service) newWorkerEntry(rt registry.Route) *actEntry {
worker: true,
workerIdle: time.Duration(rt.IdleSeconds) * time.Second,
workerMaxRun: time.Duration(rt.MaxRunSeconds) * time.Second,
- workerToken: newWorkerToken(),
+ readyc: make(chan struct{}),
}
}
+// randRead is crypto/rand's entropy, behind a seam so a test can make minting
+// fail. Nothing else in this package should read entropy.
+var randRead = rand.Read
+
// newWorkerToken mints the per-wake agent-socket credential. A fresh one every
// wake is the point: a token from a previous incarnation of the child never
// matches, so an orphan that survived a stop cannot keep talking to the agent
-// as though it were the live worker. A rand failure is fatal to the wake, not
-// silently downgraded — an empty token would authorize nothing (WorkerToken's
-// "" is the refusal), but a PREDICTABLE one would authorize everyone.
-func newWorkerToken() string {
+// as though it were the live worker.
+//
+// It returns an error rather than panicking. The agent supervises every route
+// on the box — one host that cannot mint a token must fail ITS wake (the lease
+// released, like any other wake failure), never take the process, and every
+// other tenant on the box, down with it. Deliberately no fallback source: an
+// empty token would authorize nothing, but a predictable one would authorize
+// everyone.
+func newWorkerToken() (string, error) {
var b [32]byte
- if _, err := rand.Read(b[:]); err != nil {
- // crypto/rand.Read never returns an error on any platform we ship to;
- // if it somehow did, an unguessable-token-less wake must not proceed.
- panic("activator: crypto/rand failed: " + err.Error())
+ if _, err := randRead(b[:]); err != nil {
+ return "", fmt.Errorf("mint worker token: %w", err)
+ }
+ return hex.EncodeToString(b[:]), nil
+}
+
+// errWorkerExitedEarly: the child died inside its settle window. That is a
+// failed wake — a crash on boot (bad argv, missing config, panic at start) is
+// exactly what the instance path's health probe would have caught.
+var errWorkerExitedEarly = errors.New("worker exited during its settle window")
+
+// awaitWorkerSettle is the socketless child's health probe: it is up once it
+// says so over the agent socket, or once it has survived sidecarSettle without
+// exiting. Real time, not a.now — like waitHealthy, this bounds a real
+// process's first seconds, and the test clock exists to move idle windows, not
+// to model a boot.
+func (a *Service) awaitWorkerSettle(ctx context.Context, e *actEntry, proc Proc) error {
+ window := sidecarSettle
+ if window <= 0 {
+ return nil
+ }
+ deadline := time.NewTimer(window)
+ defer deadline.Stop()
+ tick := time.NewTicker(settlePoll(window))
+ defer tick.Stop()
+ for {
+ select {
+ case <-e.readyc:
+ return nil // the child reported ready: up, early
+ case <-deadline.C:
+ if proc.Exited() {
+ return errWorkerExitedEarly
+ }
+ return nil
+ case <-tick.C:
+ if proc.Exited() {
+ return errWorkerExitedEarly
+ }
+ case <-ctx.Done():
+ return ctx.Err()
+ }
}
- return hex.EncodeToString(b[:])
}
// WakeWorker brings a sidecar up, or does nothing if it is already awake.
@@ -984,23 +1093,86 @@ func (a *Service) WakeWorker(rt registry.Route) error {
}
}
-// workerEnv is what the wake hands the child: where to call back, who it is,
-// and this wake's credential. The backend applies it OVER the tenant's own env
-// file so a tenant cannot forge its own token (StartWorker's contract).
-func (a *Service) workerEnv(e *actEntry) map[string]string {
- sock := a.cfg.AgentSocket
- if sock == "" {
- sock = DefaultAgentSocket
+// agentSocket is where a worker child dials back.
+func (a *Service) agentSocket() string {
+ if a.cfg.AgentSocket == "" {
+ return DefaultAgentSocket
}
- name, _, _ := strings.Cut(e.host, ".") // the sidecar's declared name
+ return a.cfg.AgentSocket
+}
+
+// workerEnv is what a spawn hands the child: where to call back, who it is,
+// and this incarnation's credential. The backend applies it OVER the tenant's
+// own env file so a tenant cannot forge its own token (StartWorker's
+// contract). A free function of plain strings, not a method on the entry, so
+// the two callers — the wake (off-lock, with a token still in a local) and the
+// crash restart (under a.mu) — both compose it without touching entry state
+// they don't own.
+func workerEnv(socket, host, token string) map[string]string {
+ name, _, _ := strings.Cut(host, ".") // the sidecar's declared name
return map[string]string{
- "CARLOS_AGENT_SOCKET": sock,
- "CARLOS_SIDECAR_TOKEN": e.workerToken,
- "CARLOS_SIDECAR_HOST": e.host,
+ "CARLOS_AGENT_SOCKET": socket,
+ "CARLOS_SIDECAR_TOKEN": token,
+ "CARLOS_SIDECAR_HOST": host,
"CARLOS_SIDECAR_NAME": name,
}
}
+// restartWorkerLocked respawns an ALWAYS-ON worker whose child died, in place
+// — the caller holds a.mu and has already established that e is an awake
+// worker with `hibernate: false` and an exited proc.
+//
+// Restart=always without a supervisor, the co-spawn path's rule applied to a
+// standalone member: doubling backoff from sidecarRestartBase to
+// sidecarRestartMax, and a run that lasted sidecarHealthyRun earns a fresh
+// one, so a crash a day never escalates to the cap. The first crash respawns
+// immediately and ARMS the delay for the next one — a crash loop therefore
+// paces itself instead of hot-looping the box.
+//
+// In place, not through a stop and re-wake: the lease and the replication are
+// still ours (only the child died), and design §7.2 says an always-on worker
+// runs with no lease churn. Spawned under a.mu on purpose, exactly as the
+// co-spawn restart above is: an exec is quick, and the lock is what makes a
+// restart impossible to race with a stop.
+//
+// A new token every incarnation, and only ON SUCCESS: a failed respawn must
+// not invalidate the credential of a child that might still be limping along,
+// and the dead child's token must not survive into the next one.
+func (a *Service) restartWorkerLocked(e *actEntry, now time.Time) {
+ if !e.spawnedAt.IsZero() && now.Sub(e.spawnedAt) >= sidecarHealthyRun {
+ e.workerBackoff = 0
+ }
+ if now.Before(e.workerRetryAt) {
+ return // still backing off; the child stays dead until it elapses
+ }
+ if e.workerBackoff == 0 {
+ e.workerBackoff = sidecarRestartBase
+ } else {
+ e.workerBackoff = min(e.workerBackoff*2, sidecarRestartMax)
+ }
+ e.workerRetryAt = now.Add(e.workerBackoff)
+
+ tok, err := newWorkerToken()
+ if err != nil {
+ log.Printf("activator: WORKER %s restart: %v (next attempt in %s)", e.host, err, e.workerBackoff)
+ return
+ }
+ proc, spawnVer, err := a.backend.StartWorker(context.Background(), e.route, workerEnv(a.agentSocket(), e.host, tok))
+ if err != nil {
+ log.Printf("activator: WORKER %s exited and failed to restart: %v (next attempt in %s)", e.host, err, e.workerBackoff)
+ emitEvent(a.Logs, e.route, "error", "sidecar exited and could not be restarted — retrying")
+ return
+ }
+ log.Printf("activator: worker %s exited — restarted (backoff now %s)", e.host, e.workerBackoff)
+ emitEvent(a.Logs, e.route, "error", "sidecar exited — restarted")
+ e.proc = proc
+ e.workerToken = tok
+ e.spawnVersion = spawnVer
+ e.staleSince = time.Time{} // fresh exec: it can't be stale yet
+ e.spawnedAt = now
+ e.lastActive = now
+}
+
// NoteWorkerContact stamps last-activity from an agent-socket call. It is a
// worker's only sign of life: nothing routes to a sidecar, so no request will
// ever stamp one, and without contact the idle clock runs to its window and
@@ -1013,17 +1185,28 @@ func (a *Service) NoteWorkerContact(host string) {
}
}
-// NoteWorkerReady records a worker's own "I am up" — last-activity plus the
-// build it reports running. Deliberately the child's claim, not ours: §9.2's
-// honesty rule is that a sidecar which never reports shows unknown, never
-// "current". (Task 8 additionally persists the version through the registry's
-// version setter; this is the in-memory half the agent socket needs today.)
+// NoteWorkerReady records a worker's own "I am up" — last-activity, the build
+// it reports running, and the end of its settle window. Deliberately the
+// child's claim, not ours: §9.2's honesty rule is that a sidecar which never
+// reports shows unknown, never "current". (Task 8 additionally persists the
+// version through the registry's version setter; this is the in-memory half
+// the agent socket needs today.)
+//
+// It acts on a WAKING entry as well as an awake one — that is the whole point
+// of the settle window: a child that boots fast says so and its wake resolves
+// immediately instead of waiting out sidecarSettle.
func (a *Service) NoteWorkerReady(host, version string) {
a.mu.Lock()
defer a.mu.Unlock()
- if e := a.entries[host]; e != nil && e.worker {
- e.lastActive = a.now()
- e.workerVersion = version
+ e := a.entries[host]
+ if e == nil || !e.worker {
+ return
+ }
+ e.lastActive = a.now()
+ e.workerVersion = version
+ if !e.readyNoted {
+ e.readyNoted = true
+ close(e.readyc)
}
}
@@ -1204,6 +1387,17 @@ func (a *Service) sweepOnce() {
continue
}
if e.proc.Exited() {
+ // An ALWAYS-ON worker's exit is a crash: `hibernate: false` means
+ // the record exists ⇒ the process runs, so it is restarted in
+ // place — same supervision as the co-spawn path, and no lease
+ // churn, because only the child died (the lease and replication
+ // are still ours). A HIBERNATING worker's exit means "I finished
+ // my work", and the stand-down below is exactly right for it —
+ // as it is for an instance.
+ if e.worker && !e.route.Hibernate {
+ a.restartWorkerLocked(e, now)
+ continue
+ }
log.Printf("activator: instance %s exited unexpectedly — recycling", e.host)
a.beginStopLocked(e)
continue
@@ -1217,20 +1411,28 @@ func (a *Service) sweepOnce() {
// lastActive is stamped by agent-socket contact instead of by a
// released request, so the same expression means the right thing for
// both.
+ //
+ // An ALWAYS-ON worker has no idle clock at all (design §7.2:
+ // "hibernate: false … no idle clock, no lease churn"), and no run
+ // ceiling either. Both would strand it: a stopped always-on worker has
+ // no wake source — no request can reach it, and it has no schedule —
+ // so it would stay down until the agent restarted. An instance is
+ // never in this arm: unflagged instances don't enter the activator.
+ hibernates := !e.worker || e.route.Hibernate
idleWindow := a.cfg.Idle
if e.workerIdle > 0 {
idleWindow = e.workerIdle
}
- if e.inflight == 0 && now.Sub(e.lastActive) >= idleWindow {
+ if hibernates && e.inflight == 0 && now.Sub(e.lastActive) >= idleWindow {
log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second))
a.beginStopLocked(e)
continue
}
// A worker that never reports idle still has a ceiling: a wedged
// reconnect loop must not hold a lease and a box forever. Zero means
- // no ceiling — an always-on sidecar is a legitimate shape, so this
- // must never invent one nobody declared.
- if e.worker && e.workerMaxRun > 0 && now.Sub(e.spawnedAt) >= e.workerMaxRun {
+ // no ceiling — a sidecar that declared none is a legitimate shape, so
+ // this must never invent one nobody asked for.
+ if e.worker && hibernates && e.workerMaxRun > 0 && now.Sub(e.spawnedAt) >= e.workerMaxRun {
log.Printf("activator: %s hit its max_run of %s — stopping", e.host, e.workerMaxRun)
a.beginStopLocked(e)
continue
@@ -1253,6 +1455,16 @@ func (a *Service) sweepOnce() {
cur, readErr := a.liveVer(e.host)
cycle, next := ConvergeDecision(cur, readErr, e.spawnVersion, e.staleSince, now, ConvergeGrace)
e.staleSince = next
+ // An always-on worker is NOT cycled here: the cycle's second half is
+ // "something wakes it back up", and for a worker with no request path
+ // and no schedule there is nothing to. It would convergence-cycle
+ // itself off the box. It converges instead at the agent's next restart
+ // or through `carlos restart` (Task 11) — a real gap, deliberately
+ // left visible rather than closed with a stop that strands a member's
+ // bot.
+ if cycle && e.worker && !e.route.Hibernate {
+ cycle = false
+ }
if cycle && cycled < maxConvergeCyclesPerSweep {
log.Printf("activator: %s serving %s but live symlink is now %s — cycling onto the new build", e.host, e.spawnVersion, cur)
a.beginStopLocked(e)
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index 0f12df616..4cdd233f6 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -183,6 +183,10 @@ type fakeBackend struct {
workers []*fakeProc
workerEnvs []map[string]string
workerStartN int
+ // workerDeadOnStart hands back a child that is ALREADY dead — the sidecar
+ // that crashes on boot, which the settle window exists to catch.
+ workerDeadOnStart bool
+ workerSpawnVer string // what StartWorker reports as the build it execed
// order interleaves backend calls and proc stops (start:instance,
// start:sidecar, nextDue, stop:sidecar, stop:instance) for the ordering
// assertions.
@@ -311,9 +315,27 @@ func (b *fakeBackend) StartWorker(_ context.Context, rt registry.Route, env map[
if b.workerErr != nil {
return nil, "", b.workerErr
}
- p := &fakeProc{label: "worker", rec: b}
+ p := &fakeProc{label: "worker", rec: b, dead: b.workerDeadOnStart}
b.workers = append(b.workers, p)
- return p, "", nil
+ return p, b.workerSpawnVer, nil
+}
+
+// lastWorker is the most recently spawned worker child.
+func (b *fakeBackend) lastWorker() *fakeProc {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if len(b.workers) == 0 {
+ return nil
+ }
+ return b.workers[len(b.workers)-1]
+}
+
+// kill models the child dying on its own (a crash), which the sweep notices
+// through Exited().
+func (p *fakeProc) kill() {
+ p.mu.Lock()
+ p.dead = true
+ p.mu.Unlock()
}
// workerStarts counts StartWorker calls; startCount counts Start (the socket
@@ -2373,6 +2395,271 @@ func TestDueSweepWakesASidecarAsAWorker(t *testing.T) {
}
}
+// --- the settle window --------------------------------------------------------
+
+// A worker binds nothing, so there is no socket to probe: readiness is the
+// settle window. A child that exits INSIDE it never became a working sidecar —
+// that is a failed wake, and it must give the lease back exactly as a failed
+// instance start does, not sit in the map holding one.
+func TestWorkerExitInSettleWindowFailsWakeAndReleasesLease(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ b.workerDeadOnStart = true
+
+ err := a.WakeWorker(testWorkerRoute("bot.x.internal"))
+ if err == nil {
+ t.Fatal("a worker that died inside its settle window reported a successful wake")
+ }
+ if !errors.Is(err, errWorkerExitedEarly) {
+ t.Fatalf("wake error = %v, want it to name the settle-window exit", err)
+ }
+ if a.entryCount() != 0 {
+ t.Fatal("a failed settle left an entry behind")
+ }
+ if n := b.releaseCount(); n != 1 {
+ t.Fatalf("lease releases = %d, want exactly 1", n)
+ }
+ if !b.lastWorker().isStopped() {
+ t.Fatal("the half-started child was not stopped")
+ }
+}
+
+// A child that survives the window is up — no probe, no readiness call needed.
+func TestWorkerSurvivingSettleWindowIsAwake(t *testing.T) {
+ a, _, _, _ := testActivator(t, time.Hour)
+ old := sidecarSettle
+ sidecarSettle = 60 * time.Millisecond
+ defer func() { sidecarSettle = old }()
+
+ start := time.Now()
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ if elapsed := time.Since(start); elapsed < 50*time.Millisecond {
+ t.Fatalf("wake resolved in %s — a worker nobody vouched for must survive the whole settle window first", elapsed)
+ }
+ if a.WorkerToken("bot.x.internal") == "" {
+ t.Fatal("the worker is not awake after surviving its settle window")
+ }
+}
+
+// …and a child that reports ready ends the window early: a fast sidecar must
+// not pay ten seconds of dead time on every wake.
+func TestWorkerReadyEndsSettleWindowEarly(t *testing.T) {
+ a, _, _, _ := testActivator(t, time.Hour)
+ old := sidecarSettle
+ sidecarSettle = 5 * time.Second
+ defer func() { sidecarSettle = old }()
+
+ done := make(chan error, 1)
+ start := time.Now()
+ go func() { done <- a.WakeWorker(testWorkerRoute("bot.x.internal")) }()
+
+ waitFor(t, "the waking entry to appear", func() bool { return a.Owns("bot.x.internal") })
+ a.NoteWorkerReady("bot.x.internal", "v9")
+
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("a ready report did not end the settle window — the wake waited it out")
+ }
+ if elapsed := time.Since(start); elapsed > 2*time.Second {
+ t.Fatalf("wake took %s despite a ready report", elapsed)
+ }
+}
+
+// --- always-on workers: supervision ------------------------------------------
+
+func alwaysOnWorkerRoute(host string) registry.Route {
+ rt := testWorkerRoute(host)
+ rt.Hibernate = false // the record exists ⇒ the process runs
+ return rt
+}
+
+// An always-on worker's exit is a CRASH, not a finished job: it is restarted
+// in place, keeping its lease and replication — and the backoff GROWS, so a
+// crash-looping bot paces itself instead of hot-looping the box.
+func TestAlwaysOnWorkerCrashRestartsWithGrowingBackoff(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+
+ // Crash 1: restarted at once, and the 2s delay is armed for the next.
+ b.lastWorker().kill()
+ a.sweepOnce()
+ if n := b.workerStarts(); n != 2 {
+ t.Fatalf("workerStarts = %d after the first crash, want 2 (an always-on worker's exit is a crash)", n)
+ }
+ if a.entryCount() != 1 {
+ t.Fatal("the crashed worker's entry was torn down — an always-on worker restarts in place")
+ }
+ if n := b.releaseCount(); n != 0 {
+ t.Fatalf("lease releases = %d — an in-place restart must not churn the lease", n)
+ }
+
+ // Crash 2 inside the backoff: NOT restarted yet.
+ b.lastWorker().kill()
+ a.sweepOnce()
+ if n := b.workerStarts(); n != 2 {
+ t.Fatalf("workerStarts = %d — a crash inside the backoff must wait, not hot-loop", n)
+ }
+ clk.advance(2 * time.Second)
+ a.sweepOnce()
+ if n := b.workerStarts(); n != 3 {
+ t.Fatalf("workerStarts = %d after the backoff elapsed, want 3", n)
+ }
+
+ // Crash 3: the delay has DOUBLED — 2s is no longer enough.
+ b.lastWorker().kill()
+ clk.advance(2 * time.Second)
+ a.sweepOnce()
+ if n := b.workerStarts(); n != 3 {
+ t.Fatalf("workerStarts = %d — the backoff must double after each crash, not stay at %s", n, sidecarRestartBase)
+ }
+ clk.advance(3 * time.Second)
+ a.sweepOnce()
+ if n := b.workerStarts(); n != 4 {
+ t.Fatalf("workerStarts = %d once the doubled backoff elapsed, want 4", n)
+ }
+}
+
+// A run that lasted sidecarHealthyRun earns a fresh backoff — one crash a day
+// must never escalate to the cap (the co-spawn supervisor's rule).
+func TestAlwaysOnWorkerHealthyRunResetsBackoff(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ b.lastWorker().kill()
+ a.sweepOnce() // restart 1 — backoff now 2s
+
+ // The replacement runs healthily for well past the healthy-run mark, then
+ // dies: the next restart is immediate, not delayed.
+ clk.advance(sidecarHealthyRun + time.Minute)
+ b.lastWorker().kill()
+ a.sweepOnce()
+ if n := b.workerStarts(); n != 3 {
+ t.Fatalf("workerStarts = %d — a crash after a healthy run must restart immediately on a reset backoff", n)
+ }
+}
+
+// A restart is a new incarnation: a fresh token, so the dead child's
+// credential can never speak for the live one.
+func TestAlwaysOnWorkerRestartMintsANewToken(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ first := a.WorkerToken("bot.x.internal")
+ b.lastWorker().kill()
+ a.sweepOnce()
+
+ second := a.WorkerToken("bot.x.internal")
+ if second == "" || second == first {
+ t.Fatalf("token after restart = %q (was %q) — every incarnation gets its own", second, first)
+ }
+ if env := b.lastWorkerEnv(); env["CARLOS_SIDECAR_TOKEN"] != second {
+ t.Fatalf("restarted child was handed %q but the socket expects %q", env["CARLOS_SIDECAR_TOKEN"], second)
+ }
+}
+
+// A HIBERNATING worker's exit means "I finished my work" — the existing
+// dead-child stand-down is exactly right, and must not become a restart loop.
+func TestHibernatingWorkerExitSleepsRatherThanRestarting(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil { // Hibernate: true
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ b.lastWorker().kill()
+ a.sweepOnce()
+
+ waitFor(t, "the finished worker to sleep", func() bool { return a.entryCount() == 0 })
+ if n := b.workerStarts(); n != 1 {
+ t.Fatalf("workerStarts = %d — a hibernating worker that exited has FINISHED; it must not be restarted", n)
+ }
+ if !b.lastRep().isClosed() {
+ t.Fatal("the lease was not released when the finished worker stopped")
+ }
+}
+
+// An always-on worker has no idle clock and no run ceiling: a stopped one has
+// no wake source at all (no request can reach it, and it has no schedule), so
+// either would strand it until the agent restarted.
+func TestAlwaysOnWorkerIgnoresIdleClockAndMaxRun(t *testing.T) {
+ a, _, clk, _ := testActivator(t, 10*time.Second)
+ rt := alwaysOnWorkerRoute("bot.x.internal")
+ rt.IdleSeconds = 5
+ rt.MaxRunSeconds = 30
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+
+ clk.advance(time.Hour) // far past both the idle window and the ceiling
+ a.sweepOnce()
+ // entryAwake, not entryCount: beginStopLocked flips the state under the
+ // lock and stops on its own goroutine, so a count taken right after the
+ // sweep would still see the entry it just condemned.
+ if !a.entryAwake("bot.x.internal") {
+ t.Fatal("an always-on worker was stopped by an idle window or a run ceiling — nothing would ever wake it again")
+ }
+}
+
+// Convergence cycles a stale child by STOPPING it and letting the next wake
+// exec the new build. An always-on worker has no next wake, so cycling it
+// would take it off the box for good; it is left running instead (it converges
+// at the agent's next restart).
+func TestAlwaysOnWorkerIsNotConvergenceCycled(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Hour)
+ var ver atomic.Value
+ ver.Store("v1")
+ b.workerSpawnVer = "v1"
+ a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
+
+ if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ ver.Store("v2") // a ship repoints the symlink under the running child
+ a.sweepOnce() // first sighting: starts the grace clock, never cycles
+ clk.advance(ConvergeGrace + time.Minute)
+ a.sweepOnce() // the drift has now persisted past the grace
+
+ if !a.entryAwake("bot.x.internal") {
+ t.Fatal("an always-on worker was convergence-cycled — nothing would wake it onto the new build")
+ }
+}
+
+// --- token minting ------------------------------------------------------------
+
+// The agent supervises every route on the box. A wake that cannot mint a token
+// must fail THAT wake — lease released, error reported — never take the
+// process down with it.
+func TestWorkerTokenMintFailureFailsWakeAndReleasesLease(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ old := randRead
+ randRead = func([]byte) (int, error) { return 0, errors.New("entropy pool exhausted") }
+ defer func() { randRead = old }()
+
+ err := a.WakeWorker(testWorkerRoute("bot.x.internal"))
+ if err == nil {
+ t.Fatal("a wake with no token reported success")
+ }
+ if !strings.Contains(err.Error(), "mint worker token") {
+ t.Fatalf("wake error = %v, want it to name the mint failure", err)
+ }
+ if a.entryCount() != 0 {
+ t.Fatal("the failed wake left an entry behind")
+ }
+ if n := b.releaseCount(); n != 1 {
+ t.Fatalf("lease releases = %d, want exactly 1 — a mint failure is a wake failure like any other", n)
+ }
+ if n := b.workerStarts(); n != 0 {
+ t.Fatalf("workerStarts = %d — nothing may be spawned without a token to authorize it", n)
+ }
+}
+
func TestConfigFromEnv(t *testing.T) {
t.Setenv("CARLOS_HIBERNATE_BUCKET", "")
if cfg, err := ConfigFromEnv(); err != nil || cfg != nil {
@@ -3671,6 +3958,11 @@ func TestMain(m *testing.M) {
fakeAppMain()
return
}
+ // A worker's wake waits out its settle window unless the child reports
+ // ready, and no fake child does. Shrink it once for the whole package —
+ // the tests that are ABOUT the window set their own value and restore it
+ // (spawnHealthTimeout's pattern in backend_unit_test.go).
+ sidecarSettle = 20 * time.Millisecond
os.Exit(m.Run())
}