carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/cgroup"
17 "github.com/carlosframework/platform/internal/instancectl"
18 "github.com/carlosframework/platform/internal/registry"
19 "github.com/carlosframework/platform/internal/release"
20)
21
22// launchBackend is the swap fixture's fakeSwapBackend (lease, restore,
23// raise, switch) with StartStaged on a REAL ExecBackend, flat or nested, so
24// a staged spawn runs the production preparation: the admin token, the
25// config read, the control socket's bind and, nested, the launch file and
26// the root start call. The activator's row is mapped onto the real
27// backend's row (rt) of the chosen layout. Its KillGen stops the
28// generation's handle, as execStagedBackend's does.
29type launchBackend struct {
30 *fakeSwapBackend
31 eb *ExecBackend
32 rt registry.Route
33 ctl *exeControl // nested only: what root was asked to start
34
35 lmu sync.Mutex
36 entered []uint64 // generation numbers that reached the real backend
37 procs map[uint64]Proc
38 builds []string // the builds the real backend returned a process for
39}
40
41func (b *launchBackend) StartStaged(ctx context.Context, _ registry.Route, g Gen) (Gen, Proc, error) {
42 b.lmu.Lock()
43 b.entered = append(b.entered, g.N)
44 b.lmu.Unlock()
45 got, p, err := b.eb.StartStaged(ctx, b.rt, g)
46 if p != nil {
47 b.lmu.Lock()
48 b.procs[g.N] = p
49 b.builds = append(b.builds, g.Build)
50 b.lmu.Unlock()
51 }
52 return got, p, err
53}
54
55func (b *launchBackend) KillGen(ctx context.Context, _ registry.Route, n uint64) error {
56 b.lmu.Lock()
57 p := b.procs[n]
58 b.lmu.Unlock()
59 if p == nil {
60 return nil
61 }
62 _ = p.Stop(ctx)
63 if !p.Exited() {
64 return ErrStillRunning
65 }
66 return nil
67}
68
69func (b *launchBackend) gens() []uint64 {
70 b.lmu.Lock()
71 defer b.lmu.Unlock()
72 return slices.Clone(b.entered)
73}
74
75func (b *launchBackend) stopAll() {
76 b.lmu.Lock()
77 procs := make([]Proc, 0, len(b.procs))
78 for _, p := range b.procs {
79 procs = append(procs, p)
80 }
81 b.lmu.Unlock()
82 for _, p := range procs {
83 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
84 _ = p.Stop(ctx)
85 cancel()
86 }
87}
88
89// launched is the evidence that a build's process was started: the real
90// backend returned a process for it; flat, the marker its script writes the
91// moment it runs; nested, root was asked to start the unit with it.
92func (b *launchBackend) launched(t *testing.T, id release.StagedIdentity, marker string) bool {
93 t.Helper()
94 b.lmu.Lock()
95 started := slices.Contains(b.builds, id.Version)
96 b.lmu.Unlock()
97 if started {
98 return true
99 }
100 if b.ctl != nil {
101 b.ctl.mu.Lock()
102 defer b.ctl.mu.Unlock()
103 for _, x := range b.ctl.got {
104 if x.Path == id.StagedPath {
105 return true
106 }
107 }
108 return false
109 }
110 _, err := os.Stat(marker)
111 return err == nil
112}
113
114// prepGone checks that what generation n's preparation made is gone after
115// a refusal at the launch: its control socket, and nested, its launch file.
116func (b *launchBackend) prepGone(t *testing.T, n uint64) {
117 t.Helper()
118 var sock string
119 p := b.eb.pathsFor(b.rt)
120 switch p.layout {
121 case LayoutNested:
122 sock = nestedGenSocketIn(p.runDir, n)
123 launch := filepath.Join(p.dataDir, instancectl.GenLaunchFileName(n))
124 if _, err := os.Stat(launch); !os.IsNotExist(err) {
125 t.Fatalf("generation %d's launch file %s after a refusal at the launch: %v, want it removed", n, launch, err)
126 }
127 default:
128 sock = b.eb.flatGenSocket(b.rt.Host, n)
129 }
130 if _, err := os.Stat(sock + ".control"); !os.IsNotExist(err) {
131 t.Fatalf("generation %d's control socket after a refusal at the launch: %v, want it unbound", n, err)
132 }
133}
134
135// newLaunchBackend builds the real backend for layout and wraps fb.
136func newLaunchBackend(t *testing.T, layout string, fb *fakeSwapBackend) *launchBackend {
137 t.Helper()
138 b := &launchBackend{fakeSwapBackend: fb, procs: map[uint64]Proc{}}
139 switch layout {
140 case "flat":
141 eb := testExecBackend(t)
142 eb.RunDirOverride = t.TempDir()
143 host := "launch.example.com"
144 stageLive(t, eb, host, "v0")
145 b.eb, b.rt = eb, registry.Route{Host: host, Kind: "instance", Hibernate: true,
146 DBPath: filepath.Join(eb.DataDir, host+".db"), Socket: filepath.Join(eb.RunDirOverride, host+".sock")}
147 case "nested":
148 eb, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
149 eb.GenContainment = true
150 b.ctl = &exeControl{}
151 eb.Control = b.ctl
152 b.eb, b.rt = eb, rt
153 default:
154 t.Fatalf("layout %q", layout)
155 }
156 if got, _ := b.eb.Layout(b.rt); (layout == "flat") != (got == LayoutFlat) || (layout == "nested") != (got == LayoutNested) {
157 t.Fatalf("fixture layout %v, want %s", got, layout)
158 }
159 t.Cleanup(b.stopAll)
160 return b
161}
162
163// markerScript is a staged build that writes marker the moment it runs.
164func markerScript(t *testing.T, dir, ver, marker string) release.StagedIdentity {
165 t.Helper()
166 return stagedScript(t, dir, ver, "#!/bin/sh\necho ran > '"+marker+"'\nexec sleep 30\n")
167}
168
169// onCall runs f on the call numbered want (counting from 1) and on no
170// other.
171func onCall(want int32, f func()) func(string) {
172 var n atomic.Int32
173 return func(string) {
174 if n.Add(1) == want {
175 f()
176 }
177 }
178}
179
180// Final review F1: every staged instance spawn's composed gate is asked at
181// the backend's final launch point, AFTER the backend's own preparation:
182// flat, after the token, the config read and the control socket's bind,
183// right before the process starts; nested, after the launch file, right
184// before root is asked to start the unit. A lease lost, a fence set or the
185// attempt superseded during that preparation starts nothing, removes what
186// the preparation made, records no execution evidence and is abandonment,
187// never a failure of the build. The four callers: a planned wake's B, its
188// fallback, a hold's pinned build and an overlap successor, on both
189// layouts. Before the fix the gate ran only before StartStaged, and each of
190// these launched.
191func TestTheExecGateIsAskedAtTheBackendsLaunchPoint(t *testing.T) {
192 for _, layout := range []string{"flat", "nested"} {
193 t.Run(layout+"/planned B, lease lost", func(t *testing.T) {
194 b := newLaunchBackend(t, layout, &fakeSwapBackend{fakeBackend: &fakeBackend{}})
195 dir := t.TempDir()
196 mark := filepath.Join(t.TempDir(), "v2.ran")
197 next := markerScript(t, dir, "v2", mark)
198 good := markerScript(t, dir, "v1", filepath.Join(t.TempDir(), "v1.ran"))
199 a, evs := launchActivator(t, b, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
200 b.eb.launchHook = onCall(1, func() { b.lastRep().setErr(errors.New("lease renewal failed")) })
201 _, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
202 if !errors.Is(err, errSwapLeaseLost) || classifyStep(nil, err) == stepFailed {
203 t.Fatalf("wake = %v, want the lost lease, abandonment", err)
204 }
205 gens := b.gens()
206 if len(gens) != 1 {
207 t.Fatalf("the backend was entered for generations %v, want B's alone (the refusal is inside it)", gens)
208 }
209 if b.launched(t, next, mark) {
210 t.Fatal("B was launched after the lease was lost during its preparation")
211 }
212 b.prepGone(t, gens[0])
213 time.Sleep(50 * time.Millisecond)
214 if k := evs.kinds(); len(k) != 0 {
215 t.Fatalf("events %v: a refusal at the launch was recorded", k)
216 }
217 })
218 t.Run(layout+"/planned fallback, fenced", func(t *testing.T) {
219 defer func(k time.Duration) { firstWakeKillBound = k }(firstWakeKillBound)
220 firstWakeKillBound = 500 * time.Millisecond
221 b := newLaunchBackend(t, layout, &fakeSwapBackend{fakeBackend: &fakeBackend{}})
222 dir := t.TempDir()
223 markB, markGood := filepath.Join(t.TempDir(), "v2.ran"), filepath.Join(t.TempDir(), "v1.ran")
224 next, good := markerScript(t, dir, "v2", markB), markerScript(t, dir, "v1", markGood)
225 a, evs := launchActivator(t, b, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
226 const host = "one.example.com"
227 a.genHealth = func(context.Context, Gen) error { return errors.New("GET / answered 500") }
228 b.eb.launchHook = onCall(2, func() {
229 a.mu.Lock()
230 a.fences = map[string]uint64{host: 1}
231 a.mu.Unlock()
232 })
233 if _, err := a.beginRequest(context.Background(), testRoute(host)); err == nil {
234 t.Fatal("served after the fallback was refused")
235 }
236 if gens := b.gens(); len(gens) != 2 {
237 t.Fatalf("the backend was entered for generations %v, want B's and the fallback's", gens)
238 }
239 waitFor(t, "B's launch evidence", func() bool { return b.launched(t, next, markB) })
240 time.Sleep(100 * time.Millisecond)
241 if b.launched(t, good, markGood) {
242 t.Fatal("the good build was launched on a host fenced during its preparation")
243 }
244 b.prepGone(t, b.gens()[1])
245 // B ran and failed: that is recorded. The refused fallback is not
246 // a fallback that served.
247 evs.wait(t, GenFailed)
248 if k := evs.kinds(); slices.Contains(k, GenFellBack) {
249 t.Fatalf("events %v: a fallback refused at its launch was reported as served", k)
250 }
251 })
252 t.Run(layout+"/hold's pinned build, lease lost", func(t *testing.T) {
253 f := newSwapFixture(t)
254 b := newLaunchBackend(t, layout, f.b)
255 f.a.backend = b
256 sink := &lockedSink{}
257 f.a.Logs = sink
258 mark := filepath.Join(t.TempDir(), "v2.ran")
259 id := markerScript(t, t.TempDir(), "v2", mark)
260 at := f.attempt(ModeHold)
261 at.StagedPath, at.Digest = id.StagedPath, id.Digest
262 b.eb.launchHook = onCall(1, func() { f.b.lastRep().setErr(errors.New("lease renewal failed")) })
263 res := f.a.Swap(context.Background(), at, f.newest)
264 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapLeaseLost.Error()) {
265 t.Fatalf("Swap = %+v, want abandoned: the lost lease", res)
266 }
267 gens := b.gens()
268 if len(gens) != 1 {
269 t.Fatalf("the backend was entered for generations %v, want the pin's alone", gens)
270 }
271 if b.launched(t, id, mark) {
272 t.Fatal("the hold's build was launched after the lease was lost during its preparation")
273 }
274 b.prepGone(t, gens[0])
275 if got := sink.errors(); len(got) != 0 {
276 t.Fatalf("member error events %q for an abandoned hold", got)
277 }
278 })
279 t.Run(layout+"/overlap successor, superseded", func(t *testing.T) {
280 f := newSwapFixture(t)
281 b := newLaunchBackend(t, layout, f.b)
282 f.a.backend = b
283 mark := filepath.Join(t.TempDir(), "v2.ran")
284 id := markerScript(t, t.TempDir(), "v2", mark)
285 at := f.attempt(ModeOverlap)
286 at.StagedPath, at.Digest = id.StagedPath, id.Digest
287 b.eb.launchHook = onCall(1, func() { f.rev.Add(1) })
288 res := f.a.Swap(context.Background(), at, f.newest)
289 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) {
290 t.Fatalf("Swap = %+v, want abandoned: superseded", res)
291 }
292 gens := b.gens()
293 if len(gens) != 1 {
294 t.Fatalf("the backend was entered for generations %v, want the successor's alone", gens)
295 }
296 if b.launched(t, id, mark) {
297 t.Fatal("the successor was launched after its attempt was superseded during its preparation")
298 }
299 b.prepGone(t, gens[0])
300 if g, members := f.a.genStateForTest(f.host); g.N != f.incumbent.N || len(members) != 0 {
301 t.Fatalf("current %+v, members %v: want the incumbent serving and an empty operation set", g, members)
302 }
303 })
304 }
305}
306
307// launchActivator is planActivator over a launchBackend.
308func launchActivator(t *testing.T, b *launchBackend, plan WakePlan) (*Service, *genLog) {
309 t.Helper()
310 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
311 if err != nil {
312 t.Fatal(err)
313 }
314 a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, b, reg)
315 a.gens = true
316 evs := &genLog{}
317 a.OnGen = evs.add
318 a.Planner = func(context.Context, registry.Route) WakePlan { return plan }
319 a.genHealth = func(context.Context, Gen) error { return nil }
320 t.Cleanup(func() { a.Shutdown(context.Background()) })
321 return a, evs
322}
323
324// Merge triage T9: on a first wake whose health window is short (under the
325// verdict floor, so B would not be tried and the good build would boot in
326// its place), a refusal by B's own gate is abandonment: nothing boots, B is
327// not reported untried, and nothing is recorded. verdictFloorGate asks the
328// gate first, so a build held since the plan (or a lost lease, a fence) is
329// never mistaken for a lack of time. Here B's selection check passes at
330// checkPlan and refuses at B's exec. With the floor asked first, the good
331// build booted untried and the wake reported GenHealthy.
332func TestAGateRefusalOnAShortWindowAbandonsRatherThanGoingUntried(t *testing.T) {
333 shrinkVerdictFloor(t, time.Hour) // every window is under the floor
334 dir := t.TempDir()
335 good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
336 a, b, evs := planActivator(t, WakePlan{})
337 var asked atomic.Int32
338 a.Planner = func(context.Context, registry.Route) WakePlan {
339 return WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good,
340 Check: func(build string) error {
341 if build == "v2" && asked.Add(1) >= 2 {
342 return errors.New("v2 failed on a sibling")
343 }
344 return nil
345 }}
346 }
347 _, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
348 if !errors.Is(err, errPlanStale) || errors.Is(err, errNoTimeForVerdict) {
349 t.Fatalf("wake = %v, want the gate's refusal (errPlanStale), never a lack of time", err)
350 }
351 if got := classifyStep(nil, err); got != stepStale {
352 t.Fatalf("classifyStep = %v, want stepStale: abandonment", got)
353 }
354 if gens := b.started(); len(gens) != 0 {
355 t.Fatalf("spawned %+v: the good build booted untried after B's gate refused", gens)
356 }
357 time.Sleep(50 * time.Millisecond)
358 if k := evs.kinds(); len(k) != 0 {
359 t.Fatalf("events %v: an abandoned short-window wake reported something (an untried B reads as GenHealthy)", k)
360 }
361}
362
363// The exec gate rides on the context through SplitBackend to its exec half,
364// which asks it at its launch (genbackend.go's contract): a refusal there
365// reaches the caller unchanged.
366func TestSplitBackendCarriesTheExecGate(t *testing.T) {
367 exec := &recordingGenExec{fakeBackend: &fakeBackend{}}
368 s := &SplitBackend{Exec: exec, Unit: &UnitBackend{}}
369 refused := errors.New("refused at the launch")
370 ctx := withExecGate(context.Background(), func() error { return refused })
371 _, p, err := s.StartStaged(ctx, registry.Route{Host: "e.example.com"}, Gen{N: 7, StagedPath: "/opt/carlos/versions/v2/app"})
372 if !errors.Is(err, refused) || p != nil || exec.started != 0 {
373 t.Fatalf("StartStaged = %v, %v (started %d): want the gate's refusal and nothing started", p, err, exec.started)
374 }
375}
376
377// gatedCgroups gives b a cgroup manager on a temp-dir subtree (the
378// fixture delegatedBackend uses) whose log calls flip once a limit is
379// written: a log line naming a memory.max is the manager's preparation
380// (the parent's tier, the child's limits) under way.
381func gatedCgroups(t *testing.T, b *ExecBackend, host string, flip func()) string {
382 t.Helper()
383 root := t.TempDir()
384 for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} {
385 if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
386 t.Fatal(err)
387 }
388 }
389 var once sync.Once
390 m, err := cgroup.SetupAt(root, func(format string, args ...any) {
391 t.Logf(format, args...)
392 if flip != nil && strings.Contains(format, "memory.max=") {
393 once.Do(flip)
394 }
395 })
396 if err != nil {
397 t.Fatal(err)
398 }
399 parent := filepath.Join(root, cgroup.ExecName(host))
400 if err := os.MkdirAll(parent, 0o755); err != nil {
401 t.Fatal(err)
402 }
403 if err := os.WriteFile(filepath.Join(parent, "cgroup.controllers"), []byte("cpu io memory pids\n"), 0o644); err != nil {
404 t.Fatal(err)
405 }
406 b.Cgroups = m
407 return parent
408}
409
410// workerGateService is a Service over a REAL ExecBackend worker spawn
411// (realWorkerBackend) whose cgroup manager is gatedCgroups' temp-dir
412// subtree: a worker start runs the production preparation (the build's
413// publication, the cgroup and its limits) and its start attempts. It
414// returns the worker's row, the file its build writes its $0 to the moment
415// it runs, and the host's cgroup directory.
416func workerGateService(t *testing.T, flip func()) (*Service, *ExecBackend, registry.Route, string, string) {
417 t.Helper()
418 eb := testExecBackend(t)
419 host := "bot.example.com"
420 out := filepath.Join(t.TempDir(), "argv0")
421 stageArgv0Worker(t, eb, host, "v1", out)
422 dir := gatedCgroups(t, eb, host, flip)
423 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
424 if err != nil {
425 t.Fatal(err)
426 }
427 a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, &realWorkerBackend{fakeBackend: &fakeBackend{}, exec: eb}, reg)
428 a.GenerationsFile = release.GenerationsPath(t.TempDir())
429 t.Cleanup(func() { a.Shutdown(context.Background()) })
430 return a, eb, workerRoute(host), out, dir
431}
432
433// Astra R3 §5 item 4 (ruling 206, "park worker launch hook"): a worker
434// start's ownership gate is the cgroup manager's before-start hook, as an
435// instance's is, so it is asked AFTER the cgroup preparation (the host's
436// cgroup and its limits) and before the start. Ownership lost during that
437// preparation starts nothing: no process, no $0 written, nothing placed in
438// the cgroup, the gate's own error back, and the build's pending
439// publication dropped. Before the fix the gate rode the before-exec hook,
440// asked once before the cgroup preparation, and the worker started.
441func TestAWorkerStartIsGatedAfterItsCgroupPreparation(t *testing.T) {
442 t.Parallel()
443 var refuse atomic.Bool
444 a, _, rt, out, dir := workerGateService(t, func() { refuse.Store(true) })
445 lost := errors.New("lease renewal failed")
446 var asked atomic.Int32
447 gate := func() error {
448 asked.Add(1)
449 if refuse.Load() {
450 return lost
451 }
452 return nil
453 }
454 p, _, _, err := a.startWorker(context.Background(), rt, nil, nil, gate)
455 if p != nil {
456 _ = p.Stop(context.Background())
457 }
458 if !errors.Is(err, lost) || p != nil {
459 t.Fatalf("startWorker = %v, %v; want no process and the gate's refusal", p, err)
460 }
461 if !refuse.Load() || asked.Load() == 0 {
462 t.Fatalf("the cgroup preparation ran: %v; the gate was asked %d times", refuse.Load(), asked.Load())
463 }
464 time.Sleep(200 * time.Millisecond)
465 if _, err := os.Stat(out); err == nil {
466 t.Fatal("the worker ran after its ownership was lost during the cgroup preparation")
467 }
468 if b, err := os.ReadFile(filepath.Join(dir, "cgroup.procs")); err == nil && len(b) != 0 {
469 t.Fatalf("the worker's cgroup was given pid %s after a refusal at its start", b)
470 }
471 if got := readGenerations(t, a.GenerationsFile); len(got[rt.Host]) != 0 {
472 t.Fatalf("generations file %v names a build that never ran", got)
473 }
474}
475
476// The same gate is the manager's before hook at the PID-move fallback a
477// flat start takes after a failed placement (cgroup.StartGated's
478// contract), so ownership lost by then starts nothing. The temp-dir subtree
479// is no cgroupfs: the shim's checks refuse the placement before any fork,
480// so the gate (asked after a placement) is first asked at the fallback, and
481// refuses there. That a placement which got past the gate and then failed
482// asks it again at the fallback is internal/cgroup's
483// TestFlatPlacementFailureFallsBackToPIDMove: the hook passed through is
484// this same gate. Before the clone3 start left (amadan #272) this asked
485// twice, at the clone3 start and its retry.
486func TestAWorkerStartFallbackAsksTheGate(t *testing.T) {
487 t.Parallel()
488 a, eb, rt, out, dir := workerGateService(t, nil)
489 var asked atomic.Int32
490 eb.launchHook = func(string) { asked.Add(1) }
491 superseded := errors.New("the attempt was superseded")
492 gate := func() error {
493 if asked.Load() >= 1 {
494 return superseded
495 }
496 return nil
497 }
498 p, _, _, err := a.startWorker(context.Background(), rt, nil, nil, gate)
499 if p != nil {
500 _ = p.Stop(context.Background())
501 }
502 if n := asked.Load(); n != 1 {
503 t.Fatalf("the gate was asked %d times at the starts, want once: the PID-move fallback's", n)
504 }
505 if b, err := os.ReadFile(filepath.Join(dir, "cgroup.procs")); err == nil && len(b) != 0 {
506 t.Fatalf("the worker's cgroup was given pid %s after a refusal at its start", b)
507 }
508 if !errors.Is(err, superseded) || p != nil {
509 t.Fatalf("startWorker = %v, %v; want no process and the gate's refusal", p, err)
510 }
511 time.Sleep(200 * time.Millisecond)
512 if _, err := os.Stat(out); err == nil {
513 t.Fatal("the retry started the worker after its gate refused")
514 }
515}
516
517// Final review wave 2 (F1 completion, Astra round 2 finding 1): a flat
518// staged spawn's exec gate is the cgroup manager's before-start hook, so
519// it is asked AFTER the cgroup preparation (the parent's tier, the child
520// cgroup and its limits, their log lines) and the placement, before EACH
521// exec, a flat start's PID-move fallback included. Ownership lost during
522// that preparation, or between a failed placement and its fallback,
523// starts nothing: no process, no marker, the control socket unbound, the
524// generation's child cgroup removed, abandonment with no execution
525// evidence. Before the fix the gate ran once, before the cgroup
526// preparation, and each of these launched.
527func TestTheExecGateIsAskedAtEachCgroupStart(t *testing.T) {
528 t.Run("overlap successor, superseded during the cgroup preparation", func(t *testing.T) {
529 f := newSwapFixture(t)
530 b := newLaunchBackend(t, "flat", f.b)
531 b.eb.GenContainment = true
532 parent := gatedCgroups(t, b.eb, b.rt.Host, func() { f.rev.Add(1) })
533 cgroup.FakePlacementForTest(t) // a temp dir is no cgroupfs, and a generation is never moved by PID
534 f.a.backend = b
535 mark := filepath.Join(t.TempDir(), "v2.ran")
536 id := markerScript(t, t.TempDir(), "v2", mark)
537 at := f.attempt(ModeOverlap)
538 at.StagedPath, at.Digest = id.StagedPath, id.Digest
539 res := f.a.Swap(context.Background(), at, f.newest)
540 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) || res.Executed {
541 t.Fatalf("Swap = %+v, want abandoned (superseded), nothing executed", res)
542 }
543 gens := b.gens()
544 if len(gens) != 1 {
545 t.Fatalf("the backend was entered for generations %v, want the successor's alone", gens)
546 }
547 time.Sleep(100 * time.Millisecond)
548 if b.launched(t, id, mark) {
549 t.Fatal("the successor was launched after its attempt was superseded during the cgroup preparation")
550 }
551 b.prepGone(t, gens[0])
552 // The preparation did run (the flip is its log line), and nothing was
553 // placed in the child it made. (On cgroupfs the empty child is
554 // removed; in this fixture its limit files are ordinary files, so the
555 // rmdir is refused, as CleanupHost's comment explains.)
556 if b, err := os.ReadFile(filepath.Join(parent, cgroup.GenChild(gens[0]), "cgroup.procs")); err == nil && len(b) != 0 {
557 t.Fatalf("generation %d's child cgroup was given pid %s after a refusal at its start", gens[0], b)
558 }
559 })
560 t.Run("hold's pinned build, lease lost during the cgroup preparation", func(t *testing.T) {
561 f := newSwapFixture(t)
562 b := newLaunchBackend(t, "flat", f.b)
563 gatedCgroups(t, b.eb, b.rt.Host, func() { f.b.lastRep().setErr(errors.New("lease renewal failed")) })
564 f.a.backend = b
565 mark := filepath.Join(t.TempDir(), "v2.ran")
566 id := markerScript(t, t.TempDir(), "v2", mark)
567 at := f.attempt(ModeHold)
568 at.StagedPath, at.Digest = id.StagedPath, id.Digest
569 res := f.a.Swap(context.Background(), at, f.newest)
570 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapLeaseLost.Error()) || res.Executed {
571 t.Fatalf("Swap = %+v, want abandoned (the lost lease), nothing executed", res)
572 }
573 time.Sleep(100 * time.Millisecond)
574 if b.launched(t, id, mark) {
575 t.Fatal("the hold's build was launched after the lease was lost during the cgroup preparation")
576 }
577 b.prepGone(t, b.gens()[0])
578 })
579 t.Run("overlap successor, a failed placement is not retried by PID move", func(t *testing.T) {
580 f := newSwapFixture(t)
581 b := newLaunchBackend(t, "flat", f.b)
582 b.eb.GenContainment = true
583 // The temp-dir subtree is no cgroupfs: the shim's checks refuse the
584 // placement before any fork, so the gate, asked only after a
585 // placement (amadan #272, Ruling 13), is never asked. A generation is
586 // never started and then moved by PID (readiness round 2 Task 7), so
587 // there is no retry and no gate at all: the start is refused as the
588 // box's condition.
589 parent := gatedCgroups(t, b.eb, b.rt.Host, nil)
590 var asked atomic.Int32
591 b.eb.launchHook = func(string) { asked.Add(1) }
592 f.a.backend = b
593 mark := filepath.Join(t.TempDir(), "v2.ran")
594 id := markerScript(t, t.TempDir(), "v2", mark)
595 at := f.attempt(ModeOverlap)
596 at.StagedPath, at.Digest = id.StagedPath, id.Digest
597 res := f.a.Swap(context.Background(), at, f.newest)
598 if n := asked.Load(); n != 0 {
599 t.Fatalf("the gate was asked %d times, want never: no placement, and no retry", n)
600 }
601 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, cgroup.ErrLimitNotApplied.Error()) || res.Executed {
602 t.Fatalf("Swap = %+v, want abandoned (limits not applied), nothing executed", res)
603 }
604 time.Sleep(100 * time.Millisecond)
605 if b.launched(t, id, mark) {
606 t.Fatal("the successor was launched after its placement failed")
607 }
608 gens := b.gens()
609 b.prepGone(t, gens[0])
610 // No PID move. In this fixture the child's limit files are ordinary
611 // files, so the refusal's rmdir leaves the child in place: it must
612 // still be there with its memory.max written (else the check below
613 // proves nothing), and with no cgroup.procs at all, which is the
614 // file a start-and-move writes the PID into.
615 child := filepath.Join(parent, cgroup.GenChild(gens[0]))
616 if _, err := os.Stat(filepath.Join(child, "memory.max")); err != nil {
617 t.Fatalf("generation %d's child cgroup has no limit written (%v): the start never reached its placement", gens[0], err)
618 }
619 if got, err := os.ReadFile(filepath.Join(child, "cgroup.procs")); !os.IsNotExist(err) {
620 t.Fatalf("generation %d's child cgroup has a cgroup.procs (%q, %v): a PID move wrote it", gens[0], got, err)
621 }
622 })
623}
624
625// liveGate is a LiveExecGate for tests: it records every build it is asked
626// about and refuses the builds in refuse, as a declined box's failure holds
627// would (internal/host).
628type liveGate struct {
629 mu sync.Mutex
630 asked []string
631 refuse map[string]bool
632}
633
634var errLiveHeld = errors.New("a failure record holds this build")
635
636func (g *liveGate) gate(_ registry.Route, build string) error {
637 g.mu.Lock()
638 defer g.mu.Unlock()
639 g.asked = append(g.asked, build)
640 if g.refuse[build] {
641 return fmt.Errorf("%w: %s", errLiveHeld, build)
642 }
643 return nil
644}
645
646func (g *liveGate) builds() []string {
647 g.mu.Lock()
648 defer g.mu.Unlock()
649 return slices.Clone(g.asked)
650}
651
652// repointLive points b's live/<host> at bin, as root's adopt pass does.
653func repointLive(t *testing.T, b *ExecBackend, host, bin string) {
654 t.Helper()
655 tmp := b.liveLink(host) + ".tmp"
656 os.Remove(tmp)
657 if err := os.Symlink(bin, tmp); err != nil {
658 t.Error(err)
659 return
660 }
661 if err := os.Rename(tmp, b.liveLink(host)); err != nil {
662 t.Error(err)
663 }
664}
665
666// flatLiveFixture is a flat row on a real ExecBackend whose live link names
667// v1, with v2 staged beside it. Each build writes its own $0 to out the
668// moment it runs, so a test can tell which bytes were exec'd and by which
669// path.
670func flatLiveFixture(t *testing.T) (b *ExecBackend, rt registry.Route, v1, v2, out string) {
671 t.Helper()
672 b = testExecBackend(t)
673 b.RunDirOverride = t.TempDir()
674 host := "live.example.com"
675 out = filepath.Join(t.TempDir(), "argv0")
676 v2 = stageArgv0Worker(t, b, host, "v2", out)
677 v1 = stageArgv0Worker(t, b, host, "v1", out)
678 rt = registry.Route{Host: host, Kind: "instance", Hibernate: true,
679 DBPath: filepath.Join(b.DataDir, host+".db"), Socket: filepath.Join(b.RunDirOverride, host+".sock")}
680 return b, rt, v1, v2, out
681}
682
683// Task 9 (Astra R1 #6, the R2/R3 declined residuals; plan review #6): on a
684// box that declined CARLOS_SWAP=overlap and honours failure records, the
685// flat live-link spawn resolves live/<host> ONCE, asks LiveExecGate about
686// that build at the launch (after the cgroup's preparation), and execs the
687// staged path it resolved, so a repoint landing between the gate and the
688// exec cannot start a build the gate never saw. A refusal starts nothing.
689// With the gate nil the spawn execs the live link exactly as before.
690func TestALiveExecGateGatesTheFlatLiveLinkSpawn(t *testing.T) {
691 t.Parallel()
692 t.Run("gated build is the exec'd build", func(t *testing.T) {
693 b, rt, v1, v2, out := flatLiveFixture(t)
694 g := &liveGate{refuse: map[string]bool{"v2": true}}
695 b.LiveExecGate = g.gate
696 // Root repoints at held v2 after the gate has been asked about v1 and
697 // before the exec.
698 b.launchHook = onCall(1, func() { repointLive(t, b, rt.Host, v2) })
699 p, ver, err := b.spawnChildFrom(context.Background(), rt, rt.Socket, 0, "", cgroup.Limit{})
700 if err != nil {
701 t.Fatalf("spawn = %v", err)
702 }
703 defer p.Stop(context.Background())
704 if got := strings.TrimSpace(waitForFile(t, out, 5*time.Second)); got != v1 {
705 t.Fatalf("$0 = %q, want the staged v1 the gate was asked about (%s)", got, v1)
706 }
707 if ver != "v1" || !slices.Equal(g.builds(), []string{"v1"}) {
708 t.Fatalf("spawn version %q, gate asked %v; want v1 and [v1]", ver, g.builds())
709 }
710 })
711 t.Run("a held build starts nothing", func(t *testing.T) {
712 b, rt, _, v2, out := flatLiveFixture(t)
713 repointLive(t, b, rt.Host, v2)
714 g := &liveGate{refuse: map[string]bool{"v2": true}}
715 b.LiveExecGate = g.gate
716 p, _, err := b.spawnChildFrom(context.Background(), rt, rt.Socket, 0, "", cgroup.Limit{})
717 if p != nil {
718 _ = p.Stop(context.Background())
719 }
720 if !errors.Is(err, errLiveHeld) || p != nil {
721 t.Fatalf("spawn = %v, %v; want no process and the gate's refusal", p, err)
722 }
723 time.Sleep(200 * time.Millisecond)
724 if _, err := os.Stat(out); err == nil {
725 t.Fatal("held v2 ran")
726 }
727 if _, err := os.Stat(rt.Socket + ".control"); !os.IsNotExist(err) {
728 t.Fatalf("control socket after a refusal: %v, want it unbound", err)
729 }
730 if !slices.Equal(g.builds(), []string{"v2"}) {
731 t.Fatalf("gate asked %v, want [v2]", g.builds())
732 }
733 })
734 t.Run("no gate is today's spawn", func(t *testing.T) {
735 b, rt, _, _, out := flatLiveFixture(t)
736 p, ver, err := b.spawnChildFrom(context.Background(), rt, rt.Socket, 0, "", cgroup.Limit{})
737 if err != nil {
738 t.Fatalf("spawn = %v", err)
739 }
740 defer p.Stop(context.Background())
741 if got := strings.TrimSpace(waitForFile(t, out, 5*time.Second)); got != b.liveLink(rt.Host) || ver != "v1" {
742 t.Fatalf("$0 = %q, version %q; want today's exec of %s, v1", got, ver, b.liveLink(rt.Host))
743 }
744 })
745}
746
747// Task 9: a nested live-link start asks LiveExecGate about the build the
748// live link names, right before root is asked to start the unit. A refusal
749// asks root for nothing.
750func TestALiveExecGateGatesTheNestedLiveLinkStart(t *testing.T) {
751 ctl := &fakeControl{inv: "inv1"}
752 b, rt := nestedBackend(t, ctl, &fakeUnit{})
753 g := &liveGate{refuse: map[string]bool{"v1": true}}
754 b.LiveExecGate = g.gate
755 _, _, err := b.Start(context.Background(), rt)
756 if !errors.Is(err, errLiveHeld) {
757 t.Fatalf("Start = %v, want the gate's refusal", err)
758 }
759 if strings.Contains(ctl.joined(), "start ") {
760 t.Fatalf("root was asked to start the unit after the gate refused: %q", ctl.joined())
761 }
762 if !slices.Equal(g.builds(), []string{"v1"}) {
763 t.Fatalf("gate asked %v, want [v1]", g.builds())
764 }
765 if _, err := os.Stat(rt.Socket + ".control"); !os.IsNotExist(err) {
766 t.Fatalf("control socket after a refusal: %v, want it unbound", err)
767 }
768}
769
770// Task 9: a worker spawned from the live link (onto nil) asks LiveExecGate
771// about the build it resolved, before its publication and its exec; a
772// refusal publishes and runs nothing. A recycle onto the captured build
773// (onto set) is not a move onto the live link, and is not asked.
774func TestALiveExecGateGatesAWorkerSpawnFromTheLiveLink(t *testing.T) {
775 t.Parallel()
776 b := testExecBackend(t)
777 host := "bot.example.com"
778 out := filepath.Join(t.TempDir(), "argv0")
779 v1 := stageArgv0Worker(t, b, host, "v1", out)
780 stageArgv0Worker(t, b, host, "v2", out)
781 g := &liveGate{refuse: map[string]bool{"v2": true}}
782 b.LiveExecGate = g.gate
783 published := 0
784 ctx := withBeforeExec(context.Background(), func(release.StagedIdentity) error { published++; return nil })
785 p, _, err := b.StartWorkerStaged(ctx, workerRoute(host), nil, nil)
786 if p != nil {
787 _ = p.Stop(context.Background())
788 }
789 if !errors.Is(err, errLiveHeld) || p != nil || published != 0 {
790 t.Fatalf("StartWorkerStaged = %v, %v (published %d); want the gate's refusal, nothing published or run", p, err, published)
791 }
792 time.Sleep(200 * time.Millisecond)
793 if _, err := os.Stat(out); err == nil {
794 t.Fatal("held v2 ran as the worker")
795 }
796 sum, err := release.FileSHA256(v1)
797 if err != nil {
798 t.Fatal(err)
799 }
800 onto := release.StagedIdentity{Version: "v1", StagedPath: v1, Digest: sum}
801 p, id, err := b.StartWorkerStaged(ctx, workerRoute(host), nil, &onto)
802 if err != nil {
803 t.Fatalf("recycle onto the captured v1 = %v", err)
804 }
805 defer p.Stop(context.Background())
806 if id != onto || strings.TrimSpace(waitForFile(t, out, 5*time.Second)) != v1 {
807 t.Fatalf("recycled %+v, want the captured v1", id)
808 }
809 if !slices.Equal(g.builds(), []string{"v2"}) {
810 t.Fatalf("gate asked %v, want only the live-link spawn's [v2]", g.builds())
811 }
812}
813
814// Task 9 (brief (b)): a declined worker whose live link moves to a held
815// build is not stopped by the sweep's converge. The activator asks the gate
816// before it retires the old child, so a refusal leaves the worker running
817// its current build instead of stopping it into a refused spawn.
818func TestADeclinedWorkerIsNotConvergedOntoAHeldBuild(t *testing.T) {
819 t.Parallel()
820 a, b, clk, _, ver := stagedActivator(t)
821 g := &liveGate{refuse: map[string]bool{"v2": true}}
822 a.SetLiveExecGate(g.gate)
823 host := "bot.x.internal"
824 if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
825 t.Fatalf("WakeWorker: %v", err)
826 }
827 ver.Store("v2")
828 a.sweepOnce()
829 clk.advance(ConvergeGrace + time.Minute)
830 a.sweepOnce()
831 a.sweepOnce()
832 time.Sleep(100 * time.Millisecond)
833 if calls := b.calls(); len(calls) != 1 || a.entryRespawning(host) {
834 t.Fatalf("spawns %v (respawning %v): the worker was converged onto held v2", calls, a.entryRespawning(host))
835 }
836 if b.lastWorker().Exited() {
837 t.Fatal("the converge stopped the worker serving v1")
838 }
839 if got := a.entrySpawnVersion(host); got != "v1" {
840 t.Fatalf("spawn version %q, want v1", got)
841 }
842 if !slices.Contains(g.builds(), "v2") {
843 t.Fatalf("gate asked %v, want v2 asked", g.builds())
844 }
845 // Once the link names a build the gate allows, the converge runs as
846 // before.
847 ver.Store("v3")
848 a.sweepOnce()
849 clk.advance(ConvergeGrace + time.Minute)
850 a.sweepOnce()
851 waitFor(t, "the converge onto v3", func() bool { return a.entrySpawnVersion(host) == "v3" && !a.entryRespawning(host) })
852}
853
854// Task 9 (brief (c)): a declined worker that crashes while its live link
855// names a held build respawns its captured build, however long the drift
856// lasts: the recycle's converge is refused before anything is stopped.
857func TestADeclinedWorkerCrashRecyclesItsCapturedBuildWhenTheLiveLinkIsHeld(t *testing.T) {
858 t.Parallel()
859 a, b, clk, _, ver := stagedActivator(t)
860 a.SetLiveExecGate((&liveGate{refuse: map[string]bool{"v2": true}}).gate)
861 host := "bot.x.internal"
862 if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
863 t.Fatalf("WakeWorker: %v", err)
864 }
865 ver.Store("v2")
866 for i := 1; i <= 5; i++ {
867 b.lastWorker().kill()
868 clk.advance(sidecarRestartMax + time.Second)
869 a.sweepOnce()
870 waitForRestart(t, a, b.fakeBackend, host, i+1)
871 }
872 for i, c := range b.calls()[1:] {
873 if c == nil || *c != fakeIdentity("v1") {
874 t.Fatalf("respawn %d asked for %v; want the captured v1 every time", i+1, c)
875 }
876 }
877 if got := a.entrySpawnVersion(host); got != "v1" {
878 t.Fatalf("spawn version %q, want v1", got)
879 }
880}
881
882// Task 9 (brief (c)): a worker with no captured build (a backend without
883// the staged capability) whose live link names a held build is not
884// respawned from it: a crashed one stays down until a wake starts it, and
885// one whose max_run falls due keeps running.
886func TestADeclinedUncapturedWorkerIsNotRespawnedOntoAHeldBuild(t *testing.T) {
887 t.Parallel()
888 t.Run("crashed", func(t *testing.T) {
889 a, b, clk, _ := testActivator(t, time.Hour)
890 a.liveVer = func(string) (string, error) { return "v2", nil }
891 a.SetLiveExecGate((&liveGate{refuse: map[string]bool{"v2": true}}).gate)
892 host := "bot.x.internal"
893 if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
894 t.Fatalf("WakeWorker: %v", err)
895 }
896 b.lastWorker().kill()
897 for range 3 {
898 a.sweepOnce()
899 clk.advance(sidecarRestartMax + time.Second)
900 }
901 time.Sleep(100 * time.Millisecond)
902 if n := b.workerStarts(); n != 1 || a.entryRespawning(host) {
903 t.Fatalf("workerStarts = %d (respawning %v): respawned onto held v2", n, a.entryRespawning(host))
904 }
905 if !a.Owns(host) {
906 t.Fatal("the entry went: it stays, down, for a wake to start")
907 }
908 })
909 t.Run("max_run", func(t *testing.T) {
910 a, b, clk, _ := testActivator(t, time.Hour)
911 a.liveVer = func(string) (string, error) { return "v2", nil }
912 a.SetLiveExecGate((&liveGate{refuse: map[string]bool{"v2": true}}).gate)
913 host := "bot.x.internal"
914 rt := alwaysOnWorkerRoute(host)
915 rt.MaxRunSeconds = 30
916 if err := a.WakeWorker(rt); err != nil {
917 t.Fatalf("WakeWorker: %v", err)
918 }
919 for range 3 {
920 clk.advance(31 * time.Second)
921 a.sweepOnce()
922 }
923 time.Sleep(100 * time.Millisecond)
924 if n := b.workerStarts(); n != 1 || a.entryRespawning(host) {
925 t.Fatalf("workerStarts = %d (respawning %v): recycled onto held v2", n, a.entryRespawning(host))
926 }
927 if b.lastWorker().Exited() {
928 t.Fatal("the running child was stopped by a recycle the gate refused")
929 }
930 })
931}
932
933// Task 9 fix round 1 (ruling 1): the legacy co-spawned sidecar execs the
934// live link too, and its wake tail, late attachment and the sweep's
935// supervision respawn it while the instance keeps running. On a declined box
936// with the gate set, root's repoint of the link at a held build must not
937// start that build as the sidecar: the start resolves the link (no hash),
938// asks the gate about that build, and execs the path it resolved. A refusal
939// starts nothing (the supervisor's backoff keeps it down). With the gate nil
940// the sidecar execs the link, as before.
941func TestALiveExecGateGatesTheLegacySidecarStart(t *testing.T) {
942 t.Parallel()
943 sidecarEnv := func(t *testing.T, b *ExecBackend, rt registry.Route) {
944 t.Helper()
945 if err := os.WriteFile(b.pathsFor(rt).sidecarEnv, nil, 0o600); err != nil {
946 t.Fatal(err)
947 }
948 }
949 t.Run("flat, gated build is the exec'd build", func(t *testing.T) {
950 b, rt, v1, _, out := flatLiveFixture(t)
951 sidecarEnv(t, b, rt)
952 g := &liveGate{refuse: map[string]bool{"v2": true}}
953 b.LiveExecGate = g.gate
954 p, err := b.StartSidecar(context.Background(), rt)
955 if err != nil {
956 t.Fatalf("StartSidecar = %v", err)
957 }
958 defer p.Stop(context.Background())
959 if got := strings.TrimSpace(waitForFile(t, out, 5*time.Second)); got != v1 {
960 t.Fatalf("sidecar $0 = %q, want the staged v1 the gate was asked about (%s)", got, v1)
961 }
962 if !slices.Equal(g.builds(), []string{"v1"}) {
963 t.Fatalf("gate asked %v, want [v1]", g.builds())
964 }
965 })
966 t.Run("flat, a held build starts nothing", func(t *testing.T) {
967 b, rt, _, v2, out := flatLiveFixture(t)
968 sidecarEnv(t, b, rt)
969 repointLive(t, b, rt.Host, v2)
970 b.LiveExecGate = (&liveGate{refuse: map[string]bool{"v2": true}}).gate
971 p, err := b.StartSidecar(context.Background(), rt)
972 if p != nil {
973 _ = p.Stop(context.Background())
974 }
975 if !errors.Is(err, errLiveHeld) || p != nil {
976 t.Fatalf("StartSidecar = %v, %v; want no process and the gate's refusal", p, err)
977 }
978 time.Sleep(200 * time.Millisecond)
979 if _, err := os.Stat(out); err == nil {
980 t.Fatal("held v2 ran as the sidecar")
981 }
982 })
983 t.Run("flat, no gate is today's", func(t *testing.T) {
984 b, rt, _, _, out := flatLiveFixture(t)
985 sidecarEnv(t, b, rt)
986 p, err := b.StartSidecar(context.Background(), rt)
987 if err != nil {
988 t.Fatalf("StartSidecar = %v", err)
989 }
990 defer p.Stop(context.Background())
991 if got := strings.TrimSpace(waitForFile(t, out, 5*time.Second)); got != b.liveLink(rt.Host) {
992 t.Fatalf("sidecar $0 = %q, want today's exec of %s", got, b.liveLink(rt.Host))
993 }
994 })
995 t.Run("nested, a held build asks root for nothing", func(t *testing.T) {
996 ctl := &fakeControl{inv: "inv1"}
997 b, rt := nestedBackend(t, ctl, &fakeUnit{})
998 g := &liveGate{refuse: map[string]bool{"v1": true}}
999 b.LiveExecGate = g.gate
1000 p, err := b.StartSidecar(context.Background(), rt)
1001 if !errors.Is(err, errLiveHeld) || p != nil {
1002 t.Fatalf("StartSidecar = %v, %v; want the gate's refusal", p, err)
1003 }
1004 if strings.Contains(ctl.joined(), "start ") {
1005 t.Fatalf("root was asked to start the sidecar after the gate refused: %q", ctl.joined())
1006 }
1007 if !slices.Equal(g.builds(), []string{"v1"}) {
1008 t.Fatalf("gate asked %v, want [v1]", g.builds())
1009 }
1010 })
1011}
1012
1013// Task 9 fix round 1 (ruling 2): the sweep's drift cycle of an awake
1014// instance, or a hibernating worker, stops it so its next wake runs the new
1015// build. On a declined box a held build's next wake is refused, so the gate
1016// is asked first: a refusal keeps it running and spends no budget. A build
1017// the gate allows cycles as before.
1018func TestADeclinedSweepDoesNotCycleOntoAHeldBuild(t *testing.T) {
1019 t.Parallel()
1020 t.Run("instance", func(t *testing.T) {
1021 a, b, clk, _ := testActivator(t, time.Hour)
1022 var ver atomic.Value
1023 ver.Store("v1")
1024 a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
1025 b.execVerFn = func() string { return ver.Load().(string) }
1026 a.SetLiveExecGate((&liveGate{refuse: map[string]bool{"v2": true}}).gate)
1027 release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
1028 if err != nil {
1029 t.Fatalf("beginRequest: %v", err)
1030 }
1031 release()
1032 ver.Store("v2")
1033 a.sweepOnce()
1034 clk.advance(ConvergeGrace + time.Second)
1035 a.sweepOnce()
1036 a.sweepOnce()
1037 time.Sleep(50 * time.Millisecond)
1038 if a.entryCount() != 1 || b.lastProc().isStopped() {
1039 t.Fatal("the sweep stopped an instance serving v1 into a wake onto held v2")
1040 }
1041 ver.Store("v3")
1042 a.sweepOnce()
1043 clk.advance(ConvergeGrace + time.Second)
1044 a.sweepOnce()
1045 waitFor(t, "the cycle onto v3", func() bool { return a.entryCount() == 0 })
1046 })
1047 t.Run("hibernating worker", func(t *testing.T) {
1048 a, b, clk, _ := testActivator(t, time.Hour)
1049 var ver atomic.Value
1050 ver.Store("v1")
1051 a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
1052 b.execVerFn = func() string { return ver.Load().(string) }
1053 a.SetLiveExecGate((&liveGate{refuse: map[string]bool{"v2": true}}).gate)
1054 host := "bot.x.internal"
1055 if err := a.WakeWorker(testWorkerRoute(host)); err != nil {
1056 t.Fatalf("WakeWorker: %v", err)
1057 }
1058 ver.Store("v2")
1059 a.sweepOnce()
1060 clk.advance(ConvergeGrace + time.Second)
1061 a.sweepOnce()
1062 a.sweepOnce()
1063 time.Sleep(50 * time.Millisecond)
1064 if !a.Owns(host) || b.lastWorker().Exited() {
1065 t.Fatal("the sweep stopped a worker serving v1 into a wake onto held v2")
1066 }
1067 })
1068}
1069