| index 4cdd233f6..ef4d5fbf5 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -87,12 +87,24 @@ func (p *fakeProc) Stop(context.Context) error { |
| } |
| p.mu.Lock() |
| p.stopped = true |
| + // A stopped process HAS exited: modelling that is what lets a test see the |
| + // window a converge respawn opens — old child down, new one not yet |
| + // installed — which the sweeper must not mistake for a crash. |
| + p.dead = true |
| p.mu.Unlock() |
| if p.rec != nil { |
| p.rec.record("stop:" + p.label) |
| } |
| return nil |
| } |
| + |
| +// setStopGate installs (or clears) the gate Stop blocks on, under the proc's |
| +// own lock — the sweeper may be reading it concurrently. |
| +func (p *fakeProc) setStopGate(gate chan struct{}) { |
| + p.mu.Lock() |
| + p.stopGate = gate |
| + p.mu.Unlock() |
| +} |
| func (p *fakeProc) Exited() bool { |
| p.mu.Lock() |
| defer p.mu.Unlock() |
| @@ -187,6 +199,10 @@ type fakeBackend struct { |
| // that crashes on boot, which the settle window exists to catch. |
| workerDeadOnStart bool |
| workerSpawnVer string // what StartWorker reports as the build it execed |
| + // workerStartDelay holds StartWorker open for a beat, so a test can land a |
| + // sweep INSIDE a converge respawn's window (old child stopped, new one not |
| + // yet installed) rather than hoping to hit it. |
| + workerStartDelay time.Duration |
| // order interleaves backend calls and proc stops (start:instance, |
| // start:sidecar, nextDue, stop:sidecar, stop:instance) for the ordering |
| // assertions. |
| @@ -307,6 +323,12 @@ 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.mu.Unlock() |
| + if delay > 0 { |
| + time.Sleep(delay) // outside the fake's own lock: a slow exec, not a stuck fake |
| + } |
| b.mu.Lock() |
| defer b.mu.Unlock() |
| b.workerStartN++ |
| @@ -317,7 +339,14 @@ func (b *fakeBackend) StartWorker(_ context.Context, rt registry.Route, env map[ |
| } |
| p := &fakeProc{label: "worker", rec: b, dead: b.workerDeadOnStart} |
| b.workers = append(b.workers, p) |
| - return p, b.workerSpawnVer, nil |
| + // Same symlink model as Start: execVerFn, when wired, is the live/<host> |
| + // target this exec would have read — so a convergence test can move the |
| + // symlink and have the respawned child report the new build. |
| + ver := b.workerSpawnVer |
| + if b.execVerFn != nil { |
| + ver = b.execVerFn() |
| + } |
| + return p, ver, nil |
| } |
| |
| // lastWorker is the most recently spawned worker child. |
| @@ -1658,6 +1687,27 @@ func TestOwnsCoversEntryLifetimeAnyState(t *testing.T) { |
| |
| // entryAwake reports whether host has a live entry in the awake state — how the |
| // due tests observe an async due-time wake resolving. |
| +// entryRespawning: whether a converge respawn currently owns this entry's |
| +// child — the interlock the sweeper honours. |
| +func (a *Service) entryRespawning(host string) bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + e := a.entries[host] |
| + return e != nil && e.workerRespawning |
| +} |
| + |
| +// entrySpawnVersion: the build the host's live child was exec'd from, "" if |
| +// there is no entry — how a test sees that a respawn actually landed on the |
| +// new binary rather than merely happening. |
| +func (a *Service) entrySpawnVersion(host string) string { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + if e := a.entries[host]; e != nil { |
| + return e.spawnVersion |
| + } |
| + return "" |
| +} |
| + |
| func (a *Service) entryAwake(host string) bool { |
| a.mu.Lock() |
| defer a.mu.Unlock() |
| @@ -2607,27 +2657,194 @@ func TestAlwaysOnWorkerIgnoresIdleClockAndMaxRun(t *testing.T) { |
| } |
| } |
| |
| -// 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) |
| +// --- always-on workers: converging onto a shipped build ----------------------- |
| + |
| +// shippingActivator wires the fake backend and the sweep's liveVer seam to ONE |
| +// modelled live/<host> symlink, so a test can "ship" by moving it: a child |
| +// execs whatever it points at now, and the sweep reads the same source. |
| +func shippingActivator(t *testing.T, idle time.Duration) (*Service, *fakeBackend, *fakeClock, *atomic.Value) { |
| + t.Helper() |
| + a, b, clk, _ := testActivator(t, idle) |
| var ver atomic.Value |
| ver.Store("v1") |
| - b.workerSpawnVer = "v1" |
| + b.execVerFn = func() string { return ver.Load().(string) } |
| a.liveVer = func(string) (string, error) { return ver.Load().(string), nil } |
| + return a, b, clk, &ver |
| +} |
| |
| +// A running child never re-reads live/<host>, and an always-on worker never |
| +// sleeps, so without this it would serve the old binary forever while |
| +// `carlos deploy` reported success. It converges as one MOVE — stop, respawn, |
| +// same entry, same lease — because the ordinary cycle's second half ("a |
| +// request wakes it onto the new build") does not exist for a worker. |
| +func TestAlwaysOnWorkerRespawnsOntoANewBuild(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) |
| } |
| + if got := a.entrySpawnVersion("bot.x.internal"); got != "v1" { |
| + t.Fatalf("spawn version = %q, want v1", got) |
| + } |
| + first := b.lastWorker() |
| + |
| ver.Store("v2") // a ship repoints the symlink under the running child |
| - a.sweepOnce() // first sighting: starts the grace clock, never cycles |
| + a.sweepOnce() // first sighting: starts the grace clock, never acts |
| + // entryRespawning, not a spawn count: convergeWorkerLocked sets the |
| + // interlock synchronously, under the same lock the sweep holds, so this |
| + // sees a respawn that has merely been STARTED — a count would race the |
| + // goroutine and pass whether or not the grace window was honoured. |
| + if a.entryRespawning("bot.x.internal") { |
| + t.Fatal("a respawn began on the drift's first sighting — the ConvergeGrace window must pass first") |
| + } |
| + if n := b.workerStarts(); n != 1 { |
| + t.Fatalf("workerStarts = %d — the grace window must pass before anything is respawned", n) |
| + } |
| clk.advance(ConvergeGrace + time.Minute) |
| - a.sweepOnce() // the drift has now persisted past the grace |
| + a.sweepOnce() |
| |
| + waitFor(t, "the respawn onto v2", func() bool { return a.entrySpawnVersion("bot.x.internal") == "v2" }) |
| + if n := b.workerStarts(); n != 2 { |
| + t.Fatalf("workerStarts = %d, want 2 (one respawn)", n) |
| + } |
| + if !first.isStopped() { |
| + t.Fatal("the old child was left running beside its replacement — a sidecar would double its work") |
| + } |
| if !a.entryAwake("bot.x.internal") { |
| - t.Fatal("an always-on worker was convergence-cycled — nothing would wake it onto the new build") |
| + t.Fatal("the worker is not awake after converging — the respawn must land on the SAME entry") |
| + } |
| + if b.lastRep().isClosed() || b.releaseCount() != 0 { |
| + t.Fatal("converging respawned through a stop: the lease must not churn for an always-on worker") |
| + } |
| + // The new child holds the live token, so the agent socket still knows it. |
| + if tok := a.WorkerToken("bot.x.internal"); tok == "" || b.lastWorkerEnv()["CARLOS_SIDECAR_TOKEN"] != tok { |
| + t.Fatal("the respawned child was not handed the token the socket now expects") |
| + } |
| +} |
| + |
| +// The one-per-sweep budget still applies: a bad ship must not respawn every |
| +// sidecar on the box at once. |
| +func TestWorkerConvergeRespawnIsRateLimitedPerSweep(t *testing.T) { |
| + a, b, clk, ver := shippingActivator(t, time.Hour) |
| + for _, host := range []string{"bot-a.x.internal", "bot-b.x.internal"} { |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatalf("WakeWorker %s: %v", host, err) |
| + } |
| + } |
| + ver.Store("v2") |
| + a.sweepOnce() // grace clocks start for both |
| + clk.advance(ConvergeGrace + time.Minute) |
| + |
| + a.sweepOnce() |
| + waitFor(t, "one respawn", func() bool { return b.workerStarts() == 3 }) |
| + time.Sleep(100 * time.Millisecond) // give a second respawn every chance to appear |
| + if n := b.workerStarts(); n != 3 { |
| + t.Fatalf("workerStarts = %d after one sweep, want 3 — two workers, ONE respawn per tick", n) |
| + } |
| + |
| + // The next tick takes the other one. |
| + a.sweepOnce() |
| + waitFor(t, "the second respawn", func() bool { return b.workerStarts() == 4 }) |
| +} |
| + |
| +// A HIBERNATING worker's convergence is unchanged: it is CYCLED (stopped, DB |
| +// parked, lease released) and comes back on its schedule, exactly as before. |
| +func TestHibernatingWorkerStillConvergenceCycles(t *testing.T) { |
| + a, b, clk, ver := shippingActivator(t, time.Hour) |
| + if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil { // Hibernate: true |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + ver.Store("v2") |
| + a.sweepOnce() |
| + clk.advance(ConvergeGrace + time.Minute) |
| + a.sweepOnce() |
| + |
| + waitFor(t, "the convergence cycle", func() bool { return a.entryCount() == 0 }) |
| + if n := b.workerStarts(); n != 1 { |
| + t.Fatalf("workerStarts = %d — a hibernating worker is cycled, not respawned in place", n) |
| + } |
| + if !b.lastRep().isClosed() { |
| + t.Fatal("the cycle did not release the lease") |
| + } |
| +} |
| + |
| +// A respawn stops the old child before spawning the new one, so for a moment |
| +// the entry's proc reads as exited. A sweep landing in that window must do |
| +// NOTHING: read as a crash it would start a second child, and the box would |
| +// end up running two of a bot that must run once. |
| +func TestWorkerConvergeRespawnIsNotMistakenForACrash(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 = 300 * time.Millisecond // hold the respawn's spawn open |
| + 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") }) |
| + a.sweepOnce() // a tick inside the window |
| + if n := b.workerStarts(); n != 1 { |
| + t.Fatalf("workerStarts = %d during a respawn — the sweep spawned a second child for a worker mid-converge", n) |
| + } |
| + |
| + waitFor(t, "the respawn to land", func() bool { return a.entrySpawnVersion("bot.x.internal") == "v2" }) |
| + if n := b.workerStarts(); n != 2 { |
| + t.Fatalf("workerStarts = %d after the respawn, want exactly 2", n) |
| + } |
| +} |
| + |
| +// A respawn whose spawn FAILS must not leave a hole: the dead child stays in |
| +// place, and the ordinary crash-restart path picks it up on a later tick with |
| +// its backoff. Convergence degrades into supervision. |
| +func TestFailedConvergeRespawnFallsBackToCrashRestart(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.workerErr = errors.New("exec: live symlink gone") |
| + b.mu.Unlock() |
| + |
| + ver.Store("v2") |
| + a.sweepOnce() |
| + clk.advance(ConvergeGrace + time.Minute) |
| + a.sweepOnce() |
| + waitFor(t, "the failed respawn to finish", func() bool { return !a.entryRespawning("bot.x.internal") }) |
| + if !a.entryAwake("bot.x.internal") { |
| + t.Fatal("a failed respawn tore the entry down — the crash restart would have nothing left to fix") |
| + } |
| + |
| + // The build comes back; the crash-restart path takes it from here. |
| + b.mu.Lock() |
| + b.workerErr = nil |
| + b.mu.Unlock() |
| + clk.advance(sidecarRestartBase + time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "the crash restart to recover it", func() bool { return a.entrySpawnVersion("bot.x.internal") == "v2" }) |
| +} |
| + |
| +// --- /v1/idle and the always-on contract -------------------------------------- |
| + |
| +// "I have nothing to do" is not a request an always-on worker gets to make: |
| +// the stop would delete its entry, leaving nothing — not even the crash |
| +// restart — to bring it back before the agent restarts. |
| +func TestSleepWorkerIgnoresAnAlwaysOnWorker(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + |
| + a.SleepWorker("bot.x.internal") |
| + |
| + if !a.entryAwake("bot.x.internal") { |
| + t.Fatal("an always-on worker obeyed /v1/idle — nothing would ever start it again") |
| + } |
| + if b.releaseCount() != 0 { |
| + t.Fatal("an always-on worker's lease was released on a /v1/idle it should have refused") |
| } |
| } |
| |