| new file mode 100644 |
| index 000000000..5c022fde8 |
| --- /dev/null |
| +++ b/internal/activator/workerswap_test.go |
| @@ -0,0 +1,646 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func workerAttempt(cause Cause, mode SwapMode, onto release.StagedIdentity) Attempt { |
| + return Attempt{Host: "bot.x.internal", Cause: cause, Build: onto.Version, StagedPath: onto.StagedPath, |
| + Digest: onto.Digest, Demand: onto.Demand, Mode: mode, SchedRev: 1} |
| +} |
| + |
| +func one(string) uint64 { return 1 } |
| + |
| +// waitForWorkerStarts waits until the backend has made n worker children: |
| +// a spawn call is recorded before its exec, so b.calls() alone runs ahead |
| +// of the child a test wants to act on. |
| +func waitForWorkerStarts(t *testing.T, b *stagedFake, n int) { |
| + t.Helper() |
| + waitFor(t, "a worker child", func() bool { return b.workerStarts() == n }) |
| +} |
| + |
| +// Spec §7.9: the attempt respawns on the SAME entry. The lease is never |
| +// released or re-taken, and the new child execs the attempt's staged build. |
| +func TestReplaceWorkerKeepsTheEntryAndItsLease(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + acquires, _, _ := b.counts() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, one) |
| + if res.Outcome != SwapDone || res.Gen.Build != "v2" || res.Gen.Digest != fakeIdentity("v2").Digest || !res.Gen.Worker { |
| + t.Fatalf("ReplaceWorker = %+v", res) |
| + } |
| + if got, _, _ := b.counts(); got != acquires { |
| + t.Fatalf("lease acquires %d -> %d: a socketless attempt must keep the lease (spec §7.9)", acquires, got) |
| + } |
| + if calls := b.calls(); len(calls) != 2 || calls[1] == nil || *calls[1] != fakeIdentity("v2") { |
| + t.Fatalf("spawns %v, want the wake, then v2 by its staged path", calls) |
| + } |
| + if got := a.entryServing(host); got != fakeIdentity("v2") { |
| + t.Fatalf("serving %+v", got) |
| + } |
| + if _, ok := a.Current(host); !ok { |
| + t.Fatal("Current hides the worker after its attempt finished") |
| + } |
| +} |
| + |
| +// Spec §6.5: a failed release in overlap mode respawns the good build in |
| +// its place, on the same entry, and reports the failure. |
| +func TestReplaceWorkerFallsBackToTheGoodBuild(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + good := fakeIdentity("v1") |
| + go func() { |
| + waitForWorkerStarts(t, b, 2) |
| + time.Sleep(50 * time.Millisecond) |
| + b.lastWorker().crash() |
| + }() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), &good, one) |
| + if res.Outcome != SwapFailed || res.Gen.Build != "v1" { |
| + t.Fatalf("ReplaceWorker = %+v, want failed with the good v1 serving", res) |
| + } |
| + if calls := b.calls(); len(calls) != 3 || *calls[2] != good { |
| + t.Fatalf("spawns %v, want the good build last", calls) |
| + } |
| + if got := a.entryServing(host); got != good { |
| + t.Fatalf("serving %+v, want the good build", got) |
| + } |
| +} |
| + |
| +// Spec §6.4, §8.3: a hold build that ran never falls back. The instance |
| +// stays down. |
| +func TestReplaceWorkerInHoldModeStaysDown(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + good := fakeIdentity("v1") |
| + go func() { |
| + waitForWorkerStarts(t, b, 2) |
| + time.Sleep(50 * time.Millisecond) |
| + b.lastWorker().crash() |
| + }() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeHold, fakeIdentity("v2")), &good, one) |
| + if res.Outcome != SwapDown { |
| + t.Fatalf("ReplaceWorker = %+v, want down", res) |
| + } |
| + if len(b.calls()) != 2 { |
| + t.Fatalf("spawns %v: a hold build fell back", b.calls()) |
| + } |
| + waitFor(t, "the entry to stop", func() bool { return !a.Owns(host) }) |
| +} |
| + |
| +// Spec §6.5: a failed restart respawns the build it replaced, and writes |
| +// nothing against it. |
| +func TestReplaceWorkerRestartFailureRespawnsTheServingBuild(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + serving := a.entryServing(host) |
| + go func() { |
| + waitForWorkerStarts(t, b, 2) |
| + time.Sleep(50 * time.Millisecond) |
| + b.lastWorker().crash() |
| + }() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRestart, ModeOverlap, serving), nil, one) |
| + if res.Outcome != SwapFailed { |
| + t.Fatalf("ReplaceWorker = %+v", res) |
| + } |
| + waitFor(t, "the serving build back", func() bool { return len(b.calls()) == 3 && !a.entryRespawning(host) }) |
| + if calls := b.calls(); *calls[2] != serving { |
| + t.Fatalf("respawned %+v, want the serving build", calls[2]) |
| + } |
| +} |
| + |
| +// Spec §6.5: a clean exit inside the 10 s is a one-shot's success. |
| +func TestReplaceWorkerOneShotCleanExitIsReady(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil { |
| + t.Fatal(err) |
| + } |
| + go func() { |
| + waitForWorkerStarts(t, b, 2) |
| + b.lastWorker().exit() |
| + }() |
| + if res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, one); res.Outcome != SwapDone { |
| + t.Fatalf("ReplaceWorker = %+v, want done", res) |
| + } |
| +} |
| + |
| +// Spec §6.3, §7.4, §8.1: the attempt's checks run again at the actual |
| +// spawn, after the old child has retired, and again before its result is |
| +// accepted. A newer release (or a sibling's failure, whose halt bumps the |
| +// revision) that lands during the retire means the attempt's build never |
| +// execs: the build it replaced comes back instead. One that lands during |
| +// the observation means the result is abandoned, never done, so the build |
| +// is not recorded good. Checked only before the retire, both slip through. |
| +func TestReplaceWorkerReChecksAtTheSpawnAndBeforeTheResult(t *testing.T) { |
| + t.Run("superseded while the old child retires", func(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + gate := make(chan struct{}) |
| + b.lastWorker().setStopGate(gate) |
| + var rev atomic.Uint64 |
| + rev.Store(1) |
| + results := make(chan SwapResult, 1) |
| + go func() { |
| + results <- a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, |
| + func(string) uint64 { return rev.Load() }) |
| + }() |
| + waitFor(t, "the retire to begin", func() bool { return a.entryRespawning(host) }) |
| + rev.Store(2) // a newer release lands while the old child drains |
| + close(gate) |
| + res := <-results |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) { |
| + t.Fatalf("ReplaceWorker = %+v, want abandoned: superseded", res) |
| + } |
| + for _, c := range b.calls() { |
| + if c != nil && *c == fakeIdentity("v2") { |
| + t.Fatalf("spawns %v: the superseded build was exec'd", b.calls()) |
| + } |
| + } |
| + waitFor(t, "a child back on the entry", func() bool { return len(b.calls()) == 2 && !a.entryRespawning(host) }) |
| + if got := a.entryServing(host); got != fakeIdentity("v1") { |
| + t.Fatalf("serving %+v, want the build the attempt replaced", got) |
| + } |
| + }) |
| + t.Run("superseded during the observation", func(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + var rev atomic.Uint64 |
| + rev.Store(1) |
| + go func() { |
| + waitForWorkerStarts(t, b, 2) |
| + rev.Store(2) |
| + }() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, |
| + func(string) uint64 { return rev.Load() }) |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) { |
| + t.Fatalf("ReplaceWorker = %+v, want abandoned: a superseded result is never done", res) |
| + } |
| + }) |
| +} |
| + |
| +// PF11: the attempt's checks also run in main's exec-time gate, inside |
| +// startWorker's before-exec hook, after the preparation (here the config |
| +// admission) and the publication. A newer release that lands after the |
| +// respawn's first check but before the exec means the attempt's build never |
| +// execs, and the build it replaced comes back. |
| +func TestReplaceWorkerReChecksAtTheExecItself(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + var rev atomic.Uint64 |
| + rev.Store(1) |
| + b.mu.Lock() |
| + b.admitHook = func() { rev.Store(2) } // lands during the respawn's preparation |
| + b.mu.Unlock() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, |
| + func(string) uint64 { return rev.Load() }) |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) { |
| + t.Fatalf("ReplaceWorker = %+v, want abandoned: superseded at the exec", res) |
| + } |
| + waitFor(t, "the restore", func() bool { return b.workerStarts() == 2 && !a.entryRespawning(host) }) |
| + if got := a.entryServing(host); got != fakeIdentity("v1") { |
| + t.Fatalf("serving %+v, want the build the attempt replaced (v2 must never have exec'd)", got) |
| + } |
| +} |
| + |
| +// PF11: and once more before the child is installed. A newer release that |
| +// lands while the attempt's child is being exec'd sheds that child; the |
| +// build it replaced comes back, and the result is abandoned, never done. |
| +func TestReplaceWorkerReChecksBeforeInstalling(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + var rev atomic.Uint64 |
| + rev.Store(1) |
| + var hooked atomic.Bool |
| + b.mu.Lock() |
| + b.workerStartHook = func(registry.Route) { |
| + if hooked.CompareAndSwap(false, true) { |
| + rev.Store(2) // lands after the exec gate, before the install check |
| + } |
| + } |
| + b.mu.Unlock() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, |
| + func(string) uint64 { return rev.Load() }) |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) { |
| + t.Fatalf("ReplaceWorker = %+v, want abandoned: superseded before the install", res) |
| + } |
| + waitFor(t, "the restore", func() bool { return b.workerStarts() == 3 && !a.entryRespawning(host) }) |
| + b.mu.Lock() |
| + shed := b.workers[1] |
| + b.mu.Unlock() |
| + if !shed.isStopped() { |
| + t.Fatal("the superseded attempt's child was left running") |
| + } |
| + if got := a.entryServing(host); got != fakeIdentity("v1") { |
| + t.Fatalf("serving %+v, want the build the attempt replaced", got) |
| + } |
| +} |
| + |
| +// PF11 (plan 3's termination proof is never dropped): a respawn whose old |
| +// child is not proven gone execs nothing, the attempt's build and a restore |
| +// alike, and the attempt is abandoned. |
| +func TestReplaceWorkerNeverExecsBesideAnUnprovenOldChild(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + a.gens = true |
| + hk := &plannedHostKill{stagedFake: b} |
| + a.backend = hk |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + hk.bad.Store(true) // the host cgroup still holds a process |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, one) |
| + if res.Outcome != SwapAbandoned { |
| + t.Fatalf("ReplaceWorker = %+v, want abandoned", res) |
| + } |
| + waitFor(t, "the respawns to finish", func() bool { return !a.entryRespawning(host) }) |
| + time.Sleep(50 * time.Millisecond) |
| + if n := b.workerStarts(); n != 1 { |
| + t.Fatalf("%d worker children: something exec'd beside an unproven old child", n) |
| + } |
| +} |
| + |
| +// Spec §7.4: a respawn whose gate finds the host no longer owned (here the |
| +// lease lost while the old child drained) spawns nothing at all, the |
| +// replaced build included. Turning every refusal into a restore ran the old |
| +// build against a database the box no longer owned. |
| +func TestReplaceWorkerWithoutOwnershipSpawnsNothing(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + gate := make(chan struct{}) |
| + b.lastWorker().setStopGate(gate) |
| + results := make(chan SwapResult, 1) |
| + go func() { |
| + results <- a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, one) |
| + }() |
| + waitFor(t, "the retire to begin", func() bool { return a.entryRespawning(host) }) |
| + b.lastRep().setErr(errors.New("lease renewal failed")) // ownership lost mid-retire |
| + close(gate) |
| + if res := <-results; res.Outcome != SwapAbandoned { |
| + t.Fatalf("ReplaceWorker = %+v, want abandoned", res) |
| + } |
| + waitFor(t, "the respawn to finish", func() bool { return !a.entryRespawning(host) }) |
| + if calls := b.calls(); len(calls) != 1 { |
| + t.Fatalf("spawns %v: something exec'd after the lease was lost", calls) |
| + } |
| +} |
| + |
| +// A superseded attempt touches nothing. |
| +func TestReplaceWorkerSupersededSpawnsNothing(t *testing.T) { |
| + a, b, _, _, _ := stagedActivator(t) |
| + if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil { |
| + t.Fatal(err) |
| + } |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, func(string) uint64 { return 2 }) |
| + if res.Outcome != SwapAbandoned || len(b.calls()) != 1 { |
| + t.Fatalf("ReplaceWorker = %+v, spawns %v", res, b.calls()) |
| + } |
| +} |
| + |
| +// PF9: an attempt whose build could not be published before its exec |
| +// (ErrBuildNotPublished) ran nothing of the build. It is abandonment, a |
| +// staging refusal, never a failure of the build. |
| +func TestReplaceWorkerRefusedForPublicationIsAbandoned(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + blocker := filepath.Join(t.TempDir(), "not-a-dir") |
| + if err := os.WriteFile(blocker, nil, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + a.mu.Lock() |
| + a.GenerationsFile = filepath.Join(blocker, "generations.json") // unwritable from here on |
| + a.mu.Unlock() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, one) |
| + if res.Outcome != SwapAbandoned { |
| + t.Fatalf("ReplaceWorker = %+v, want abandoned: an unpublished build never ran", res) |
| + } |
| + waitFor(t, "the respawns to finish", func() bool { return !a.entryRespawning(host) }) |
| + if n := b.workerStarts(); n != 1 { |
| + t.Fatalf("%d worker children: an unpublished build was exec'd", n) |
| + } |
| +} |
| + |
| +// Carried from Task 9: e.unexpectedExit is never cleared by the arms that |
| +// set it. A stale mark from an earlier crash must not turn an intentional |
| +// stop during the attempt's observation into a verdict: the stop cancels |
| +// the observation, and the attempt is abandoned, never done. |
| +func TestReplaceWorkerAnIntentionalStopDuringTheObservationIsNoVerdict(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 2*time.Second) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + a.mu.Lock() |
| + a.entries[host].unexpectedExit = true // left by an earlier crash restart |
| + a.mu.Unlock() |
| + go func() { |
| + waitForWorkerStarts(t, b, 2) |
| + waitFor(t, "the observation", func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + e := a.entries[host] |
| + return e != nil && e.firstWatch != nil |
| + }) |
| + a.mu.Lock() |
| + a.beginStopLocked(a.entries[host]) // an operator's stop |
| + a.mu.Unlock() |
| + }() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, one) |
| + if res.Outcome != SwapAbandoned { |
| + t.Fatalf("ReplaceWorker = %+v, want abandoned: an intentional stop records nothing", res) |
| + } |
| +} |
| + |
| +// Spec §7.9: with CARLOS_SWAP unset (no generations, no rollout manager), a |
| +// worker's captured serving identity is still published on its wake and on |
| +// every respawn, so `carlos sidecars ls` shows what runs. Only the rollout |
| +// manager wrote it, and a flag-off box showed "unknown", or a stale build |
| +// after the flag was turned off. |
| +func TestAWorkersServingIdentityIsPublishedWithTheFlagOff(t *testing.T) { |
| + a, b, _, _, _ := stagedActivator(t) |
| + var mu sync.Mutex |
| + var got []string |
| + a.OnWorkerServing = func(host string, id release.StagedIdentity) { |
| + mu.Lock() |
| + got = append(got, host+"="+id.Version) |
| + mu.Unlock() |
| + } |
| + published := func() int { mu.Lock(); defer mu.Unlock(); return len(got) } |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + waitFor(t, "the wake's identity", func() bool { return published() == 1 }) |
| + b.lastWorker().crash() |
| + a.sweepOnce() // the always-on worker respawns |
| + waitFor(t, "the respawn's identity", func() bool { return published() == 2 }) |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if want := host + "=" + a.entryServing(host).Version; got[1] != want { |
| + t.Fatalf("published %v, want the respawned child's %q last", got, want) |
| + } |
| +} |
| + |
| +// Spec §7.9: a delayed publication of an older worker incarnation never |
| +// overwrites a newer one. Here the wake's publication (incarnation 1) is |
| +// replayed after the respawn published incarnation 2: it is dropped. An |
| +// unconditional write left the older version on display indefinitely. |
| +func TestAnOlderWorkerPublicationIsDropped(t *testing.T) { |
| + a, b, _, _, _ := stagedActivator(t) |
| + var mu sync.Mutex |
| + var got []string |
| + a.OnWorkerServing = func(_ string, id release.StagedIdentity) { |
| + mu.Lock() |
| + got = append(got, id.Version) |
| + mu.Unlock() |
| + } |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + a.mu.Lock() |
| + e := a.entries[host] |
| + firstID, firstInc := e.workerServing, e.workerInc |
| + a.mu.Unlock() |
| + b.lastWorker().crash() |
| + a.sweepOnce() |
| + waitFor(t, "the respawn's publication", func() bool { mu.Lock(); defer mu.Unlock(); return len(got) == 2 }) |
| + a.publishWorkerServing(e, firstID, firstInc) // the older one, delayed |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if len(got) != 2 { |
| + t.Fatalf("published %v: an older incarnation's publication was delivered after a newer one", got) |
| + } |
| +} |
| + |
| +// Spec §7.9 (P5 review finding 8): a child exec'd from an external live-link |
| +// target has no captured version. Its publication still goes out, empty, |
| +// so the row stops naming the build the previous child ran. Skipping empty |
| +// versions left ServingVersion=v1 on display indefinitely. |
| +func TestAnUnknownVersionClearsTheServingVersion(t *testing.T) { |
| + a, _, _, _, _ := stagedActivator(t) |
| + var mu sync.Mutex |
| + var got []string |
| + a.OnWorkerServing = func(_ string, id release.StagedIdentity) { |
| + mu.Lock() |
| + got = append(got, id.Version) |
| + mu.Unlock() |
| + } |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + waitFor(t, "the wake's identity", func() bool { mu.Lock(); defer mu.Unlock(); return len(got) == 1 }) |
| + a.mu.Lock() |
| + e := a.entries[host] |
| + e.workerInc++ // a replacement exec'd from an external live-link target |
| + inc := e.workerInc |
| + a.mu.Unlock() |
| + a.publishWorkerServing(e, release.StagedIdentity{StagedPath: "/srv/external/app"}, inc) |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if len(got) != 2 || got[1] != "" { |
| + t.Fatalf("published %q, want the empty version last: the old one must be cleared", got) |
| + } |
| +} |
| + |
| +// Spec §7.4 (P5 review finding 1): every respawn passes the ownership check |
| +// at its exec, a fallback included. Here B execs, the lease is lost, and |
| +// then B crashes: the good build is NOT respawned. Without the check the |
| +// fallback ran A against a database the box no longer owned. |
| +func TestAWorkerFallbackWithoutOwnershipSpawnsNothing(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + good := fakeIdentity("v1") |
| + go func() { |
| + waitForWorkerStarts(t, b, 2) |
| + b.lastRep().setErr(errors.New("lease renewal failed")) // ownership lost after B's exec |
| + time.Sleep(50 * time.Millisecond) |
| + b.lastWorker().crash() |
| + }() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), &good, one) |
| + if res.Outcome != SwapFailed || res.Gen.Build != "" { |
| + t.Fatalf("ReplaceWorker = %+v, want failed with nothing serving", res) |
| + } |
| + if calls := b.calls(); len(calls) != 2 { |
| + t.Fatalf("spawns %v: the fallback exec'd after the lease was lost", calls) |
| + } |
| +} |
| + |
| +// Spec §6.5, §8.1 (P5 review finding 5): a restart counts only once a NEW |
| +// child is installed. Here the restart's exec fails after the old child |
| +// stopped cleanly: the entry still holds that child, with the same |
| +// artifact, and its clean exit read as a one-shot's success, so the |
| +// scheduler acknowledged config that never ran. |
| +func TestAFailedRestartExecIsNeverDone(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + serving := a.entryServing(host) |
| + b.mu.Lock() |
| + b.workerErr = errors.New("exec: text file busy") |
| + b.mu.Unlock() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRestart, ModeOverlap, serving), nil, one) |
| + if res.Outcome == SwapDone { |
| + t.Fatalf("ReplaceWorker = %+v: a restart whose child never ran was done", res) |
| + } |
| + if res.Outcome != SwapAbandoned { |
| + t.Fatalf("ReplaceWorker = %+v: a failed exec is a staging refusal, never a failure of the build", res) |
| + } |
| + waitFor(t, "the respawns to finish", func() bool { return !a.entryRespawning(host) }) |
| +} |
| + |
| +// Spec §8.1 (P5 review finding 7): a ReplaceWorker fallback holds its |
| +// publication until the scheduler has recorded B's failure and calls |
| +// PublishHeldServing. Published from the respawn, a blocked callback sat |
| +// in front of the SwapFailed result, and so of the failure record. |
| +func TestAWorkerFallbackPublishesOnlyWhenReleased(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + var mu sync.Mutex |
| + var got []string |
| + block := make(chan struct{}) |
| + a.OnWorkerServing = func(_ string, id release.StagedIdentity) { |
| + mu.Lock() |
| + got = append(got, id.Version) |
| + n := len(got) |
| + mu.Unlock() |
| + if n > 2 { |
| + <-block // a slow registry write for the fallback's identity (the wake's v1, then B's v2, come first) |
| + } |
| + } |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + good := fakeIdentity("v1") |
| + go func() { |
| + waitForWorkerStarts(t, b, 2) |
| + time.Sleep(50 * time.Millisecond) |
| + b.lastWorker().crash() |
| + }() |
| + results := make(chan SwapResult, 1) |
| + go func() { |
| + results <- a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), &good, one) |
| + }() |
| + select { |
| + case res := <-results: |
| + if res.Outcome != SwapFailed || res.Gen.Build != "v1" { |
| + t.Fatalf("ReplaceWorker = %+v", res) |
| + } |
| + case <-time.After(5 * time.Second): |
| + close(block) |
| + t.Fatal("the failure result waited on the fallback's publication") |
| + } |
| + mu.Lock() |
| + n := len(got) |
| + mu.Unlock() |
| + if n != 2 { |
| + t.Fatalf("published %v before the failure was recorded", got) |
| + } |
| + close(block) |
| + a.PublishHeldServing(host) // the scheduler, after done() |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if len(got) != 3 || got[2] != "v1" { |
| + t.Fatalf("published %v, want the fallback's v1 once released", got) |
| + } |
| +} |
| + |
| +// Spec §8.1 (P5 review finding 7): on a planned worker wake that falls back, |
| +// the failure transition (GenFellBack, whose OnGen records B's failure) |
| +// completes before the fallback's identity is published, and a blocked |
| +// publication does not hold it up. Publishing before afterWake left B |
| +// unheld while the callback blocked. |
| +func TestAFallbackWakeRecordsTheFailureBeforeItPublishes(t *testing.T) { |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond) |
| + a, b, _, _, _ := stagedActivator(t) |
| + a.gens = true |
| + a.backend = &plannedHostKill{stagedFake: b} // the failed child's host cgroup proves empty |
| + evs := &genLog{} |
| + a.OnGen = evs.add |
| + dir := t.TempDir() |
| + good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2") |
| + a.Planner = func(context.Context, registry.Route) WakePlan { |
| + return WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good} |
| + } |
| + block := make(chan struct{}) |
| + var sawFailure, called atomic.Bool |
| + a.OnWorkerServing = func(string, release.StagedIdentity) { |
| + sawFailure.Store(slices.Contains(evs.kinds(), GenFellBack)) |
| + called.Store(true) |
| + <-block |
| + } |
| + go func() { |
| + waitForWorkerStarts(t, b, 1) |
| + time.Sleep(50 * time.Millisecond) |
| + b.lastWorker().crash() |
| + }() |
| + go a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")) //nolint:errcheck |
| + evs.wait(t, GenFellBack) // arrives while the publication is still blocked |
| + waitFor(t, "the publication", func() bool { return called.Load() }) |
| + close(block) |
| + if !sawFailure.Load() { |
| + t.Fatal("the fallback's identity was published before its failure transition") |
| + } |
| +} |