| index 417ce31a4..d70444402 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -2654,29 +2654,74 @@ func TestNoteWorkerReadyPersistsVersionToRegistry(t *testing.T) { |
| // than write an empty string over whatever was there. Recording "" IS a |
| // version stamp, and an empty stamp reads as "verified empty" rather than |
| // "never reported" — the wrong claim. |
| +// TestNoteWorkerReadyEmptyVersionNeverStampsUnknown seeds the row through a |
| +// LEGITIMATE report, not a pre-wake SetVersions call: since review round 2 |
| +// (TestFreshWorkerWakeClearsAPreviousIncarnationsVersion), a fresh wake |
| +// itself clears any pre-existing version — correctly, that's the whole |
| +// fix for a stale version surviving into a new incarnation — so seeding |
| +// before WakeWorker would be testing the wake's clear, not NoteWorkerReady's |
| +// own empty-report guard. This isolates the guard: a real v8 report, then a |
| +// SECOND call with no version claim, must leave v8 in place. |
| func TestNoteWorkerReadyEmptyVersionNeverStampsUnknown(t *testing.T) { |
| a, b, _, reg := testActivator(t, time.Hour) |
| host := "bot.x.internal" |
| - // Seed a version on the row BEFORE the wake, the way a prior adopt pass |
| - // would have — an empty report must not clobber it either. |
| if err := reg.Upsert(testWorkerRoute(host)); err != nil { |
| t.Fatalf("upsert: %v", err) |
| } |
| - if err := reg.SetVersions(host, "v8", ""); err != nil { |
| - t.Fatalf("seed version: %v", err) |
| - } |
| - |
| if err := a.WakeWorker(testWorkerRoute(host)); err != nil { |
| t.Fatalf("WakeWorker: %v", err) |
| } |
| - noteReadyAsChild(t, a, b, host, "") |
| + noteReadyAsChild(t, a, b, host, "v8") |
| + if rt, err := reg.Get(host); err != nil || rt == nil || rt.Version != "v8" { |
| + t.Fatalf("get %s after the first report: %+v, %v; want v8", host, rt, err) |
| + } |
| + |
| + // A second call reporting no version claim at all — the honesty rule |
| + // says "" must never itself be a stamp. |
| + a.NoteWorkerReady(host, "") |
| |
| rt, err := reg.Get(host) |
| if err != nil || rt == nil { |
| t.Fatalf("get %s: %+v, %v", host, rt, err) |
| } |
| if rt.Version != "v8" { |
| - t.Fatalf("registry Version = %q after an empty ready report, want the seeded %q untouched", rt.Version, "v8") |
| + t.Fatalf("registry Version = %q after a second, empty ready report, want the previously confirmed %q untouched", rt.Version, "v8") |
| + } |
| +} |
| + |
| +// TestFreshWorkerWakeClearsAPreviousIncarnationsVersion is review round 1's |
| +// third finding: a fresh wake is equally a new incarnation (doWake, not just |
| +// respawnWorkerLocked's crash/converge paths), so a hibernating worker that |
| +// slept after reporting v1 and wakes again without ever calling /v1/ready |
| +// this time (it merely survives the settle window — TestWorkerSurviving- |
| +// SettleWindowIsAwake's shape) must read unknown, not its previous |
| +// incarnation's v1. |
| +func TestFreshWorkerWakeClearsAPreviousIncarnationsVersion(t *testing.T) { |
| + a, _, _, reg := testActivator(t, time.Hour) |
| + host := "bot.x.internal" |
| + rt := testWorkerRoute(host) // Hibernate: true |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + // Seed the row the way a PREVIOUS incarnation's own report would have |
| + // left it before the worker slept. |
| + if err := reg.SetVersions(host, "v1", ""); err != nil { |
| + t.Fatalf("seed version: %v", err) |
| + } |
| + |
| + old := sidecarSettle |
| + sidecarSettle = 20 * time.Millisecond |
| + defer func() { sidecarSettle = old }() |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + |
| + got, err := reg.Get(host) |
| + if err != nil || got == nil { |
| + t.Fatalf("get %s: %+v, %v", host, got, err) |
| + } |
| + if got.Version != "" { |
| + t.Fatalf("registry Version = %q after a fresh wake that never reported, want empty (unknown) — not the previous incarnation's %q", got.Version, "v1") |
| } |
| } |
| |
| @@ -2734,6 +2779,46 @@ func TestAlwaysOnWorkerCrashRestartsWithGrowingBackoff(t *testing.T) { |
| waitForRestart(t, a, b, "bot.x.internal", 4) |
| } |
| |
| +// TestAlwaysOnWorkerCrashRestartClearsVersionUntilTheNewChildReports is |
| +// review round 1's finding, the crash-restart twin of the converge-respawn |
| +// test: restartWorkerLocked also replaces the child on the SAME entry |
| +// through respawnWorkerLocked, so a crash must clear the row's version too — |
| +// the dead child's last report must not go on looking current once its |
| +// process is gone and a new one has taken its place. |
| +func TestAlwaysOnWorkerCrashRestartClearsVersionUntilTheNewChildReports(t *testing.T) { |
| + a, b, _, reg := testActivator(t, time.Hour) |
| + host := "bot.x.internal" |
| + rt := alwaysOnWorkerRoute(host) |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + noteReadyAsChild(t, a, b, host, "v1") |
| + |
| + b.lastWorker().kill() |
| + a.sweepOnce() |
| + waitForRestart(t, a, b, host, 2) |
| + |
| + got, err := reg.Get(host) |
| + if err != nil || got == nil { |
| + t.Fatalf("get %s: %+v, %v", host, got, err) |
| + } |
| + if got.Version != "" { |
| + t.Fatalf("registry Version = %q immediately after a crash restart, want empty (unknown) until the restarted child reports — not the dead child's v1", got.Version) |
| + } |
| + |
| + noteReadyAsChild(t, a, b, host, "v2") |
| + got, err = reg.Get(host) |
| + if err != nil || got == nil { |
| + t.Fatalf("get %s: %+v, %v", host, got, err) |
| + } |
| + if got.Version != "v2" { |
| + t.Fatalf("registry Version = %q after the restarted child reported, want v2", got.Version) |
| + } |
| +} |
| + |
| // 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 |
| @@ -3151,6 +3236,19 @@ func shippingActivator(t *testing.T, idle time.Duration) (*Service, *fakeBackend |
| return a, b, clk, &ver |
| } |
| |
| +// shippingActivatorWithRegistry is shippingActivator with the registry handle |
| +// kept — the version-clear-on-respawn tests need to read routes.version back, |
| +// which shippingActivator's callers have never needed before. |
| +func shippingActivatorWithRegistry(t *testing.T, idle time.Duration) (*Service, *fakeBackend, *fakeClock, *registry.Registry, *atomic.Value) { |
| + t.Helper() |
| + a, b, clk, reg := testActivator(t, idle) |
| + var ver atomic.Value |
| + ver.Store("v1") |
| + b.execVerFn = func() string { return ver.Load().(string) } |
| + a.liveVer = func(string) (string, error) { return ver.Load().(string), nil } |
| + return a, b, clk, reg, &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, |
| @@ -3200,6 +3298,53 @@ func TestAlwaysOnWorkerRespawnsOntoANewBuild(t *testing.T) { |
| } |
| } |
| |
| +// TestWorkerConvergeRespawnClearsVersionUntilTheNewChildReports is review |
| +// round 1's finding: a converge respawn execs a NEW build on the SAME entry |
| +// (respawnWorkerLocked, reached via convergeWorkerLocked), and the registry |
| +// row must stop claiming the OUTGOING child's version the moment that |
| +// happens — reading unknown until the new incarnation reports for itself — |
| +// rather than going on citing a build that is no longer running. |
| +func TestWorkerConvergeRespawnClearsVersionUntilTheNewChildReports(t *testing.T) { |
| + a, b, clk, reg, ver := shippingActivatorWithRegistry(t, time.Hour) |
| + host := "bot.x.internal" |
| + rt := alwaysOnWorkerRoute(host) |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + noteReadyAsChild(t, a, b, host, "v1") |
| + if got, err := reg.Get(host); err != nil || got == nil || got.Version != "v1" { |
| + t.Fatalf("get %s after the first report: %+v, %v; want Version v1", host, got, err) |
| + } |
| + |
| + ver.Store("v2") // a ship repoints the symlink under the running child |
| + a.sweepOnce() // first sighting: starts the grace clock |
| + clk.advance(ConvergeGrace + time.Minute) |
| + a.sweepOnce() // the respawn onto v2 fires |
| + waitFor(t, "the respawn onto v2", func() bool { return a.entrySpawnVersion(host) == "v2" }) |
| + |
| + // The registry must already read unknown — the new child has not been |
| + // given any chance to report yet, and the row must not go on citing v1. |
| + got, err := reg.Get(host) |
| + if err != nil || got == nil { |
| + t.Fatalf("get %s: %+v, %v", host, got, err) |
| + } |
| + if got.Version != "" { |
| + t.Fatalf("registry Version = %q immediately after a converge respawn, want empty (unknown) until the new incarnation reports — not the outgoing child's v1", got.Version) |
| + } |
| + |
| + noteReadyAsChild(t, a, b, host, "v2") |
| + got, err = reg.Get(host) |
| + if err != nil || got == nil { |
| + t.Fatalf("get %s: %+v, %v", host, got, err) |
| + } |
| + if got.Version != "v2" { |
| + t.Fatalf("registry Version = %q after the new incarnation reported, want v2", got.Version) |
| + } |
| +} |
| + |
| // 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) { |