carlos / platform Public

Take the worker respawn off the global mutex, and behind the lease

Paul Campbell pushed by paul@keymail.dev 58fe39dca7ae5b475c0fcd1efa038929847059a1 parent 7eaf3e7
2 files changed, +423 −118
  • internal/activator/activator.go +190 −97
  • internal/activator/activator_test.go +233 −21
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 4b8534902..a650a4c7b 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -330,11 +330,14 @@ type actEntry struct {
// run that lasted sidecarHealthyRun.
workerBackoff time.Duration
workerRetryAt time.Time
- // workerRespawning is set while convergeWorkerLocked's goroutine owns this
+ // workerRespawning is set while respawnWorkerLocked's goroutine owns this
// entry's child: the sweeper skips the entry entirely so the moment
// between the old child's stop and the new one's spawn — when the proc
- // reads as Exited — is not mistaken for a crash.
+ // reads as Exited — is not mistaken for a crash. respawnDone is closed
+ // when that goroutine finishes, and is what stop() waits on: a stop must
+ // not release the lease while a spawn against the same DB is in flight.
workerRespawning bool
+ respawnDone chan struct{}
}
// Sidecar restart pacing — Restart=always semantics without a supervisor: the
@@ -461,6 +464,10 @@ type Service struct {
mu sync.Mutex
entries map[string]*actEntry
+ // shuttingDown is set by Shutdown before it stops anything, and read by
+ // the supervised-respawn goroutines: the agent is going away, so nothing
+ // new may be spawned against a DB whose lease is about to be released.
+ shuttingDown bool
sweepStop chan struct{}
sweepDone chan struct{}
@@ -720,7 +727,6 @@ 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
@@ -752,10 +758,6 @@ 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
@@ -825,14 +827,21 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
if e.worker {
// 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.
+ // is a wake failure like any other: release and report.
token, err = newWorkerToken()
if err != nil {
_ = lease.Release(ctx)
return nil, fmt.Errorf("start %s: %w", host, err)
}
+ // Published onto the entry NOW, not at the awake transition: the
+ // child's first act is to call /v1/ready over the agent socket, which
+ // authorizes against WorkerToken — and that call is what ENDS the
+ // settle window. A token that only appeared once the entry was awake
+ // would make the early-ready path unreachable: the socket would
+ // refuse the very call it is waiting for, and every wake would pay the
+ // full window. Publishing early gives nothing away: a wake that fails
+ // deletes its entry, so the token still dies with it.
+ a.setWorkerToken(e, token)
proc, spawnVer, err = a.backend.StartWorker(ctx, rt, workerEnv(a.agentSocket(), host, token))
} else {
proc, spawnVer, err = a.backend.Start(ctx, rt)
@@ -876,7 +885,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, workerToken: token}
+ w := &wokenInstance{rep: rep, proc: proc, spawnVersion: spawnVer}
// 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
@@ -1069,6 +1078,24 @@ func (a *Service) WakeWorker(rt registry.Route) error {
switch e.state {
case actAwake:
+ // Same stand-down beginRequest makes: a lost lease or a dead child
+ // found at wake time turns into a stop, and the loop then re-wakes
+ // it. Without this, a boot pass or an operator command would report
+ // success for a worker whose lease is gone — the one answer that
+ // makes a broken sidecar look healthy. A respawn in flight is not a
+ // dead child: its proc is mid-replacement, so leave it be.
+ if !e.workerRespawning {
+ if repErr := e.rep.Err(); repErr != nil || e.proc.Exited() {
+ if repErr != nil {
+ log.Printf("activator: LEASE LOST for %s (%v) — standing down", e.host, repErr)
+ } else {
+ log.Printf("activator: worker %s exited — recycling", e.host)
+ }
+ a.beginStopLocked(e)
+ a.mu.Unlock()
+ continue
+ }
+ }
a.mu.Unlock()
return nil // already up: a no-op, not a second child
@@ -1123,26 +1150,21 @@ func workerEnv(socket, host, token string) map[string]string {
}
}
-// 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.
+// restartWorkerLocked replaces an ALWAYS-ON worker's dead child — 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.
+// paces itself instead of hot-looping the box. The pacing is decided here,
+// under the lock; the exec itself is not (see respawnWorkerLocked).
//
// 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.
+// runs with no lease churn.
func (a *Service) restartWorkerLocked(e *actEntry, now time.Time) {
if !e.spawnedAt.IsZero() && now.Sub(e.spawnedAt) >= sidecarHealthyRun {
e.workerBackoff = 0
@@ -1156,96 +1178,123 @@ func (a *Service) restartWorkerLocked(e *actEntry, now time.Time) {
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
+ log.Printf("activator: worker %s exited — restarting (backoff now %s)", e.host, e.workerBackoff)
+ // nil: this child is already dead, so there is nothing to retire first.
+ a.respawnWorkerLocked(e, nil, "exited")
}
// convergeWorkerLocked replaces an always-on worker's child with one exec'd
-// from the build live/<host> now points at, on the SAME entry — the caller
-// holds a.mu and has already established the drift persisted past
-// ConvergeGrace and that the per-sweep budget allows it.
+// from the build live/<host> now points at — the caller holds a.mu and has
+// already established the drift persisted past ConvergeGrace and that the
+// per-sweep budget allows it. The old child is retired first: it is alive, and
+// two incarnations of an outbound worker would double its work.
+func (a *Service) convergeWorkerLocked(e *actEntry) {
+ a.respawnWorkerLocked(e, e.proc, "converging onto a new build")
+}
+
+// workerSpawnTimeout bounds a supervised respawn's exec. It is generous
+// because ExecBackend.StartWorker may materialize the build from the bucket
+// first (a dangling live/<host> after a retention prune), but it is BOUNDED:
+// an unbounded spawn that never returns would leave workerRespawning set, and
+// the sweeper skips a respawning entry — no stand-down, no restart, no idle,
+// and nothing able to wake it. A wedged fetch must cost one entry a couple of
+// minutes, not the entry forever.
+const workerSpawnTimeout = 2 * time.Minute
+
+// respawnWorkerLocked replaces a worker's child on the SAME entry, keeping its
+// lease and replication: only the process is new. The caller holds a.mu.
+// stopOld is the child to retire first (nil when it is already dead).
//
-// Stop THEN spawn, and both off the lock, on their own goroutine: the old
-// child's SIGTERM drain is bounded but not instant, and nothing may hold a.mu
-// across it — every request wake on the box queues behind that lock. Stopping
-// first (rather than spawning first and stopping after) is deliberate: a
-// sidecar is an outbound worker, so two live incarnations would double its
-// work — a bot posting twice — where a brief gap costs only latency.
+// Everything expensive happens OFF a.mu, on its own goroutine. That is not a
+// stylistic preference: ExecBackend.StartWorker can materialize a build from
+// the bucket, and the SIGTERM drain of the outgoing child is bounded but not
+// instant — either one held across the global mutex would stall every request
+// wake, Owns and Shutdown on the box. (The co-spawn supervisor above DOES exec
+// under the lock; that is StartSidecar, which never fetches anything. The two
+// are not interchangeable.)
//
-// e.workerRespawning is the interlock. While it is set the sweeper skips the
-// entry entirely, so the mid-respawn moment when the old proc reads as Exited
-// is not mistaken for a crash (which would spawn a SECOND child). If the
-// entry is stopped or replaced while the respawn is in flight, the new child
-// is stopped instead of installed — a stop always wins.
+// Three orderings carry the safety:
+// - Retire, THEN spawn. A sidecar is an outbound worker: two live
+// incarnations double its work, where a gap costs only latency.
+// - Ask again, under the lock, immediately BEFORE spawning. A stop that
+// landed while the old child was draining has already released the lease,
+// and a child spawned after that would be writing the tenant's DB with no
+// single-writer claim behind it. Shedding it afterwards is not enough —
+// the agent may be gone by then.
+// - Check once more before INSTALLING, and shed the child if the entry was
+// stopped or replaced meanwhile. A stop always wins.
+//
+// e.workerRespawning is the sweeper's interlock (mid-respawn the entry's proc
+// reads as exited, which read as a crash would spawn a second child), and
+// e.respawnDone is what stop() and Shutdown wait on so a stop never completes
+// — nor the process exit — while a spawn is still in flight.
//
// A failed spawn deliberately leaves the dead child in place: the next sweep
-// sees Exited and the crash-restart path takes over, with its backoff. The
-// convergence attempt degrades into supervision rather than into a hole.
-func (a *Service) convergeWorkerLocked(e *actEntry) {
+// sees Exited and the crash-restart path takes over with its backoff. A failed
+// converge degrades into supervision rather than into a hole.
+func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string) {
e.workerRespawning = true
- old, rt, host := e.proc, e.route, e.host
+ e.respawnDone = make(chan struct{})
+ done, rt, host := e.respawnDone, e.route, e.host
go func() {
- stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout)
- if err := old.Stop(stopCtx); err != nil {
- log.Printf("activator: worker %s converge: stopping the old child: %v", host, err)
- }
- cancel()
-
var (
proc Proc
spawnVer string
+ tok string
+ err error
)
- tok, err := newWorkerToken()
- if err == nil {
- proc, spawnVer, err = a.backend.StartWorker(context.Background(), rt, workerEnv(a.agentSocket(), host, tok))
+ if stopOld != nil {
+ stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout)
+ if serr := stopOld.Stop(stopCtx); serr != nil {
+ log.Printf("activator: worker %s respawn: retiring the old child: %v", host, serr)
+ }
+ cancel()
+ }
+
+ // Still wanted? Asked HERE, before anything is spawned: past this
+ // point a stop can only be raced, never prevented.
+ a.mu.Lock()
+ wanted := a.entries[host] == e && e.state == actAwake && !a.shuttingDown
+ a.mu.Unlock()
+
+ if wanted {
+ ctx, cancel := context.WithTimeout(context.Background(), workerSpawnTimeout)
+ if tok, err = newWorkerToken(); err == nil {
+ proc, spawnVer, err = a.backend.StartWorker(ctx, rt, workerEnv(a.agentSocket(), host, tok))
+ }
+ cancel()
}
a.mu.Lock()
e.workerRespawning = false
- live := a.entries[host] == e && e.state == actAwake
- if err != nil {
+ close(done)
+ live := a.entries[host] == e && e.state == actAwake && !a.shuttingDown
+ switch {
+ case !wanted || !live:
a.mu.Unlock()
- log.Printf("activator: WORKER %s failed to respawn onto the new build: %v (the sweep's crash restart takes it from here)", host, err)
- emitEvent(a.Logs, rt, "error", "sidecar could not restart onto the new build — retrying")
- return
- }
- if !live {
- // Stopped or replaced under us — a stop always wins. Shed the
- // child we just made rather than installing it over the top.
+ log.Printf("activator: worker %s stopped mid-respawn (%s) — abandoning it", host, why)
+ if proc != nil {
+ // Spawned into a window that closed under us: shed it rather
+ // than leave a child nothing owns.
+ shedCtx, shedCancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
+ _ = proc.Stop(shedCtx)
+ shedCancel()
+ }
+ case err != nil:
a.mu.Unlock()
- log.Printf("activator: worker %s stopped mid-converge — discarding the respawned child", host)
- shedCtx, shedCancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
- _ = proc.Stop(shedCtx)
- shedCancel()
- return
+ log.Printf("activator: WORKER %s failed to respawn (%s): %v — the sweep's crash restart takes it from here", host, why, err)
+ emitEvent(a.Logs, rt, "error", "sidecar could not be restarted — retrying")
+ default:
+ e.proc = proc
+ e.workerToken = tok
+ e.spawnVersion = spawnVer
+ e.staleSince = time.Time{} // fresh exec: it cannot be stale yet
+ e.spawnedAt = a.now()
+ e.lastActive = a.now()
+ a.mu.Unlock()
+ log.Printf("activator: worker %s respawned (%s), now running %s", host, why, spawnVer)
+ emitEvent(a.Logs, rt, "info", "sidecar restarted")
}
- e.proc = proc
- e.workerToken = tok
- e.spawnVersion = spawnVer
- e.staleSince = time.Time{} // fresh exec: it cannot be stale yet
- e.spawnedAt = a.now()
- e.lastActive = a.now()
- a.mu.Unlock()
- log.Printf("activator: worker %s respawned onto %s", host, spawnVer)
- emitEvent(a.Logs, rt, "info", "sidecar restarted onto a new build")
}()
}
@@ -1321,19 +1370,35 @@ func (a *Service) SleepWorker(host string) {
a.beginStopLocked(e)
}
-// WorkerToken reports the live per-wake token for host, or "" — the agent
-// socket's authorization check. A token from a previous wake never matches,
-// and a host that is asleep, waking, stopping or not a worker at all has no
-// token, so every such call is refused.
+// WorkerToken reports the live per-incarnation token for host, or "" — the
+// agent socket's authorization check. A token from a previous wake, or from a
+// previous incarnation of a restarted child, never matches.
+//
+// It answers for a WAKING entry as well as an awake one, because the child's
+// first call — /v1/ready, which ends the settle window — necessarily arrives
+// while the wake is still resolving. Refusing then would make the early-ready
+// path unreachable and cost every wake the full window. Nothing else is
+// loosened: a stopping entry, an asleep host, an instance and an unknown host
+// all still get "", and a failed wake takes its token with it when the entry
+// is deleted.
func (a *Service) WorkerToken(host string) string {
a.mu.Lock()
defer a.mu.Unlock()
- if e := a.entries[host]; e != nil && e.worker && e.state == actAwake {
+ if e := a.entries[host]; e != nil && e.worker && (e.state == actAwake || e.state == actWaking) {
return e.workerToken
}
return ""
}
+// setWorkerToken publishes a wake's freshly minted credential onto the entry.
+// Its own critical section because doWake runs off a.mu — every other write to
+// an entry's fields is likewise made under the lock.
+func (a *Service) setWorkerToken(e *actEntry, tok string) {
+ a.mu.Lock()
+ e.workerToken = tok
+ a.mu.Unlock()
+}
+
// stop drives awake→asleep: stop the child (its SIGTERM drain is the final
// write), checkpoint what's on disk to S3 and release the lease, then forget
// the entry. The local DB stays in place — it's the warm cache for the next
@@ -1349,6 +1414,26 @@ func (a *Service) stop(e *actEntry) {
}
emitEvent(a.Logs, e.route, "info", stopping)
+ // Wait out a supervised respawn first. Everything below — the final
+ // checkpoint and the LEASE RELEASE — assumes no other process is being
+ // pointed at this DB; a respawn that finished after the release would run
+ // a child with no single-writer claim behind it, on the one path whose
+ // whole purpose is single-writer. The respawn's own pre-spawn check makes
+ // this wait short (it aborts as soon as it sees this state flip); the
+ // bound is insurance against a wedged exec, and instances never wait at
+ // all (respawnDone is nil for them).
+ a.mu.Lock()
+ waitRespawn := e.respawnDone
+ respawning := e.workerRespawning
+ a.mu.Unlock()
+ if respawning && waitRespawn != nil {
+ select {
+ case <-waitRespawn:
+ case <-time.After(workerSpawnTimeout + actStopTimeout):
+ log.Printf("activator: %s respawn did not finish before its stop — proceeding without it", e.host)
+ }
+ }
+
// Co-stop, sidecar FIRST — before the next-due probe and the instance's
// own SIGTERM. Two reasons: the sidecar must never outlive its instance
// (an orphan's reconnect loop would be a standing wake source — the
@@ -1729,6 +1814,14 @@ func (a *Service) Shutdown(ctx context.Context) {
// below, so peers stop proxying here immediately and their wakes succeed
// the moment each lease releases. Deploys, box failure drills, and
// scale-down all ride this same path. (`a.mesh.stop(ctx)` went here.)
+ // Announce the shutdown BEFORE anything stops: a supervised respawn in
+ // flight checks this flag immediately before it spawns, so it abandons
+ // rather than starting a child this shutdown would then have to chase.
+ // (stop() waits out any respawn that got past that check, so the waits
+ // below cover the rest.)
+ a.mu.Lock()
+ a.shuttingDown = true
+ a.mu.Unlock()
if a.sweepStop != nil {
close(a.sweepStop)
<-a.sweepDone
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index ef4d5fbf5..10f2ede05 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -203,6 +203,7 @@ type fakeBackend struct {
// sweep INSIDE a converge respawn's window (old child stopped, new one not
// yet installed) rather than hoping to hit it.
workerStartDelay time.Duration
+ workerEnterN int // StartWorker calls ENTERED (before the delay)
// order interleaves backend calls and proc stops (start:instance,
// start:sidecar, nextDue, stop:sidecar, stop:instance) for the ordering
// assertions.
@@ -325,6 +326,7 @@ func (b *fakeBackend) setSidecarErr(err error) {
func (b *fakeBackend) StartWorker(_ context.Context, rt registry.Route, env map[string]string) (Proc, string, error) {
b.mu.Lock()
delay := b.workerStartDelay
+ b.workerEnterN++ // counted BEFORE the delay: "the exec has begun"
b.mu.Unlock()
if delay > 0 {
time.Sleep(delay) // outside the fake's own lock: a slow exec, not a stuck fake
@@ -376,6 +378,14 @@ func (b *fakeBackend) workerStarts() int {
return b.workerStartN
}
+// workerEnters counts spawns that have BEGUN — the only way to know a test is
+// inside a slow exec rather than merely near one.
+func (b *fakeBackend) workerEnters() int {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.workerEnterN
+}
+
func (b *fakeBackend) startCount() int {
b.mu.Lock()
defer b.mu.Unlock()
@@ -2265,7 +2275,9 @@ func TestWorkerContactDefersSleep(t *testing.T) {
a.NoteWorkerContact("bot.x.internal")
clk.advance(9 * time.Second)
a.sweepOnce()
- if a.entryCount() != 1 {
+ // entryAwake, not entryCount: a condemned entry still counts until its
+ // stop goroutine finishes, so a count here passes either way.
+ if !a.entryAwake("bot.x.internal") {
t.Fatal("agent-socket contact must defer the idle clock — it is a worker's only sign of life")
}
}
@@ -2282,7 +2294,7 @@ func TestWorkerRouteIdleWindowOverridesPlatformDefault(t *testing.T) {
}
clk.advance(20 * time.Second)
a.sweepOnce()
- if a.entryCount() != 1 {
+ if !a.entryAwake("bot.x.internal") {
t.Fatal("worker slept before its own idle window elapsed")
}
clk.advance(11 * time.Second)
@@ -2344,6 +2356,32 @@ func TestWakeWorkerIsIdempotent(t *testing.T) {
}
}
+// An already-awake worker whose lease is gone is BROKEN, not up. WakeWorker
+// must stand it down and wake it afresh — reporting success to a boot pass or
+// an operator for a worker that cannot write is the one answer that makes a
+// dead sidecar look healthy.
+func TestWakeWorkerStandsDownALostLeaseAndRewakes(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ rt := testWorkerRoute("bot.x.internal")
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ b.lastRep().setErr(errors.New("lease renewal failed"))
+
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("second WakeWorker: %v", err)
+ }
+ if n := b.workerStarts(); n != 2 {
+ t.Fatalf("workerStarts = %d — a wake for a worker with a lost lease must recycle it, not report success", n)
+ }
+ if !a.entryAwake("bot.x.internal") {
+ t.Fatal("the re-wake did not leave the worker awake")
+ }
+ if acquires, _, _ := b.counts(); acquires != 2 {
+ t.Fatalf("lease acquires = %d, want 2 — the stand-down must re-claim the lease", acquires)
+ }
+}
+
// Concurrent wakes coalesce on one entry, the same singleflight the request
// path gets: three goroutines, one child, one lease.
func TestWakeWorkerCoalescesConcurrentCallers(t *testing.T) {
@@ -2447,6 +2485,45 @@ func TestDueSweepWakesASidecarAsAWorker(t *testing.T) {
// --- the settle window --------------------------------------------------------
+// noteReadyAsChild reports readiness the way the real child must: presenting
+// the token it was handed in its env, which the agent socket (Task 7) matches
+// against WorkerToken before accepting anything. Calling NoteWorkerReady
+// directly would walk around the authorization the feature actually depends
+// on — and hide a token that is not visible until the wake resolves, which
+// would make this whole early-ready path dead code in production.
+func noteReadyAsChild(t *testing.T, a *Service, b *fakeBackend, host, version string) {
+ t.Helper()
+ presented := b.lastWorkerEnv()["CARLOS_SIDECAR_TOKEN"]
+ live := a.WorkerToken(host)
+ if presented == "" || live == "" || presented != live {
+ t.Fatalf("the agent socket would REFUSE this child's ready call: it presents %q, WorkerToken says %q", presented, live)
+ }
+ a.NoteWorkerReady(host, version)
+}
+
+// The child's very first call arrives while its wake is still resolving — that
+// call is what ends the settle window — so its credential must be live then,
+// not only once the entry is awake.
+func TestWorkerTokenIsLiveDuringTheSettleWindow(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ old := sidecarSettle
+ sidecarSettle = 5 * time.Second
+ defer func() { sidecarSettle = old }()
+
+ done := make(chan error, 1)
+ go func() { done <- a.WakeWorker(testWorkerRoute("bot.x.internal")) }()
+ waitFor(t, "the waking entry to appear", func() bool { return a.Owns("bot.x.internal") })
+ waitFor(t, "the child to be spawned", func() bool { return b.workerStarts() == 1 })
+
+ if tok := a.WorkerToken("bot.x.internal"); tok == "" {
+ t.Fatal("no token during the settle window — the socket would refuse the /v1/ready call it is waiting for")
+ }
+ noteReadyAsChild(t, a, b, "bot.x.internal", "v9") // releases the wake
+ if err := <-done; err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+}
+
// 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
@@ -2471,6 +2548,12 @@ func TestWorkerExitInSettleWindowFailsWakeAndReleasesLease(t *testing.T) {
if !b.lastWorker().isStopped() {
t.Fatal("the half-started child was not stopped")
}
+ // The credential is published at mint time so the child can call
+ // /v1/ready during the window — a wake that FAILED must still take it
+ // away, which the entry's deletion does.
+ if tok := a.WorkerToken("bot.x.internal"); tok != "" {
+ t.Fatalf("WorkerToken = %q after a failed wake — a dead wake's credential must not stay live", tok)
+ }
}
// A child that survives the window is up — no probe, no readiness call needed.
@@ -2495,7 +2578,7 @@ func TestWorkerSurvivingSettleWindowIsAwake(t *testing.T) {
// …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)
+ a, b, _, _ := testActivator(t, time.Hour)
old := sidecarSettle
sidecarSettle = 5 * time.Second
defer func() { sidecarSettle = old }()
@@ -2505,7 +2588,7 @@ func TestWorkerReadyEndsSettleWindowEarly(t *testing.T) {
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")
+ noteReadyAsChild(t, a, b, "bot.x.internal", "v9")
select {
case err := <-done:
@@ -2538,11 +2621,11 @@ func TestAlwaysOnWorkerCrashRestartsWithGrowingBackoff(t *testing.T) {
}
// Crash 1: restarted at once, and the 2s delay is armed for the next.
+ // The spawn runs off a.mu (a bucket materialize must never be held across
+ // the global mutex), so the count is awaited, not read straight after.
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)
- }
+ waitForRestart(t, a, b, "bot.x.internal", 2)
if a.entryCount() != 1 {
t.Fatal("the crashed worker's entry was torn down — an always-on worker restarts in place")
}
@@ -2550,30 +2633,39 @@ func TestAlwaysOnWorkerCrashRestartsWithGrowingBackoff(t *testing.T) {
t.Fatalf("lease releases = %d — an in-place restart must not churn the lease", n)
}
- // Crash 2 inside the backoff: NOT restarted yet.
+ // Crash 2 inside the backoff: NOT restarted yet. entryRespawning is set
+ // synchronously under the sweep's own lock, so its absence right after the
+ // sweep is a fact, not a race.
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)
+ if a.entryRespawning("bot.x.internal") || b.workerStarts() != 2 {
+ t.Fatalf("workerStarts = %d — a crash inside the backoff must wait, not hot-loop", b.workerStarts())
}
clk.advance(2 * time.Second)
a.sweepOnce()
- if n := b.workerStarts(); n != 3 {
- t.Fatalf("workerStarts = %d after the backoff elapsed, want 3", n)
- }
+ waitForRestart(t, a, b, "bot.x.internal", 3)
// 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)
+ if a.entryRespawning("bot.x.internal") || b.workerStarts() != 3 {
+ t.Fatalf("workerStarts = %d — the backoff must double after each crash, not stay at %s", b.workerStarts(), 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)
- }
+ waitForRestart(t, a, b, "bot.x.internal", 4)
+}
+
+// waitForRestart waits until a supervised respawn has both spawned its child
+// and INSTALLED it — the install and the interlock clear happen in one
+// critical section, so waiting on the pair is what makes the next kill land on
+// the new child rather than the outgoing one.
+func waitForRestart(t *testing.T, a *Service, b *fakeBackend, host string, want int) {
+ t.Helper()
+ waitFor(t, fmt.Sprintf("restart #%d of %s", want, host), func() bool {
+ return b.workerStarts() == want && !a.entryRespawning(host)
+ })
}
// A run that lasted sidecarHealthyRun earns a fresh backoff — one crash a day
@@ -2585,15 +2677,14 @@ func TestAlwaysOnWorkerHealthyRunResetsBackoff(t *testing.T) {
}
b.lastWorker().kill()
a.sweepOnce() // restart 1 — backoff now 2s
+ waitForRestart(t, a, b, "bot.x.internal", 2)
// 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)
- }
+ waitForRestart(t, a, b, "bot.x.internal", 3)
}
// A restart is a new incarnation: a fresh token, so the dead child's
@@ -2606,6 +2697,7 @@ func TestAlwaysOnWorkerRestartMintsANewToken(t *testing.T) {
first := a.WorkerToken("bot.x.internal")
b.lastWorker().kill()
a.sweepOnce()
+ waitForRestart(t, a, b, "bot.x.internal", 2)
second := a.WorkerToken("bot.x.internal")
if second == "" || second == first {
@@ -2657,6 +2749,126 @@ func TestAlwaysOnWorkerIgnoresIdleClockAndMaxRun(t *testing.T) {
}
}
+// A supervised restart must not exec while holding the activator's global
+// mutex: ExecBackend.StartWorker materializes the build from the bucket when
+// live/<host> is dangling, and a slow fetch held across a.mu stalls every
+// request wake, Owns and Shutdown on the box.
+func TestWorkerCrashRestartSpawnsOffTheActivatorLock(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ b.mu.Lock()
+ b.workerStartDelay = 400 * time.Millisecond // a slow exec: a bucket fetch
+ b.mu.Unlock()
+ b.lastWorker().kill()
+
+ start := time.Now()
+ a.sweepOnce()
+ if elapsed := time.Since(start); elapsed > 150*time.Millisecond {
+ t.Fatalf("sweepOnce took %s — the sweeper is doing the exec itself, under a.mu", elapsed)
+ }
+ waitFor(t, "the restart to be in flight", func() bool { return a.entryRespawning("bot.x.internal") })
+
+ // The lock must be free for everyone else while the spawn runs.
+ start = time.Now()
+ owns := a.Owns("bot.x.internal")
+ if elapsed := time.Since(start); elapsed > 150*time.Millisecond {
+ t.Fatalf("Owns blocked %s behind a respawn — the spawn is holding the global mutex", elapsed)
+ }
+ if !owns {
+ t.Fatal("Owns lost the entry mid-restart")
+ }
+ waitFor(t, "the restart to land", func() bool { return b.workerStarts() == 2 })
+}
+
+// A respawn must never spawn a child after the lease has gone. Shutdown
+// announces itself first, so a respawn still in flight abandons rather than
+// starting a process that would write the tenant's DB with no single-writer
+// claim behind it — and possibly outlive the agent as an orphan.
+func TestShutdownStopsARespawnFromOutlivingTheLease(t *testing.T) {
+ a, b, clk, ver := shippingActivator(t, time.Hour)
+ if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ b.mu.Lock()
+ b.workerStartDelay = 500 * time.Millisecond
+ b.mu.Unlock()
+
+ ver.Store("v2")
+ a.sweepOnce()
+ clk.advance(ConvergeGrace + time.Minute)
+ a.sweepOnce() // starts the respawn
+ waitFor(t, "the respawn to be in flight", func() bool { return a.entryRespawning("bot.x.internal") })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ a.Shutdown(ctx)
+
+ if a.entryCount() != 0 {
+ t.Fatal("Shutdown returned with entries still live")
+ }
+ if n := b.workerStarts(); n != 1 {
+ t.Fatalf("workerStarts = %d at shutdown-return — a child was spawned after the lease was released", n)
+ }
+ // …and none appears afterwards either: the goroutine must have abandoned,
+ // not merely been outrun.
+ time.Sleep(700 * time.Millisecond)
+ if n := b.workerStarts(); n != 1 {
+ t.Fatalf("workerStarts = %d after shutdown — an orphan was spawned once the agent had gone", n)
+ }
+ for i, p := range b.workers {
+ if !p.isStopped() {
+ t.Fatalf("worker child %d was left running by Shutdown", i)
+ }
+ }
+}
+
+// A stop that lands AFTER a respawn has committed to spawning must not release
+// the lease under it: the child appearing moments later would write the
+// tenant's DB with no single-writer claim behind it. Bounce is the case
+// Shutdown's announcement does not cover.
+func TestStopWaitsOutAnInFlightRespawn(t *testing.T) {
+ a, b, clk, ver := shippingActivator(t, time.Hour)
+ if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ b.mu.Lock()
+ b.workerStartDelay = 400 * time.Millisecond
+ b.mu.Unlock()
+
+ ver.Store("v2")
+ a.sweepOnce()
+ clk.advance(ConvergeGrace + time.Minute)
+ a.sweepOnce()
+ // Inside the exec, i.e. PAST the respawn's own pre-spawn check: from here
+ // a stop can only be raced, which is what the wait exists for.
+ waitFor(t, "the respawn's exec to begin", func() bool { return b.workerEnters() == 2 })
+
+ if !a.Bounce("bot.x.internal") {
+ t.Fatal("Bounce found no awake worker to stop")
+ }
+ // The lease must still be held for as long as the exec is running. Watched
+ // against the SPAWN's own progress (workerStarts rises only when
+ // StartWorker returns), not against the entry: a stop that ignored the
+ // respawn would delete the entry in the same breath as closing the lease,
+ // leaving nothing to ask.
+ deadline := time.Now().Add(300 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if b.lastRep().isClosed() && b.workerStarts() < 2 {
+ t.Fatal("the lease was released while a respawn's exec was still running — the child appearing behind it would have no single-writer claim")
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ waitFor(t, "the stop to complete", func() bool { return a.entryCount() == 0 })
+ for i, p := range b.workers {
+ if !p.isStopped() {
+ t.Fatalf("worker child %d survived the stop", i)
+ }
+ }
+}
+
// --- always-on workers: converging onto a shipped build -----------------------
// shippingActivator wires the fake backend and the sweep's liveVer seam to ONE