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 "log"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "slices"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "testing"
16 "time"
17
18 "github.com/carlosframework/platform/internal/cgroup"
19 "github.com/carlosframework/platform/internal/instancectl"
20 "github.com/carlosframework/platform/internal/registry"
21)
22
23// plantMember puts a generation into host's operation set the way Swap does
24// (Task 4). A successor waits for the barrier's cancel and is then stopped by
25// its owner. A retiring generation is stopped by its owner at once.
26func plantMember(t *testing.T, a *Service, host string, role genRole, p *fakeProc, n uint64) *genMember {
27 t.Helper()
28 ctx, cancel := context.WithCancel(context.Background())
29 t.Cleanup(cancel)
30 m := &genMember{gen: Gen{N: n, Build: "v2"}, role: role, proc: p, done: make(chan struct{})}
31 if role != roleRetiring {
32 m.cancel = cancel
33 }
34 a.mu.Lock()
35 e := a.entries[host]
36 if e == nil {
37 a.mu.Unlock()
38 t.Fatalf("no entry for %s", host)
39 }
40 a.addMemberLocked(e, m)
41 a.mu.Unlock()
42 go func() {
43 if role != roleRetiring {
44 <-ctx.Done()
45 }
46 a.dropMember(e, m)
47 }()
48 return m
49}
50
51type barrierEnv struct {
52 a *Service
53 b *fakeBackend
54 clk *fakeClock
55 host string
56}
57
58func awakeForBarrier(t *testing.T, swap bool) barrierEnv {
59 t.Helper()
60 a, b, clk, _ := testActivator(t, time.Hour)
61 a.gens = swap
62 host := "barrier.example.com"
63 arrangeAwake(t, a, testRoute(host))
64 return barrierEnv{a, b, clk, host}
65}
66
67// stopSignal is an EventSink that closes began when an entry's stop emits
68// "instance stopping": the stop goroutine has started, and from there it
69// reaches the barrier without blocking on anything else.
70type stopSignal struct {
71 once sync.Once
72 began chan struct{}
73}
74
75func (s *stopSignal) App(string, string, string, string) {}
76func (s *stopSignal) Event(_, _, _, _, msg string) {
77 if msg == "instance stopping" {
78 s.once.Do(func() { close(s.began) })
79 }
80}
81
82// Every terminal stop cancels and joins the whole operation set before the
83// final checkpoint and the lease release (spec §7.4). The member's stop is
84// held open by a gate; until it closes, neither the instance's own stop
85// nor the lease release may happen.
86func TestSwapWitnessBarrierJoinsBeforeTheLease(t *testing.T) {
87 t.Parallel()
88 stops := []struct {
89 name string
90 stop func(x barrierEnv)
91 rewake bool
92 }{
93 {"hibernate", func(x barrierEnv) { x.clk.advance(2 * time.Hour); x.a.sweepOnce() }, true},
94 {"lease lost", func(x barrierEnv) { x.b.lastRep().setErr(errors.New("renewal failed")); x.a.sweepOnce() }, true},
95 {"restart", func(x barrierEnv) { x.a.Bounce(x.host) }, true},
96 {"maintenance fence", func(x barrierEnv) {
97 go func() {
98 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
99 defer cancel()
100 x.a.FenceAndStop(ctx, x.host, 1) //nolint:errcheck
101 }()
102 }, false},
103 {"move or park retire", func(x barrierEnv) { x.a.RetireExcept(map[string]bool{}) }, true},
104 {"shutdown", func(x barrierEnv) { go x.a.Shutdown(context.Background()) }, false},
105 }
106 for _, tc := range stops {
107 for _, role := range []genRole{roleSuccessor, roleRetiring} {
108 t.Run(tc.name+"/"+role.String(), func(t *testing.T) {
109 x := awakeForBarrier(t, true)
110 sig := &stopSignal{began: make(chan struct{})}
111 x.a.Logs = sig
112 joining := make(chan struct{})
113 var joinOnce sync.Once
114 x.a.mu.Lock()
115 x.a.joinWaiting = func(_, what string) {
116 if what == "operation set" {
117 joinOnce.Do(func() { close(joining) })
118 }
119 }
120 x.a.mu.Unlock()
121 gate := make(chan struct{})
122 member := &fakeProc{label: "gen:9", rec: x.b, stopGate: gate}
123 plantMember(t, x.a, x.host, role, member, 9)
124 incumbent, rep := x.b.lastProc(), x.b.lastRep()
125 tc.stop(x)
126 select {
127 case <-sig.began:
128 case <-time.After(5 * time.Second):
129 t.Fatal("the stop never began")
130 }
131 // The stop has begun. Wait for it to reach the barrier (the
132 // join seam's positive signal), or, if something skipped the
133 // barrier, for the incumbent's stop or the lease release.
134 waitFor(t, "the stop at the barrier", func() bool {
135 select {
136 case <-joining:
137 return true
138 default:
139 }
140 return rep.isClosed() || incumbent.stopEntered() > 0
141 })
142 if rep.isClosed() || incumbent.stopEntered() > 0 {
143 t.Fatal("the stop went past the barrier while a generation was still draining")
144 }
145 close(gate)
146 waitFor(t, "the lease release", rep.isClosed)
147 ev := x.b.events()
148 if i, j := slices.Index(ev, "stop:gen:9"), slices.Index(ev, "stop:instance"); i < 0 || j < 0 || i > j {
149 t.Fatalf("order %v: the member must stop before the instance", ev)
150 }
151 if tc.rewake {
152 // #231: Owns stays true while an entry drains, so the stop is
153 // proven by a fresh spawn instead.
154 arrangeAwake(t, x.a, testRoute(x.host))
155 if got := x.b.startCount(); got != 2 {
156 t.Fatalf("Start calls = %d after the stop, want 2", got)
157 }
158 }
159 })
160 }
161 }
162}
163
164// A member whose termination cannot be proven (ErrStillRunning) keeps the
165// lease, exactly as an unproven main process does (spec §7.4). The reaper
166// finishes the member once it exits, and then the stop completes.
167func TestBarrierKeepsTheLeaseWhileAGenerationIsUnproven(t *testing.T) {
168 x := awakeForBarrier(t, true)
169 old := opJoinBound
170 opJoinBound = 100 * time.Millisecond
171 t.Cleanup(func() { opJoinBound = old })
172 member := &fakeProc{label: "gen:9", rec: x.b, stuck: true}
173 plantMember(t, x.a, x.host, roleRetiring, member, 9)
174 waitFor(t, "the member marked unproven", func() bool {
175 x.a.mu.Lock()
176 defer x.a.mu.Unlock()
177 m := x.a.entries[x.host].ops.members[9]
178 return m != nil && m.unproven
179 })
180 rep := x.b.lastRep()
181 x.a.Bounce(x.host)
182 waitFor(t, "the stop to pend", func() bool {
183 x.a.mu.Lock()
184 defer x.a.mu.Unlock()
185 e := x.a.entries[x.host]
186 return e != nil && e.pendingStop
187 })
188 if rep.isClosed() {
189 t.Fatal("the lease was released over an unproven generation")
190 }
191 member.exit()
192 x.a.sweepOnce() // reaps the member
193 waitFor(t, "the member reaped", func() bool {
194 x.a.mu.Lock()
195 defer x.a.mu.Unlock()
196 e := x.a.entries[x.host]
197 return e == nil || len(e.ops.members) == 0
198 })
199 x.a.sweepOnce() // finishes the stop
200 waitFor(t, "the lease release", rep.isClosed)
201 arrangeAwake(t, x.a, testRoute(x.host))
202 if got := x.b.startCount(); got != 2 {
203 t.Fatalf("Start calls = %d, want 2", got)
204 }
205}
206
207// killGenBackend is fakeBackend plus plan 2's KillGen. err, while set, is
208// what every kill answers: a generation child that still holds a process.
209type killGenBackend struct {
210 *fakeBackend
211 kmu sync.Mutex
212 err error
213 kills []uint64
214}
215
216func (k *killGenBackend) KillGen(_ context.Context, _ registry.Route, n uint64) error {
217 k.kmu.Lock()
218 defer k.kmu.Unlock()
219 k.kills = append(k.kills, n)
220 return k.err
221}
222
223func (k *killGenBackend) setErr(err error) {
224 k.kmu.Lock()
225 k.err = err
226 k.kmu.Unlock()
227}
228
229func (k *killGenBackend) killed() []uint64 {
230 k.kmu.Lock()
231 defer k.kmu.Unlock()
232 return slices.Clone(k.kills)
233}
234
235// A generation's leader can exit while a process it forked lives on in its
236// child cgroup. The leader's exit is not termination (spec §7.3, §7.4).
237// After the graceful drain the member's own child is killed and proven
238// empty through plan 2's KillGen. While that proof fails, the member stays
239// in the set and the lease stays held, exactly as for ErrStillRunning. The
240// reaper retries the kill and finishes the stop once the child is empty.
241//
242// Without the KillGen step, stopMember accepts Exited() alone: the member
243// leaves the set at once, "marked unproven" never happens, and the Bounce
244// releases the lease over the surviving descendant.
245func TestSwapWitnessMemberStopProvesTheGenerationChildEmpty(t *testing.T) {
246 x := awakeForBarrier(t, true)
247 old := opJoinBound
248 opJoinBound = 100 * time.Millisecond
249 t.Cleanup(func() { opJoinBound = old })
250 kb := &killGenBackend{fakeBackend: x.b, err: errors.New("g9 still holds a process")}
251 x.a.backend = kb
252 member := &fakeProc{label: "gen:9", rec: x.b} // its leader exits on SIGTERM
253 plantMember(t, x.a, x.host, roleRetiring, member, 9)
254 waitFor(t, "the member marked unproven", func() bool {
255 x.a.mu.Lock()
256 defer x.a.mu.Unlock()
257 m := x.a.entries[x.host].ops.members[9]
258 return m != nil && m.unproven
259 })
260 if !member.isStopped() || !member.Exited() {
261 t.Fatal("the graceful stop never ran, or the leader did not exit")
262 }
263 if got := kb.killed(); !slices.Equal(got, []uint64{9}) {
264 t.Fatalf("KillGen calls = %v, want [9] (the member's own generation)", got)
265 }
266 rep := x.b.lastRep()
267 x.a.Bounce(x.host)
268 waitFor(t, "the stop to pend", func() bool {
269 x.a.mu.Lock()
270 defer x.a.mu.Unlock()
271 e := x.a.entries[x.host]
272 return e != nil && e.pendingStop
273 })
274 if rep.isClosed() {
275 t.Fatal("the lease was released while generation 9's child still held a process")
276 }
277 x.a.sweepOnce() // the reaper retries the kill, which still fails
278 waitFor(t, "the reaper's retry to finish", func() bool {
279 x.a.mu.Lock()
280 defer x.a.mu.Unlock()
281 m := x.a.entries[x.host].ops.members[9]
282 return len(kb.killed()) >= 2 && m != nil && !m.reaping
283 })
284 if rep.isClosed() {
285 t.Fatal("the lease was released on a failed retry")
286 }
287 kb.setErr(nil)
288 x.a.sweepOnce() // the kill proves the child empty now
289 waitFor(t, "the member reaped", func() bool {
290 x.a.mu.Lock()
291 defer x.a.mu.Unlock()
292 e := x.a.entries[x.host]
293 return e == nil || len(e.ops.members) == 0
294 })
295 x.a.sweepOnce() // finishes the stop
296 waitFor(t, "the lease release", rep.isClosed)
297}
298
299// A backend with no generation children (containment off answers
300// errNoGenContainment) has nothing more to prove than the leader's exit:
301// the generation shares the host cgroup, whose emptiness the terminal
302// stop's own evidence proves.
303func TestMemberStopWithoutContainmentAcceptsTheLeaderExit(t *testing.T) {
304 x := awakeForBarrier(t, true)
305 kb := &killGenBackend{fakeBackend: x.b, err: fmt.Errorf("wrapped: %w", errNoGenContainment)}
306 x.a.backend = kb
307 m := plantMember(t, x.a, x.host, roleRetiring, &fakeProc{label: "gen:9", rec: x.b}, 9)
308 select {
309 case <-m.done:
310 case <-time.After(5 * time.Second):
311 t.Fatal("the member never left the set")
312 }
313}
314
315// A member that never got a process (its spawn failed with its kill
316// unproven, as startUnitAt answers) is not proven by the missing handle:
317// its generation's child is, through KillGen (P2-13 (f)).
318func TestMemberStopWithoutAProcessProvesItsChild(t *testing.T) {
319 x := awakeForBarrier(t, true)
320 kb := &killGenBackend{fakeBackend: x.b, err: errors.New("g12 still holds a process")}
321 x.a.backend = kb
322 m := &genMember{gen: Gen{N: 12, Build: "v2"}, role: roleSpawning, done: make(chan struct{})}
323 x.a.mu.Lock()
324 e := x.a.entries[x.host]
325 x.a.addMemberLocked(e, m)
326 x.a.mu.Unlock()
327 if x.a.dropMember(e, m) {
328 t.Fatal("a member with no process and an unproven child was taken as terminated")
329 }
330 if got := kb.killed(); !slices.Equal(got, []uint64{12}) {
331 t.Fatalf("KillGen calls = %v, want [12]", got)
332 }
333 kb.setErr(nil)
334 x.a.sweepOnce()
335 select {
336 case <-m.done:
337 case <-time.After(5 * time.Second):
338 t.Fatal("the member never left once its child was empty")
339 }
340}
341
342// stubbornProc is a main process whose Stop fails with an error other than
343// ErrStillRunning (a claims-on KillAndEmpty error) and whose leader lives
344// on until exit. stops counts Stop calls.
345type stubbornProc struct {
346 exited atomic.Bool
347 stops atomic.Int32
348}
349
350func (p *stubbornProc) Stop(context.Context) error {
351 p.stops.Add(1)
352 return errors.New("cgroup.kill: the child is not empty")
353}
354func (p *stubbornProc) Exited() bool { return p.exited.Load() }
355func (p *stubbornProc) Crashed() bool { return false }
356
357// THE retain-until-proven rule (spec §7.4): a stop whose main process is
358// still alive after a failed kill keeps its lease. Only ErrStillRunning
359// kept it before; any other Stop error, with the leader alive, skipped the
360// proof (it ran only for an exited leader) and released the lease.
361func TestAStopKeepsTheLeaseWhileItsLeaderLivesOn(t *testing.T) {
362 x := awakeForBarrier(t, true)
363 p := &stubbornProc{}
364 x.a.mu.Lock()
365 x.a.entries[x.host].proc = p
366 x.a.mu.Unlock()
367 rep := x.b.lastRep()
368 x.a.Bounce(x.host)
369 waitFor(t, "the stop to pend", func() bool {
370 x.a.mu.Lock()
371 defer x.a.mu.Unlock()
372 e := x.a.entries[x.host]
373 return e != nil && e.pendingStop
374 })
375 // The leader is alive and the retry cadence has not come round (the
376 // clock is fake), so this sweep starts nothing: the stop stays pending.
377 x.a.sweepOnce()
378 x.a.mu.Lock()
379 e := x.a.entries[x.host]
380 idle := e != nil && e.pendingStop && !e.genReaping && !e.stopRetrying
381 x.a.mu.Unlock()
382 if !idle || rep.isClosed() {
383 t.Fatal("the lease was released while the leader still ran after a failed kill")
384 }
385 p.exited.Store(true)
386 x.a.sweepOnce()
387 waitFor(t, "the lease release", rep.isClosed)
388}
389
390// A stop keeps its lease while a stray sidecar of the host is tracked
391// (spec §7.4): its process may still run against the database. The lease
392// went with the stop before.
393func TestAStopKeepsTheLeaseWhileAStraySidecarRuns(t *testing.T) {
394 t.Parallel()
395 x := awakeForBarrier(t, true)
396 stray := &fakeProc{label: "sidecar", rec: x.b, stuck: true}
397 x.a.mu.Lock()
398 x.a.straySidecars = map[string][]*straySidecar{x.host: {{proc: stray, rt: testRoute(x.host)}}}
399 x.a.mu.Unlock()
400 rep := x.b.lastRep()
401 x.a.Bounce(x.host)
402 waitFor(t, "the stop to pend", func() bool {
403 x.a.mu.Lock()
404 defer x.a.mu.Unlock()
405 e := x.a.entries[x.host]
406 return e != nil && e.pendingStop
407 })
408 if rep.isClosed() {
409 t.Fatal("the lease was released while a stray sidecar ran")
410 }
411 x.a.mu.Lock()
412 delete(x.a.straySidecars, x.host) // proven gone (Task 7's reaper does this)
413 x.a.mu.Unlock()
414 x.a.sweepOnce()
415 waitFor(t, "the lease release", rep.isClosed)
416}
417
418// failingContainment is a claims-on child whose KillAndEmpty fails until
419// heal is called.
420type failingContainment struct {
421 mu sync.Mutex
422 bad bool
423}
424
425func (c *failingContainment) KillAndEmpty(context.Context) error {
426 c.mu.Lock()
427 defer c.mu.Unlock()
428 if c.bad {
429 return errors.New("cgroup.kill: the child is not empty")
430 }
431 return nil
432}
433func (c *failingContainment) Close() error { return nil }
434func (c *failingContainment) heal() {
435 c.mu.Lock()
436 c.bad = false
437 c.mu.Unlock()
438}
439
440// A claims-on generation is terminated only when its own Stop completed
441// (spec §7.4): KillAndEmpty on its child included. Here the leader has
442// exited but KillAndEmpty fails, so the member stays in the set, unproven,
443// until the child is empty. Treating any claimProcess as proven accepted
444// the leader's exit alone.
445func TestAClaimsOnMemberNeedsItsChildEmptied(t *testing.T) {
446 x := awakeForBarrier(t, true)
447 cmd := exec.Command("true")
448 if err := cmd.Run(); err != nil {
449 t.Skipf("no true(1): %v", err)
450 }
451 done := make(chan struct{})
452 close(done) // the leader has exited
453 cont := &failingContainment{bad: true}
454 p := &claimProcess{cmd: cmd, prepared: cont, done: done}
455 m := &genMember{gen: Gen{N: 11, Build: "v2"}, role: roleRetiring, proc: p, done: make(chan struct{})}
456 x.a.mu.Lock()
457 e := x.a.entries[x.host]
458 x.a.addMemberLocked(e, m)
459 x.a.mu.Unlock()
460 if x.a.dropMember(e, m) {
461 t.Fatal("a claims-on generation whose KillAndEmpty failed was taken as terminated")
462 }
463 cont.heal()
464 x.a.sweepOnce()
465 select {
466 case <-m.done:
467 case <-time.After(5 * time.Second):
468 t.Fatal("the member never left once its child was empty")
469 }
470}
471
472// The current generation is held to the same proof (spec §7.4): a terminal
473// stop whose main process has exited keeps the lease while that
474// generation's child is not proven empty, and finishes once it is. A stop
475// that accepts the leader's exit alone releases the lease over a surviving
476// descendant. This is the witness for mutation M8.
477func TestSwapWitnessStopProvesTheCurrentGenerationEmpty(t *testing.T) {
478 t.Parallel()
479 x := awakeForBarrier(t, true)
480 kb := &killGenBackend{fakeBackend: x.b, err: errors.New("the child still holds a process")}
481 x.a.backend = kb
482 x.a.mu.Lock()
483 x.a.entries[x.host].gen.N = 1 << 41 // the woken generation, as plan 1's wake records it
484 x.a.mu.Unlock()
485 rep := x.b.lastRep()
486 x.a.Bounce(x.host)
487 waitFor(t, "the stop to pend", func() bool {
488 x.a.mu.Lock()
489 defer x.a.mu.Unlock()
490 e := x.a.entries[x.host]
491 return e != nil && e.pendingStop
492 })
493 if !x.b.lastProc().isStopped() {
494 t.Fatal("the graceful stop never ran")
495 }
496 kills := len(kb.killed())
497 x.a.sweepOnce() // retries the proof, which still fails
498 waitFor(t, "the retried proof to finish", func() bool {
499 x.a.mu.Lock()
500 defer x.a.mu.Unlock()
501 e := x.a.entries[x.host]
502 return len(kb.killed()) > kills && e != nil && !e.genReaping
503 })
504 if rep.isClosed() {
505 t.Fatal("the lease was released while the current generation's child still held a process")
506 }
507 kb.setErr(nil)
508 x.a.sweepOnce()
509 waitFor(t, "the lease release", rep.isClosed)
510 if k := kb.killed(); len(k) == 0 || k[len(k)-1] != 1<<41 {
511 t.Fatalf("KillGen calls = %v, want the current generation's", k)
512 }
513}
514
515// With swaps on, Shutdown covers a waking entry (spec §7.4): it cancels the
516// wake and waits for it. Its child is stopped and its lease given back
517// before Shutdown returns.
518func TestSwapWitnessShutdownJoinsAWakingEntry(t *testing.T) {
519 t.Parallel()
520 a, b, _, _ := testActivator(t, time.Hour)
521 a.gens = true
522 gate := make(chan struct{})
523 b.startGate = gate
524 host := "waking.example.com"
525 go func() {
526 if release, err := a.beginRequest(context.Background(), testRoute(host)); err == nil {
527 release()
528 }
529 }()
530 waitFor(t, "the wake to reach Start", func() bool { return b.startCount() == 1 })
531 done := make(chan struct{})
532 go func() { a.Shutdown(context.Background()); close(done) }()
533 select {
534 case <-done:
535 t.Fatal("Shutdown returned while a wake was still spawning")
536 case <-time.After(100 * time.Millisecond):
537 }
538 close(gate)
539 select {
540 case <-done:
541 case <-time.After(5 * time.Second):
542 t.Fatal("Shutdown never returned")
543 }
544 if !b.lastProc().isStopped() {
545 t.Fatal("the woken child outlived Shutdown")
546 }
547 if got := b.releaseCount(); got != 1 {
548 t.Fatalf("lease releases = %d, want 1", got)
549 }
550 // Shutdown cancelled the wake's context. The release must not run under
551 // it: a real release is an S3 DeleteObject that would fail at once and
552 // leave the lease held for its whole TTL.
553 for _, err := range b.releaseCtxErrors() {
554 if err != nil {
555 t.Fatalf("the lease was released under a dead context (%v)", err)
556 }
557 }
558}
559
560// Every doWake bail-out gives the lease back under a live context, even
561// after the wake's own context is gone (a fence, a Shutdown). Here a fence
562// lands while Start is held, so the wake's context is cancelled before it
563// releases. Flag off: the fix is not gated on generations.
564func TestAFencedWakeReleasesUnderALiveContext(t *testing.T) {
565 t.Parallel()
566 a, b, _, _ := testActivator(t, time.Hour)
567 gate := make(chan struct{})
568 b.startGate = gate
569 host := "fenced-wake.example.com"
570 failed := make(chan error, 1)
571 go func() {
572 release, err := a.beginRequest(context.Background(), testRoute(host))
573 if err == nil {
574 release()
575 }
576 failed <- err
577 }()
578 waitFor(t, "the wake to reach Start", func() bool { return b.startCount() == 1 })
579 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
580 defer cancel()
581 fenceDone := make(chan struct{})
582 go func() {
583 a.FenceAndStop(ctx, host, 1) //nolint:errcheck
584 close(fenceDone)
585 }()
586 waitFor(t, "the fence", func() bool { return a.Fenced(host) })
587 close(gate)
588 if err := <-failed; err == nil {
589 t.Fatal("a fenced wake succeeded")
590 }
591 <-fenceDone
592 if got := b.releaseCount(); got != 1 {
593 t.Fatalf("lease releases = %d, want 1", got)
594 }
595 for _, err := range b.releaseCtxErrors() {
596 if err != nil {
597 t.Fatalf("the lease was released under a dead context (%v)", err)
598 }
599 }
600}
601
602// Flag off: Shutdown joins a waking entry exactly as it does with swaps on
603// (#292). It used to leave one to resolve on its own, and the agent then
604// exited with the wake still holding its lease. Here the start ignores its
605// cancelled context, so Shutdown must wait it out, stop what it woke and
606// give the lease back under a live context.
607func TestShutdownJoinsAWakingEntryWithSwapOff(t *testing.T) {
608 t.Parallel()
609 a, b, _, _ := testActivator(t, time.Hour)
610 gate := make(chan struct{})
611 b.startGate = gate
612 host := "waking.example.com"
613 go func() {
614 if release, err := a.beginRequest(context.Background(), testRoute(host)); err == nil {
615 release()
616 }
617 }()
618 waitFor(t, "the wake to reach Start", func() bool { return b.startCount() == 1 })
619 done := make(chan struct{})
620 go func() { a.Shutdown(context.Background()); close(done) }()
621 select {
622 case <-done:
623 t.Fatal("Shutdown returned while a wake was still spawning, with swaps off")
624 case <-time.After(100 * time.Millisecond):
625 }
626 close(gate)
627 select {
628 case <-done:
629 case <-time.After(5 * time.Second):
630 t.Fatal("Shutdown never returned")
631 }
632 if !b.lastProc().isStopped() {
633 t.Fatal("the woken child outlived Shutdown")
634 }
635 if got := b.releaseCount(); got != 1 {
636 t.Fatalf("lease releases = %d, want 1", got)
637 }
638 for _, err := range b.releaseCtxErrors() {
639 if err != nil {
640 t.Fatalf("the lease was released under a dead context (%v)", err)
641 }
642 }
643 if n := a.entryCount(); n != 0 {
644 t.Fatalf("Shutdown left %d entr(ies) behind", n)
645 }
646}
647
648// execGenBackend wakes through a real ExecBackend's StartGen (a placed flat
649// spawn under GenContainment, in a delegated cgroup tree of plain files)
650// while the fake keeps the lease, restore and replica. Both kills, the
651// spawn path's own (ExecBackend.killGen) and the reaper's (KillGen), answer
652// the embedded killGenBackend's err.
653type execGenBackend struct {
654 *killGenBackend
655 x *ExecBackend
656 gmu sync.Mutex
657 gens []uint64
658}
659
660func (g *execGenBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, perGen bool) (Proc, Gen, error) {
661 g.gmu.Lock()
662 g.gens = append(g.gens, n)
663 g.gmu.Unlock()
664 return g.x.StartGen(ctx, rt, n, perGen)
665}
666
667func (g *execGenBackend) NextDueVia(ctx context.Context, rt registry.Route, _ string) (int64, error) {
668 return g.fakeBackend.NextDue(ctx, rt)
669}
670
671func (g *execGenBackend) firstGen() uint64 {
672 g.gmu.Lock()
673 defer g.gmu.Unlock()
674 return g.gens[0]
675}
676
677// A placed flat generation whose health wait fails is stopped by its
678// leader only, and a process it forked can live on in g<n> (spec §7.3).
679// startChildAt kills g<n> after the leader's stop. When that kill fails,
680// the process goes back with an error wrapping errGenUnproven and the wake
681// keeps its lease until the sweep proves the child empty. When it
682// succeeds, nothing is left and the lease goes back at once. With
683// containment off nothing is placed and startChildAt makes no kill. Its
684// handle now comes back with the health error (Task 15 fix round 1, ruling
685// a), so the failed-wake cleanup's proof asks KillGen once, which answers
686// errNoGenContainment as ExecBackend's does with containment off; this
687// backend has no host kill, so the lease goes back.
688func TestAPlacedSpawnWithAFailedHealthWaitProvesItsChild(t *testing.T) {
689 old := spawnHealthTimeout
690 spawnHealthTimeout = 300 * time.Millisecond
691 t.Cleanup(func() { spawnHealthTimeout = old })
692 rows := []struct {
693 name string
694 containment bool
695 killErr error
696 wantKept bool
697 }{
698 {"placed, kill fails", true, errors.New("g still holds a process"), true},
699 {"placed, kill proves the child empty", true, nil, false},
700 {"containment off", false, errNoGenContainment, false},
701 }
702 for _, row := range rows {
703 t.Run(row.name, func(t *testing.T) {
704 host := "placed.example.com"
705 x, _ := delegatedBackend(t, host)
706 cgroup.FakePlacementForTest(t) // a temp dir is no cgroupfs, and a generation is never moved by PID
707 x.GenContainment = row.containment
708 x.RunDirOverride = shortRunDir(t)
709 stageLive(t, x, host, "v1") // exits at once without binding: the health wait fails
710 a, b, _, _ := testActivator(t, time.Hour)
711 a.gens = true
712 kb := &killGenBackend{fakeBackend: b, err: row.killErr}
713 gb := &execGenBackend{killGenBackend: kb, x: x}
714 x.killGen = kb.KillGen
715 a.backend = gb
716 rt := flatGenRoute(x, host)
717
718 _, err := a.beginRequest(context.Background(), rt)
719 if err == nil {
720 t.Fatal("a wake whose health wait failed succeeded")
721 }
722 n := gb.firstGen()
723 a.mu.Lock()
724 _, kept := a.unprovenWakes[host]
725 a.mu.Unlock()
726 if kept != row.wantKept {
727 t.Fatalf("retained = %v, want %v (err %v)", kept, row.wantKept, err)
728 }
729 if !row.containment {
730 if k := kb.killed(); !slices.Equal(k, []uint64{n}) {
731 t.Fatalf("KillGen calls = %v with containment off, want only the cleanup's proof of generation %d", k, n)
732 }
733 if got := b.releaseCount(); got != 1 {
734 t.Fatalf("lease releases = %d, want 1", got)
735 }
736 return
737 }
738 if k := kb.killed(); len(k) == 0 || k[0] != n {
739 t.Fatalf("KillGen calls = %v, want generation %d's first", k, n)
740 }
741 if !row.wantKept {
742 if got := b.releaseCount(); got != 1 {
743 t.Fatalf("lease releases = %d after a proven kill, want 1", got)
744 }
745 return
746 }
747 if got := b.releaseCount(); got != 0 {
748 t.Fatalf("lease releases = %d while g%d is unproven, want 0", got, n)
749 }
750 kb.setErr(nil)
751 a.sweepOnce()
752 waitFor(t, "the lease release", func() bool { return b.releaseCount() == 1 })
753 })
754 }
755}
756
757// unprovenStartBackend is a GenBackend whose next StartGen fails the way a
758// generation start fails when its kill cannot be proven (plan 2,
759// startUnitAt): fail, while set, answers that one call. Every later call
760// spawns through the fake's Start. KillGen comes from killGenBackend.
761type unprovenStartBackend struct {
762 *killGenBackend
763 smu sync.Mutex
764 fail func(n uint64) (Proc, error)
765 gens []uint64 // every generation number StartGen was handed
766}
767
768func (u *unprovenStartBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, _ bool) (Proc, Gen, error) {
769 u.smu.Lock()
770 fail := u.fail
771 u.fail = nil
772 u.gens = append(u.gens, n)
773 u.smu.Unlock()
774 if fail != nil {
775 p, err := fail(n)
776 return p, Gen{N: n}, err
777 }
778 p, ver, err := u.fakeBackend.Start(ctx, rt)
779 return p, canonicalGen(rt, n, ver), err
780}
781
782func (u *unprovenStartBackend) NextDueVia(ctx context.Context, rt registry.Route, _ string) (int64, error) {
783 return u.fakeBackend.NextDue(ctx, rt)
784}
785
786func (u *unprovenStartBackend) firstGen() uint64 {
787 u.smu.Lock()
788 defer u.smu.Unlock()
789 return u.gens[0]
790}
791
792var (
793 _ GenBackend = (*unprovenStartBackend)(nil)
794 _ genKiller = (*unprovenStartBackend)(nil)
795)
796
797// P2-13 (plan 2's parked L1): a generation start that failed with its kill
798// unproven may still be running. The wake keeps the lease (spec §7.4,
799// "unproven, the lease is kept"), refuses the host's next wake, and gives
800// the lease back only once the sweep proves the generation's child empty
801// through KillGen. Before, a start that returned no process released the
802// lease at once, and one whose Stop failed released it anyway.
803//
804// The sentinel rows hold with generations off too: errGenUnproven and
805// instancectl.ErrGenUnproven exist only under plan 2's GenContainment,
806// which can be on while swaps are off. This is the witness for M9.
807func TestSwapWitnessUnprovenWakeKeepsTheLease(t *testing.T) {
808 t.Parallel()
809 lost := errors.New("the unit exited before it was observed")
810 rows := []struct {
811 name string
812 gens bool
813 leader *stubbornProc // the row's process, alive until told to exit; nil: none
814 err error
815 }{
816 {"errGenUnproven with no process", true, nil,
817 fmt.Errorf("start unit: %w; %w: kill timed out", lost, errGenUnproven)},
818 {"instancectl.ErrGenUnproven with no process", true, nil,
819 fmt.Errorf("h: start unit: %w", fmt.Errorf("%w: %w", instancectl.ErrGenUnproven, lost))},
820 {"a failed stop while the leader lives", true, &stubbornProc{},
821 errors.New("health: no answer within 30s")},
822 {"generations off, sentinel present", false, nil,
823 fmt.Errorf("start unit: %w; %w: kill timed out", lost, errGenUnproven)},
824 }
825 for _, row := range rows {
826 t.Run(row.name, func(t *testing.T) {
827 a, b, _, _ := testActivator(t, time.Hour)
828 a.gens = row.gens
829 kb := &killGenBackend{fakeBackend: b, err: errors.New("the generation's child still holds a process")}
830 ub := &unprovenStartBackend{killGenBackend: kb, fail: func(uint64) (Proc, error) {
831 if row.leader != nil {
832 return row.leader, row.err
833 }
834 return nil, row.err
835 }}
836 a.backend = ub
837 host := "unproven.example.com"
838 rt := testRoute(host)
839 ctx := context.Background()
840 retained := func() *unprovenWake {
841 a.mu.Lock()
842 defer a.mu.Unlock()
843 return a.unprovenWakes[host]
844 }
845 settled := func() bool {
846 a.mu.Lock()
847 defer a.mu.Unlock()
848 u := a.unprovenWakes[host]
849 return u == nil || !u.busy
850 }
851
852 if _, err := a.beginRequest(ctx, rt); err == nil {
853 t.Fatal("the wake should have failed")
854 }
855 if got := b.releaseCount(); got != 0 {
856 t.Fatalf("lease releases = %d after a failed wake whose generation is unproven, want 0", got)
857 }
858 if retained() == nil {
859 t.Fatal("the failed wake was not retained")
860 }
861 n := ub.firstGen()
862
863 // The host's next wake is refused before it takes a lease.
864 _, err := a.beginRequest(ctx, rt)
865 if !errors.Is(err, errWakeUnproven) {
866 t.Fatalf("second wake: err = %v, want errWakeUnproven", err)
867 }
868 if acq, _, _ := b.counts(); acq != 1 {
869 t.Fatalf("Acquire calls = %d, want 1 (the refusal comes before the lease)", acq)
870 }
871
872 // A sweep whose proof still fails keeps the lease.
873 a.sweepOnce()
874 waitFor(t, "the retained wake's retry to finish", settled)
875 if got := b.releaseCount(); got != 0 || retained() == nil {
876 t.Fatalf("lease releases = %d after a failed proof, want 0 and the wake retained", got)
877 }
878 if row.leader != nil {
879 if got := row.leader.stops.Load(); got < 2 {
880 t.Fatalf("leader Stop calls = %d, want the sweep to have stopped it again", got)
881 }
882 // The leader exits, but its generation's child is still not
883 // proven empty: the lease stays.
884 row.leader.exited.Store(true)
885 a.sweepOnce()
886 waitFor(t, "the retained wake's retry to finish", settled)
887 if got := b.releaseCount(); got != 0 || retained() == nil {
888 t.Fatalf("lease releases = %d with the leader gone but the child unproven, want 0", got)
889 }
890 }
891
892 kb.setErr(nil)
893 a.sweepOnce()
894 waitFor(t, "the lease release", func() bool { return b.releaseCount() == 1 })
895 waitFor(t, "the retained wake dropped", func() bool { return retained() == nil })
896 if k := kb.killed(); !slices.Contains(k, n) {
897 t.Fatalf("KillGen calls = %v, want generation %d's", k, n)
898 }
899
900 release, err := a.beginRequest(ctx, rt)
901 if err != nil {
902 t.Fatalf("third wake: %v", err)
903 }
904 release()
905 if got := b.startCount(); got != 1 {
906 t.Fatalf("Start calls = %d, want 1 (the third wake)", got)
907 }
908 a.Bounce(host)
909 waitFor(t, "cleanup", func() bool { return a.entryCount() == 0 })
910 })
911 }
912}
913
914// syncLog is a log destination that goroutines may write concurrently (the
915// reapers log off the test's goroutine).
916type syncLog struct {
917 mu sync.Mutex
918 b strings.Builder
919}
920
921func (s *syncLog) Write(p []byte) (int, error) {
922 s.mu.Lock()
923 defer s.mu.Unlock()
924 return s.b.Write(p)
925}
926
927func (s *syncLog) String() string {
928 s.mu.Lock()
929 defer s.mu.Unlock()
930 return s.b.String()
931}
932
933// Shutdown covers a retained failed wake (spec §7.4, I2: nothing runs after
934// Shutdown returns). No entry waits for one, so Shutdown polls its reaper
935// until the generation is proven stopped and the lease given back, or the
936// budget runs out, which it logs with the lease still held. Before, Shutdown
937// saw no entries and returned at once, leaving the generation running and
938// its lease held until the TTL. The retention has no generations gate (the
939// sentinel exists only under GenContainment), so neither has the wait.
940func TestSwapWitnessShutdownWaitsForAnUnprovenWake(t *testing.T) {
941 for _, row := range []struct {
942 name string
943 gens bool
944 budget time.Duration
945 heal bool
946 }{
947 {"healed within the budget", true, 10 * time.Second, true},
948 {"healed within the budget, generations off", false, 10 * time.Second, true},
949 {"the budget runs out", true, 300 * time.Millisecond, false},
950 } {
951 t.Run(row.name, func(t *testing.T) {
952 a, b, _, _ := testActivator(t, time.Hour)
953 a.gens = row.gens
954 kb := &killGenBackend{fakeBackend: b, err: errors.New("the generation's child still holds a process")}
955 a.backend = &unprovenStartBackend{killGenBackend: kb, fail: func(uint64) (Proc, error) {
956 return nil, fmt.Errorf("start unit: health wait failed; %w: kill timed out", errGenUnproven)
957 }}
958 host := "unproven.example.com"
959 retained := func() bool {
960 a.mu.Lock()
961 defer a.mu.Unlock()
962 return a.unprovenWakes[host] != nil
963 }
964 if _, err := a.beginRequest(context.Background(), testRoute(host)); err == nil {
965 t.Fatal("the wake should have failed")
966 }
967 if !retained() || b.releaseCount() != 0 {
968 t.Fatalf("retained %v, lease releases %d: want the failed wake retained with its lease", retained(), b.releaseCount())
969 }
970 logs := &syncLog{}
971 if !row.heal {
972 flags := log.Flags()
973 log.SetOutput(logs)
974 t.Cleanup(func() { log.SetOutput(os.Stderr); log.SetFlags(flags) })
975 }
976 kills := len(kb.killed())
977
978 shut := make(chan struct{})
979 go func() {
980 ctx, cancel := context.WithTimeout(context.Background(), row.budget)
981 defer cancel()
982 a.Shutdown(ctx)
983 close(shut)
984 }()
985 returned := func() bool {
986 select {
987 case <-shut:
988 return true
989 default:
990 return false
991 }
992 }
993 // Two more failed proofs since Shutdown began: it is still
994 // polling the reaper, so it has not returned while the wake is
995 // retained.
996 waitFor(t, "two failed proofs during Shutdown, or its return", func() bool {
997 return len(kb.killed()) >= kills+2 || returned()
998 })
999 if returned() {
1000 t.Fatalf("Shutdown returned while the failed wake was retained (KillGen calls during it: %d)", len(kb.killed())-kills)
1001 }
1002
1003 if row.heal {
1004 kb.setErr(nil)
1005 }
1006 select {
1007 case <-shut:
1008 case <-time.After(5 * time.Second):
1009 t.Fatal("Shutdown did not return")
1010 }
1011 if row.heal {
1012 if retained() || b.releaseCount() != 1 {
1013 t.Fatalf("after Shutdown: retained %v, lease releases %d; want the wake proven and its lease given back once", retained(), b.releaseCount())
1014 }
1015 return
1016 }
1017 if !retained() || b.releaseCount() != 0 {
1018 t.Fatalf("after the budget ran out: retained %v, lease releases %d; want the lease kept", retained(), b.releaseCount())
1019 }
1020 if got := logs.String(); !strings.Contains(got, host) || !strings.Contains(got, "shutdown") || !strings.Contains(got, "not proven stopped") {
1021 t.Fatalf("Shutdown's log does not name the retained wake:\n%s", got)
1022 }
1023 })
1024 }
1025}
1026
1027// sigtermIgnorer is a leader that does not stop when asked: Stop answers
1028// ErrStillRunning, as unitProc does when SIGKILL could not prove the unit
1029// gone, until the process exits on its own (exit).
1030type sigtermIgnorer struct {
1031 exited atomic.Bool
1032 stops atomic.Int32
1033}
1034
1035func (p *sigtermIgnorer) Stop(context.Context) error {
1036 p.stops.Add(1)
1037 if p.exited.Load() {
1038 return nil
1039 }
1040 return ErrStillRunning
1041}
1042func (p *sigtermIgnorer) Exited() bool { return p.exited.Load() }
1043
1044// fencedStartBackend is a GenBackend whose StartGen has spawned p but
1045// returns it only once gate opens: a fence lands between the spawn and the
1046// wake's post-spawn check. No KillGen: the leader's exit is the proof.
1047type fencedStartBackend struct {
1048 *fakeBackend
1049 p *sigtermIgnorer
1050 entered chan struct{}
1051 gate chan struct{}
1052}
1053
1054func (s *fencedStartBackend) StartGen(_ context.Context, rt registry.Route, n uint64, _ bool) (Proc, Gen, error) {
1055 close(s.entered)
1056 <-s.gate
1057 return s.p, canonicalGen(rt, n, "v1"), nil
1058}
1059
1060func (s *fencedStartBackend) NextDueVia(context.Context, registry.Route, string) (int64, error) {
1061 return 0, nil
1062}
1063
1064var _ GenBackend = (*fencedStartBackend)(nil)
1065
1066// A wake fenced after its spawn (the post-spawn ctx/wakeAllowed check) stops
1067// its process. If that process does not stop, the lease is kept until it is
1068// proven gone, with generations off too: a fail-closed dormancy exception
1069// (plan 3 task 2 fix), because releasing it would let another box take the
1070// host while this process still writes. Before, with generations off,
1071// cleanupFailedWake released the lease over the live process, as main does.
1072// The wake is retained (unprovenWakes): the host's next wake is refused,
1073// FenceAndStop reports the stop unproven, and the sweep gives the lease back
1074// once the leader has exited.
1075func TestSwapWitnessAFencedWakeKeepsItsLeaseWhileItsProcessRuns(t *testing.T) {
1076 t.Parallel()
1077 for _, gens := range []bool{false, true} {
1078 t.Run(fmt.Sprintf("generations %v", gens), func(t *testing.T) {
1079 a, b, _, _ := testActivator(t, time.Hour)
1080 a.gens = gens
1081 p := &sigtermIgnorer{}
1082 s := &fencedStartBackend{fakeBackend: b, p: p, entered: make(chan struct{}), gate: make(chan struct{})}
1083 a.backend = s
1084 host := "fenced.example.com"
1085 woke := make(chan error, 1)
1086 go func() {
1087 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1088 defer cancel()
1089 _, err := a.beginRequest(ctx, testRoute(host))
1090 woke <- err
1091 }()
1092 <-s.entered
1093 fenced := make(chan error, 1)
1094 go func() {
1095 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
1096 defer cancel()
1097 _, err := a.FenceAndStop(ctx, host, 1)
1098 fenced <- err
1099 }()
1100 waitFor(t, "the fence", func() bool { return !a.wakeAllowed(host) })
1101 close(s.gate)
1102 if err := <-woke; err == nil {
1103 t.Fatal("the fenced wake should have failed")
1104 }
1105 if p.stops.Load() == 0 {
1106 t.Fatal("the fenced wake did not stop its process")
1107 }
1108 if got := b.releaseCount(); got != 0 {
1109 t.Fatalf("lease releases = %d while the fenced wake's process runs, want 0", got)
1110 }
1111 retained := func() bool {
1112 a.mu.Lock()
1113 defer a.mu.Unlock()
1114 return a.unprovenWakes[host] != nil
1115 }
1116 settled := func() bool {
1117 a.mu.Lock()
1118 defer a.mu.Unlock()
1119 u := a.unprovenWakes[host]
1120 return u == nil || !u.busy
1121 }
1122 if !retained() {
1123 t.Fatal("the fenced wake was not retained")
1124 }
1125 if err := <-fenced; !errors.Is(err, ErrStopUnproven) {
1126 t.Fatalf("FenceAndStop = %v, want ErrStopUnproven while the process runs", err)
1127 }
1128
1129 // A sweep while it still runs keeps the lease.
1130 a.sweepOnce()
1131 waitFor(t, "the retained wake's retry", settled)
1132 if got := b.releaseCount(); got != 0 || !retained() {
1133 t.Fatalf("lease releases = %d after a sweep with the process alive, want 0 and the wake retained", got)
1134 }
1135
1136 // It exits; the next sweep proves it and gives the lease back.
1137 p.exited.Store(true)
1138 a.sweepOnce()
1139 waitFor(t, "the lease release", func() bool { return b.releaseCount() == 1 })
1140 waitFor(t, "the retained wake dropped", func() bool { return !retained() })
1141 a.LiftFence(host, 1)
1142 })
1143 }
1144}
1145
1146// uncontainedStartBackend is the flag-off exec backend's shape for a
1147// retained failed wake (Task 15, Astra IMPL-3 F6): KillGen answers
1148// errNoGenContainment, as ExecBackend's does with GenContainment off, and
1149// KillHost proves carlos-exec@<host> empty, failing while populated.
1150type uncontainedStartBackend struct {
1151 *unprovenStartBackend
1152 hmu sync.Mutex
1153 populated bool
1154 err error // when set, every KillHost answers it
1155 hostKills int
1156}
1157
1158func (h *uncontainedStartBackend) KillHost(context.Context, registry.Route) error {
1159 h.hmu.Lock()
1160 defer h.hmu.Unlock()
1161 h.hostKills++
1162 if h.err != nil {
1163 return h.err
1164 }
1165 if h.populated {
1166 return errors.New("carlos-exec@<host> still holds a process")
1167 }
1168 return nil
1169}
1170
1171func (h *uncontainedStartBackend) setPopulated(on bool) {
1172 h.hmu.Lock()
1173 h.populated = on
1174 h.hmu.Unlock()
1175}
1176
1177func (h *uncontainedStartBackend) hostKillCount() int {
1178 h.hmu.Lock()
1179 defer h.hmu.Unlock()
1180 return h.hostKills
1181}
1182
1183var _ hostKiller = (*uncontainedStartBackend)(nil)
1184
1185// Task 15, Astra IMPL-3 F6: with the flag off a retained failed wake was
1186// proven stopped by its leader's exit alone. KillGen answers
1187// errNoGenContainment (there is no g<n>), and genChildEmpty took that as
1188// proof, so the lease went back while a process the leader forked could
1189// still run in carlos-exec@<host>. The proof now also asks KillHost to
1190// empty the host cgroup: while it cannot, the wake stays retained with its
1191// lease, and once it can the lease goes back.
1192func TestARetainedFlagOffWakeProvesItsHostCgroupEmpty(t *testing.T) {
1193 t.Parallel()
1194 a, b, _, _ := testActivator(t, time.Hour)
1195 a.gens = false
1196 leader := &sigtermIgnorer{}
1197 kb := &killGenBackend{fakeBackend: b, err: errNoGenContainment}
1198 ub := &unprovenStartBackend{killGenBackend: kb, fail: func(uint64) (Proc, error) {
1199 return leader, errors.New("health: no answer within 30s")
1200 }}
1201 hb := &uncontainedStartBackend{unprovenStartBackend: ub, populated: true}
1202 a.backend = hb
1203 host := "forked.example.com"
1204 rt := testRoute(host)
1205 retained := func() bool {
1206 a.mu.Lock()
1207 defer a.mu.Unlock()
1208 return a.unprovenWakes[host] != nil
1209 }
1210 settled := func() bool {
1211 a.mu.Lock()
1212 defer a.mu.Unlock()
1213 u := a.unprovenWakes[host]
1214 return u == nil || !u.busy
1215 }
1216 if _, err := a.beginRequest(context.Background(), rt); err == nil {
1217 t.Fatal("the wake should have failed")
1218 }
1219 if !retained() || b.releaseCount() != 0 {
1220 t.Fatalf("retained %v, lease releases %d: want the wake whose leader lives retained with its lease", retained(), b.releaseCount())
1221 }
1222
1223 // The leader exits, but a descendant it forked is still in the host
1224 // cgroup: the lease stays and the wake stays retained.
1225 leader.exited.Store(true)
1226 a.sweepOnce()
1227 waitFor(t, "the retained wake's retry", settled)
1228 if got := b.releaseCount(); got != 0 || !retained() {
1229 t.Fatalf("lease releases = %d with the leader gone and carlos-exec@<host> populated, want 0 and the wake retained", got)
1230 }
1231 if hb.hostKillCount() == 0 {
1232 t.Fatal("the proof never asked KillHost about the host cgroup")
1233 }
1234 if k := kb.killed(); !slices.Contains(k, ub.firstGen()) {
1235 t.Fatalf("KillGen calls = %v, want generation %d's (answered errNoGenContainment)", k, ub.firstGen())
1236 }
1237 if _, err := a.beginRequest(context.Background(), rt); !errors.Is(err, errWakeUnproven) {
1238 t.Fatalf("second wake: err = %v, want errWakeUnproven while the host cgroup is unproven", err)
1239 }
1240
1241 // The host cgroup empties: the next sweep proves the wake stopped.
1242 hb.setPopulated(false)
1243 a.sweepOnce()
1244 waitFor(t, "the lease release", func() bool { return b.releaseCount() == 1 })
1245 waitFor(t, "the retained wake dropped", func() bool { return !retained() })
1246}
1247
1248// The same proof for a flag-off worker's failed wake: it is never numbered
1249// with generations off (Gen{}), so genChildEmpty's "no number, nothing to
1250// prove" answered for it. A worker runs in carlos-exec@<host> as well, so
1251// its leader's exit is not enough either. A backend with no host kill and
1252// a contained generation are as before, and so is a box with no delegated
1253// cgroup, where the wake ran unaccounted and nothing could be proven.
1254func TestFailedWakeStoppedProvesTheHostCgroupWithoutContainment(t *testing.T) {
1255 t.Parallel()
1256 exited := &sigtermIgnorer{}
1257 exited.exited.Store(true)
1258 for _, row := range []struct {
1259 name string
1260 gen Gen
1261 killGen error // nil: a contained generation whose child KillGen proved empty
1262 hostKill bool // the backend has KillHost
1263 populated bool
1264 hostErr error // what KillHost answers, when set
1265 want bool
1266 wantHost bool // KillHost asked
1267 }{
1268 {"uncontained instance, host populated", Gen{N: 4}, errNoGenContainment, true, true, nil, false, true},
1269 {"uncontained instance, host empty", Gen{N: 4}, errNoGenContainment, true, false, nil, true, true},
1270 {"flag-off worker, host populated", Gen{}, errNoGenContainment, true, true, nil, false, true},
1271 {"flag-off worker, host empty", Gen{}, errNoGenContainment, true, false, nil, true, true},
1272 {"contained generation", Gen{N: 4}, nil, true, true, nil, true, false},
1273 {"no host kill", Gen{N: 4}, errNoGenContainment, false, false, nil, true, false},
1274 {"no delegated cgroup", Gen{N: 4}, errNoGenContainment, true, false, cgroup.ErrNoDelegation, true, true},
1275 } {
1276 t.Run(row.name, func(t *testing.T) {
1277 a, b, _, _ := testActivator(t, time.Hour)
1278 kb := &killGenBackend{fakeBackend: b, err: row.killGen}
1279 ub := &unprovenStartBackend{killGenBackend: kb}
1280 hb := &uncontainedStartBackend{unprovenStartBackend: ub, populated: row.populated, err: row.hostErr}
1281 if row.hostKill {
1282 a.backend = hb
1283 } else {
1284 a.backend = ub
1285 }
1286 if got := a.failedWakeStopped(testRoute("f6.example.com"), row.gen, exited, ""); got != row.want {
1287 t.Fatalf("failedWakeStopped = %v, want %v", got, row.want)
1288 }
1289 if asked := hb.hostKillCount() > 0; asked != row.wantHost {
1290 t.Fatalf("KillHost asked = %v, want %v", asked, row.wantHost)
1291 }
1292 })
1293 }
1294}
1295
1296// realFlatBackend is fakeBackend's lease, restore and replica with a real
1297// flag-off ExecBackend's spawn (StartGen, startChildAt and its health
1298// wait), its KillGen (errNoGenContainment) and its layout. Only KillHost
1299// is a fake, since the test box has no delegated cgroup v2: it fails while
1300// populated, as a host cgroup that still holds a process does.
1301type realFlatBackend struct {
1302 *fakeBackend
1303 exec *ExecBackend
1304 hmu sync.Mutex
1305 populated bool
1306 hostKills int
1307}
1308
1309func (r *realFlatBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, perGen bool) (Proc, Gen, error) {
1310 return r.exec.StartGen(ctx, rt, n, perGen)
1311}
1312
1313func (r *realFlatBackend) NextDueVia(ctx context.Context, rt registry.Route, _ string) (int64, error) {
1314 return r.fakeBackend.NextDue(ctx, rt)
1315}
1316
1317func (r *realFlatBackend) KillGen(ctx context.Context, rt registry.Route, n uint64) error {
1318 return r.exec.KillGen(ctx, rt, n)
1319}
1320
1321func (r *realFlatBackend) Layout(rt registry.Route) (Layout, string) { return r.exec.Layout(rt) }
1322
1323func (r *realFlatBackend) KillHost(context.Context, registry.Route) error {
1324 r.hmu.Lock()
1325 defer r.hmu.Unlock()
1326 r.hostKills++
1327 if r.populated {
1328 return errors.New("carlos-exec@<host> still holds a process")
1329 }
1330 return nil
1331}
1332
1333func (r *realFlatBackend) setPopulated(on bool) {
1334 r.hmu.Lock()
1335 r.populated = on
1336 r.hmu.Unlock()
1337}
1338
1339func (r *realFlatBackend) hostKillCount() int {
1340 r.hmu.Lock()
1341 defer r.hmu.Unlock()
1342 return r.hostKills
1343}
1344
1345var (
1346 _ GenBackend = (*realFlatBackend)(nil)
1347 _ genKiller = (*realFlatBackend)(nil)
1348 _ hostKiller = (*realFlatBackend)(nil)
1349 _ Classifier = (*realFlatBackend)(nil)
1350)
1351
1352// Task 15 fix round 1 (rulings a and b), F6 in the shape it really takes:
1353// a flat exec child whose health wait times out. startChildAt stopped the
1354// leader and returned no handle, so doWake gave the lease back at once,
1355// with nothing asked of carlos-exec@<host>, where whatever the app forked
1356// still ran. The handle now comes back with the health error, and the
1357// failed-wake cleanup proves the host cgroup empty before any release:
1358// populated, the wake is retained with its lease; emptied, the sweep gives
1359// the lease back.
1360func TestARealFlatHealthFailedWakeProvesItsHostCgroup(t *testing.T) {
1361 requireUnixSockets(t)
1362 old := spawnHealthTimeout
1363 spawnHealthTimeout = 500 * time.Millisecond
1364 t.Cleanup(func() { spawnHealthTimeout = old })
1365 a, b, _, _ := testActivator(t, time.Hour)
1366 a.gens = false
1367 exec := testExecBackend(t)
1368 host := "unhealthy-f6.example.com"
1369 stageServingLive(t, exec, host)
1370 t.Setenv("ACTIVATOR_FAKE_APP_UNHEALTHY", "1")
1371 rb := &realFlatBackend{fakeBackend: b, exec: exec, populated: true}
1372 a.backend = rb
1373 rt := registry.Route{Host: host, Kind: "instance", Socket: tmpSock(t), DBPath: filepath.Join(t.TempDir(), host+".db"), Hibernate: true}
1374 if l, _ := exec.Layout(rt); l != LayoutFlat && l != LayoutForeign {
1375 t.Fatalf("layout = %s, want an exec child's (flat or foreign)", l)
1376 }
1377 retained := func() bool {
1378 a.mu.Lock()
1379 defer a.mu.Unlock()
1380 return a.unprovenWakes[host] != nil
1381 }
1382 settled := func() bool {
1383 a.mu.Lock()
1384 defer a.mu.Unlock()
1385 u := a.unprovenWakes[host]
1386 return u == nil || !u.busy
1387 }
1388 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1389 defer cancel()
1390 if _, err := a.beginRequest(ctx, rt); err == nil {
1391 t.Fatal("a never-healthy app woke")
1392 }
1393 if got := b.releaseCount(); got != 0 || !retained() {
1394 t.Fatalf("lease releases = %d, retained %v after a health failure with carlos-exec@<host> populated; want 0 and retained", got, retained())
1395 }
1396 if rb.hostKillCount() == 0 {
1397 t.Fatal("the failed wake's cleanup never asked KillHost")
1398 }
1399 a.sweepOnce()
1400 waitFor(t, "the retained wake's retry", settled)
1401 if got := b.releaseCount(); got != 0 || !retained() {
1402 t.Fatalf("lease releases = %d after a sweep with the host cgroup still populated, want 0", got)
1403 }
1404 rb.setPopulated(false)
1405 a.sweepOnce()
1406 waitFor(t, "the lease release", func() bool { return b.releaseCount() == 1 })
1407 waitFor(t, "the retained wake dropped", func() bool { return !retained() })
1408}
1409
1410// Ruling b with childProc's semantics and no live leader: a flag-off failed
1411// wake whose leader stopped (Stop nil, Exited true, as childProc always
1412// answers) still proves the host cgroup before its lease goes. Before, the
1413// proof ran only for a leader that lived, so this released at once.
1414// Ruling c: a nested row and a unit-backed row have their own proof and are
1415// never KillHost'd; their failed wake releases as before.
1416func TestAFlagOffFailedWakeProvesTheHostCgroupOnlyForAnExecChild(t *testing.T) {
1417 t.Parallel()
1418 for _, row := range []struct {
1419 name string
1420 layout Layout
1421 backing string
1422 retained bool // populated host cgroup: the wake is kept
1423 killHost bool
1424 }{
1425 {"flat", LayoutFlat, "", true, true},
1426 {"foreign", LayoutForeign, "", true, true},
1427 {"nested", LayoutNested, "", false, false},
1428 {"unit-backed", LayoutFlat, registry.BackingUnit, false, false},
1429 } {
1430 t.Run(row.name, func(t *testing.T) {
1431 a, b, _, _ := testActivator(t, time.Hour)
1432 a.gens = false
1433 b.layoutFn = func(registry.Route) (Layout, string) { return row.layout, "" }
1434 leader := &sigtermIgnorer{}
1435 leader.exited.Store(true)
1436 kb := &killGenBackend{fakeBackend: b, err: errNoGenContainment}
1437 ub := &unprovenStartBackend{killGenBackend: kb, fail: func(uint64) (Proc, error) {
1438 return leader, errors.New("health: no answer within 30s")
1439 }}
1440 hb := &uncontainedStartBackend{unprovenStartBackend: ub, populated: true}
1441 a.backend = hb
1442 host := "exited-" + row.name + ".example.com"
1443 rt := testRoute(host)
1444 rt.Backing = row.backing
1445 if _, err := a.beginRequest(context.Background(), rt); err == nil {
1446 t.Fatal("the wake should have failed")
1447 }
1448 a.mu.Lock()
1449 _, retained := a.unprovenWakes[host]
1450 a.mu.Unlock()
1451 if retained != row.retained {
1452 t.Fatalf("retained = %v, want %v", retained, row.retained)
1453 }
1454 wantReleases := 1
1455 if row.retained {
1456 wantReleases = 0
1457 }
1458 if got := b.releaseCount(); got != wantReleases {
1459 t.Fatalf("lease releases = %d, want %d", got, wantReleases)
1460 }
1461 if asked := hb.hostKillCount() > 0; asked != row.killHost {
1462 t.Fatalf("KillHost asked = %v, want %v", asked, row.killHost)
1463 }
1464 })
1465 }
1466}
1467