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 "os"
7 "path/filepath"
8 "slices"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/cgroup"
15 "github.com/carlosframework/platform/internal/provision"
16 "github.com/carlosframework/platform/internal/registry"
17)
18
19// sidecarStartDoneOf is the host's supervised-start join channel, nil when
20// none is in flight.
21func (a *Service) sidecarStartDoneOf(host string) chan struct{} {
22 a.mu.Lock()
23 defer a.mu.Unlock()
24 if e := a.entries[host]; e != nil {
25 return e.sidecarStartDone
26 }
27 return nil
28}
29
30// sidecarProc is the i'th sidecar the fake backend started.
31func (g *fakeSwapBackend) sidecarProc(i int) *fakeProc {
32 g.mu.Lock()
33 defer g.mu.Unlock()
34 if i >= len(g.sidecars) {
35 return nil
36 }
37 return g.sidecars[i]
38}
39
40// sidecarStoppingIn reports whether the i'th sidecar's Stop has been
41// entered: with a stop gate on it, that Stop is in flight and blocked.
42func (g *fakeSwapBackend) sidecarStoppingIn(i int) bool {
43 p := g.sidecarProc(i)
44 return p != nil && p.stopEntered() > 0
45}
46
47// generationSwitched is true once the entry's current generation is not the
48// fixture's incumbent.
49func (f *swapFixture) generationSwitched() bool {
50 g, _ := f.a.genStateForTest(f.host)
51 return g.N != f.incumbent.N
52}
53
54// Flag off, on a backend that cannot pin an executable (the fixture never
55// opts into the identity capability, as a unit row's backend has none): a
56// generation with no staged file starts its legacy sidecar (the wake tail
57// and the supervisor) through StartSidecar and the live link, as before.
58// A generation that names its file runs it whatever the flag (ruling
59// R-EXEC-SIDECAR; TestLegacySidecarOwnershipCheckRunsWithSwapOff).
60func TestLegacySidecarUsesTheLiveLinkWithSwapOff(t *testing.T) {
61 t.Parallel()
62 f := newSwapFixture(t)
63 f.a.gens = false
64 f.b.sidecarStaged = true
65 f.a.Bounce(f.host)
66 waitFor(t, "the stop", func() bool { return f.a.entryCount() == 0 })
67 arrangeAwake(t, f.a, testRoute(f.host))
68 if got := f.b.sidecarStartCount(); got != 1 {
69 t.Fatalf("StartSidecar calls = %d after the wake, want 1", got)
70 }
71 f.b.lastSidecar().kill()
72 f.clk.advance(3 * time.Second)
73 f.a.sweepOnce()
74 waitFor(t, "the supervised restart", func() bool { return f.a.sidecarSettled(f.host) && f.b.sidecarStartCount() == 2 })
75 if paths := f.b.sidecarGenPaths(); len(paths) != 0 {
76 t.Fatalf("StartSidecarGen ran with swaps off: %v", paths)
77 }
78}
79
80// Flag off, ownership: startSidecar asks ownedLocked whatever the flag (an
81// accepted dormancy exception, recorded in task-7-adapt.md). Exactly what
82// changes against today, called directly so no earlier sweep check masks it:
83// a host that does not own itself (fenced, shutting down, lease lost, no
84// longer the host's entry) gets NO co-spawned sidecar, where today's
85// StartSidecar would have spawned one. A host that owns itself gets its
86// sidecar on the generation's own staged file, with the flag off too
87// (ruling R-EXEC-SIDECAR, settings Task 5c), never the live link.
88func TestLegacySidecarOwnershipCheckRunsWithSwapOff(t *testing.T) {
89 f := newSwapFixture(t)
90 f.a.gens = false
91 f.b.sidecarStaged = true
92 f.a.mu.Lock()
93 e := f.a.entries[f.host]
94 rt := e.route
95 f.a.mu.Unlock()
96 rep := f.b.lastRep()
97
98 // Owned: the generation's recorded file, by path.
99 sc, err := f.a.startSidecar(context.Background(), e, rt, f.incumbent, false)
100 if err != nil || sc == nil {
101 t.Fatalf("an owned host's sidecar = %v, %v; want the generation's spawn", sc, err)
102 }
103 if paths := f.b.sidecarGenPaths(); len(paths) != 1 || paths[0] != f.incumbent.StagedPath {
104 t.Fatalf("StartSidecarGen paths = %v, want the generation's %q", paths, f.incumbent.StagedPath)
105 }
106 if got := f.b.sidecarStartCount(); got != 0 {
107 t.Fatalf("StartSidecar (live link) calls = %d, want none", got)
108 }
109
110 refused := func(name string, arm, disarm func(), want error) {
111 t.Helper()
112 arm()
113 sc, err := f.a.startSidecar(context.Background(), e, rt, f.incumbent, false)
114 disarm()
115 if !errors.Is(err, want) || sc != nil {
116 t.Fatalf("%s: startSidecar = %v, %v; want %v and no process", name, sc, err, want)
117 }
118 if got := len(f.b.sidecarGenPaths()); got != 1 || f.b.sidecarStartCount() != 0 {
119 t.Fatalf("%s: sidecar spawns = %d (live link %d), want 1 (none beside a host that does not own itself)", name, got, f.b.sidecarStartCount())
120 }
121 }
122 refused("fenced",
123 func() { f.a.mu.Lock(); f.a.fences = map[string]uint64{f.host: 1}; f.a.mu.Unlock() },
124 func() { f.a.mu.Lock(); f.a.fences = nil; f.a.mu.Unlock() }, errSwapFenced)
125 refused("shutting down",
126 func() { f.a.mu.Lock(); f.a.shuttingDown = true; f.a.mu.Unlock() },
127 func() { f.a.mu.Lock(); f.a.shuttingDown = false; f.a.mu.Unlock() }, errSwapShutdown)
128 refused("lease lost",
129 func() { rep.setErr(errors.New("lease lost")) },
130 func() { rep.setErr(nil) }, errSwapLeaseLost)
131 refused("not the host's entry",
132 func() { f.a.mu.Lock(); delete(f.a.entries, f.host); f.a.mu.Unlock() },
133 func() { f.a.mu.Lock(); f.a.entries[f.host] = e; f.a.mu.Unlock() }, errSwapNotAwake)
134
135 // And once owned again, the same call spawns as before.
136 if _, err := f.a.startSidecar(context.Background(), e, rt, f.incumbent, false); err != nil {
137 t.Fatalf("owned again: %v", err)
138 }
139 if got := len(f.b.sidecarGenPaths()); got != 2 {
140 t.Fatalf("StartSidecarGen calls = %d, want 2", got)
141 }
142}
143
144// Flag on: late attachment, the supervisor, a swap's replacement and the
145// wake tail all exec the CURRENT generation's captured staged path (spec
146// §7.5). The live link names v3 throughout and is never used.
147func TestLegacySidecarRunsTheServingGeneration(t *testing.T) {
148 t.Parallel()
149 t.Run("late attachment and the supervisor", func(t *testing.T) {
150 f := newSwapFixture(t)
151 f.b.execVerFn = func() string { return "v3" }
152 f.b.sidecarStaged = true // staged after the wake: the sweep attaches it
153 f.a.sweepOnce()
154 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
155 f.b.lastSidecar().kill()
156 f.clk.advance(3 * time.Second)
157 f.a.sweepOnce()
158 waitFor(t, "the supervised restart", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 2 })
159 for _, p := range f.b.sidecarGenPaths() {
160 if p != f.incumbent.StagedPath {
161 t.Fatalf("a sidecar ran %q, want the serving generation's %q", p, f.incumbent.StagedPath)
162 }
163 }
164 if got := f.b.sidecarStartCount(); got != 0 {
165 t.Fatalf("StartSidecar (live link) ran %d time(s) with swaps on", got)
166 }
167 })
168 t.Run("a swap replaces the sidecar stop-then-start", func(t *testing.T) {
169 f := newSwapFixture(t)
170 f.b.sidecarStaged = true
171 f.a.sweepOnce()
172 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
173 old := f.b.lastSidecar()
174 if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapDone {
175 t.Fatalf("Swap = %+v", res)
176 }
177 paths := f.b.sidecarGenPaths()
178 if len(paths) != 2 || paths[1] != f.staged {
179 t.Fatalf("sidecar spawns %v: the replacement must run the new generation's %q", paths, f.staged)
180 }
181 if !old.isStopped() {
182 t.Fatal("the old sidecar outlived the replacement")
183 }
184 ev := f.b.events()
185 if i, j := slices.Index(ev, "stop:sidecar"), slices.Index(ev, "startSidecarGen:"+f.staged); i < 0 || j < 0 || i > j {
186 t.Fatalf("order %v: stop the old sidecar, then start the new one", ev)
187 }
188 })
189 t.Run("the wake tail of a hold", func(t *testing.T) {
190 f := newSwapFixture(t)
191 f.b.sidecarStaged = true
192 if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapDone {
193 t.Fatalf("Swap = %+v", res)
194 }
195 if paths := f.b.sidecarGenPaths(); len(paths) == 0 || paths[len(paths)-1] != f.staged {
196 t.Fatalf("the hold's wake tail started sidecars %v, want %q last", paths, f.staged)
197 }
198 })
199}
200
201// The swap's replacement holds the supervisor's interlock, so a supervised
202// restart that falls due meanwhile cannot start a second sidecar (review
203// focus 4).
204func TestSwapSidecarReplacementHoldsTheSupervisorOff(t *testing.T) {
205 t.Parallel()
206 f := newSwapFixture(t)
207 f.b.sidecarStaged = true
208 f.a.sweepOnce()
209 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
210 gate := make(chan struct{})
211 f.b.gmu.Lock()
212 f.b.sidecarGenGate = gate
213 f.b.gmu.Unlock()
214 results := make(chan SwapResult, 1)
215 go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
216 waitFor(t, "the replacement's start", func() bool { return len(f.b.sidecarGenPaths()) == 2 })
217 held := f.a.sidecarStartDoneOf(f.host)
218 if held == nil {
219 t.Fatal("the replacement holds no interlock")
220 }
221 f.clk.advance(time.Minute)
222 f.a.sweepOnce() // the old sidecar is stopped (exited): the supervisor would restart it
223 // The supervisor claims sidecarStarting under the sweep's own lock, so a
224 // claim would have replaced the join channel before sweepOnce returned.
225 if now := f.a.sidecarStartDoneOf(f.host); now != held {
226 t.Fatal("a supervised restart claimed the interlock during the replacement")
227 }
228 if n := len(f.b.sidecarGenPaths()); n != 2 {
229 t.Fatalf("sidecar spawns = %d during the replacement, want 2", n)
230 }
231 close(gate)
232 if res := <-results; res.Outcome != SwapDone {
233 t.Fatalf("Swap = %+v", res)
234 }
235}
236
237// A supervised restart already in flight when the swap switches captured the
238// OLD generation. The swap's replacement joins it and then replaces what it
239// installed, so the new server never runs beside the old build's sidecar
240// (spec §7.5). Returning early because sidecarStarting is set (the old
241// replaceSidecar) leaves the supervisor's old-build sidecar installed with
242// no replacement pending: the third spawn never comes.
243func TestSwapSidecarReplacementJoinsAnInFlightSupervisor(t *testing.T) {
244 t.Parallel()
245 f := newSwapFixture(t)
246 f.b.sidecarStaged = true
247 f.a.sweepOnce()
248 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
249 gate := make(chan struct{})
250 f.b.gmu.Lock()
251 f.b.sidecarGenGate = gate
252 f.b.gmu.Unlock()
253 f.b.lastSidecar().kill()
254 f.clk.advance(3 * time.Second)
255 f.a.sweepOnce() // the supervisor starts a sidecar for the incumbent and blocks
256 waitFor(t, "the supervisor's start", func() bool { return len(f.b.sidecarGenPaths()) == 2 })
257 results := make(chan SwapResult, 1)
258 go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
259 waitFor(t, "the switch", f.generationSwitched)
260 close(gate)
261 if res := <-results; res.Outcome != SwapDone {
262 t.Fatalf("Swap = %+v", res)
263 }
264 waitFor(t, "the replacement onto the new generation", func() bool {
265 p := f.b.sidecarGenPaths()
266 return len(p) == 3 && f.a.sidecarSettled(f.host)
267 })
268 paths := f.b.sidecarGenPaths()
269 if paths[1] != f.incumbent.StagedPath || paths[2] != f.staged {
270 t.Fatalf("sidecar spawns %v: want the supervisor's on %q, then the replacement on %q", paths, f.incumbent.StagedPath, f.staged)
271 }
272 // The supervisor's sidecar is stopped by the replacement's
273 // stop-then-start.
274 f.b.mu.Lock()
275 supervised, current := f.b.sidecars[1], f.b.sidecars[len(f.b.sidecars)-1]
276 f.b.mu.Unlock()
277 if !supervised.isStopped() {
278 t.Fatal("the supervisor's old-build sidecar outlived the swap")
279 }
280 f.a.mu.Lock()
281 installed := f.a.entries[f.host].sidecar
282 f.a.mu.Unlock()
283 if installed != Proc(current) {
284 t.Fatal("the installed sidecar is not the replacement")
285 }
286}
287
288// The supervisor's start for the retired generation is joined until that
289// process is proven gone (spec §7.4, §7.5): the replacement never starts
290// while it still runs. Discarding it asynchronously let the supervisor
291// close its join at once, so the replacement started beside a sidecar
292// still draining on the old build.
293func TestSwapSidecarNeverRunsBesideTheRetiredGenerationsSidecar(t *testing.T) {
294 t.Parallel()
295 f := newSwapFixture(t)
296 f.b.sidecarStaged = true
297 f.a.sweepOnce()
298 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
299 startGate, stopGate := make(chan struct{}), make(chan struct{})
300 f.b.gmu.Lock()
301 f.b.sidecarGenGate, f.b.sidecarStopGate = startGate, stopGate
302 f.b.gmu.Unlock()
303 f.b.lastSidecar().kill()
304 f.clk.advance(3 * time.Second)
305 f.a.sweepOnce() // the supervisor starts the incumbent's sidecar and blocks
306 waitFor(t, "the supervisor's start", func() bool { return len(f.b.sidecarGenPaths()) == 2 })
307 results := make(chan SwapResult, 1)
308 go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
309 waitFor(t, "the switch", f.generationSwitched)
310 close(startGate) // the supervisor's sidecar starts; its Stop blocks on stopGate
311 // The retired generation's sidecar is being stopped, and the replacement
312 // is sequenced behind that Stop: nothing can have started beside it.
313 waitFor(t, "the retired sidecar's stop to be in flight", func() bool { return f.b.sidecarStoppingIn(1) })
314 if n := len(f.b.sidecarGenPaths()); n != 2 {
315 t.Fatalf("%d sidecar starts: the replacement started while the retired generation's sidecar still ran", n)
316 }
317 select {
318 case res := <-results:
319 t.Fatalf("the swap returned (%+v) before the retired sidecar was proven gone", res)
320 default:
321 }
322 close(stopGate)
323 if res := <-results; res.Outcome != SwapDone {
324 t.Fatalf("Swap = %+v", res)
325 }
326 waitFor(t, "the replacement", func() bool { return len(f.b.sidecarGenPaths()) == 3 })
327 if p := f.b.sidecarGenPaths(); p[2] != f.staged {
328 t.Fatalf("sidecar starts %v: want the new generation's last", p)
329 }
330}
331
332// A terminal stop that begins while a supervised sidecar start is in flight
333// joins that start, and the sidecar it produced, before the lease is
334// released (spec §7.4). Discarding it asynchronously let the supervisor
335// close its join at once, so the stop released the lease while the new
336// sidecar was still running.
337func TestSwapStopJoinsTheSidecarAStartLeftBehind(t *testing.T) {
338 t.Parallel()
339 f := newSwapFixture(t)
340 f.b.sidecarStaged = true
341 f.a.sweepOnce()
342 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
343 startGate, stopGate := make(chan struct{}), make(chan struct{})
344 f.b.gmu.Lock()
345 f.b.sidecarGenGate, f.b.sidecarStopGate = startGate, stopGate
346 f.b.gmu.Unlock()
347 f.b.lastSidecar().kill()
348 f.clk.advance(3 * time.Second)
349 f.a.sweepOnce() // the supervisor's start blocks
350 waitFor(t, "the supervisor's start", func() bool { return len(f.b.sidecarGenPaths()) == 2 })
351 rep := f.b.lastRep()
352 joining := make(chan struct{})
353 var joinOnce sync.Once
354 f.a.mu.Lock()
355 f.a.joinWaiting = func(_, what string) {
356 if what == "sidecar start" {
357 joinOnce.Do(func() { close(joining) })
358 }
359 }
360 f.a.mu.Unlock()
361 f.a.Bounce(f.host)
362 // The stop is joined on the supervisor's start: it goes no further (no
363 // instance stop, so no lease release) while the start is in flight. Wait
364 // for the stop to reach that join (the join seam's positive signal), or,
365 // if something skipped it, for the instance's stop.
366 waitFor(t, "the stop at its sidecar-start join", func() bool {
367 select {
368 case <-joining:
369 return true
370 default:
371 }
372 return slices.Contains(f.b.events(), "stop:instance")
373 })
374 if slices.Contains(f.b.events(), "stop:instance") || rep.isClosed() {
375 t.Fatal("the stop went past its join on the in-flight sidecar start")
376 }
377 close(startGate) // the start lands on a stopping entry; its sidecar's Stop blocks
378 waitFor(t, "the left-behind sidecar's stop to be in flight", func() bool { return f.b.sidecarStoppingIn(1) })
379 if rep.isClosed() {
380 t.Fatal("the lease was released while the sidecar a start left behind still ran")
381 }
382 close(stopGate)
383 waitFor(t, "the lease release", rep.isClosed)
384 if started := f.b.sidecarProc(1); !started.isStopped() {
385 t.Fatal("the sidecar the start left behind was never stopped")
386 }
387}
388
389// Spec §7.4: a discarded sidecar whose stop is unproven is never forgotten.
390// It stays tracked, the host takes no new wake beside it, and the sweep
391// stops it again until it exits. Logging it and moving on let the next wake
392// run a second sidecar beside a live one.
393func TestAStraySidecarIsNeverForgotten(t *testing.T) {
394 t.Parallel()
395 f := newSwapFixture(t)
396 f.a.Bounce(f.host)
397 waitFor(t, "the instance asleep", func() bool { return f.a.entryCount() == 0 })
398 stray := &fakeProc{label: "sidecar", rec: f.b.fakeBackend, stuck: true}
399 f.a.discardSidecar(testRoute(f.host), stray)
400 if got := f.a.strayCount(f.host); got != 1 {
401 t.Fatalf("tracked strays = %d, want 1", got)
402 }
403 if _, err := f.a.beginRequest(context.Background(), testRoute(f.host)); err == nil || !strings.Contains(err.Error(), errWakeUnproven.Error()) {
404 t.Fatalf("a wake beside an unproven sidecar = %v, want refused", err)
405 }
406 stray.exit()
407 f.a.sweepOnce() // the reaper re-proves it off the lock
408 waitFor(t, "the stray proven gone", func() bool { return f.a.strayCount(f.host) == 0 })
409 release, err := f.a.beginRequest(context.Background(), testRoute(f.host))
410 if err != nil {
411 t.Fatalf("the wake once the stray exited: %v", err)
412 }
413 release()
414}
415
416func (a *Service) strayCount(host string) int {
417 a.mu.Lock()
418 defer a.mu.Unlock()
419 return len(a.straySidecars[host])
420}
421
422// strayReaping is true while any stray of host has a re-proof in flight.
423func (a *Service) strayReaping(host string) bool {
424 a.mu.Lock()
425 defer a.mu.Unlock()
426 for _, s := range a.straySidecars[host] {
427 if s.reaping {
428 return true
429 }
430 }
431 return false
432}
433
434func (a *Service) stopPending(host string) bool {
435 a.mu.Lock()
436 defer a.mu.Unlock()
437 e := a.entries[host]
438 return e != nil && e.pendingStop
439}
440
441// Spec §7.4, §7.5: a legacy sidecar is gone only when its cgroup is empty.
442// childProc.Stop signals only the leader, so a process the sidecar forked
443// outlives its exit in role-sidecar. Here the leader exits but a descendant
444// stays (KillSidecar cannot empty the cgroup): the replacement does not
445// start beside it, and a terminal stop keeps the lease until it is proven
446// gone. Accepting Exited() started the new sidecar beside the descendant
447// and released the lease under it.
448func TestASidecarsDescendantBlocksItsReplacementAndTheLease(t *testing.T) {
449 t.Parallel()
450 f := newSwapFixture(t)
451 f.b.sidecarStaged = true
452 f.a.sweepOnce()
453 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
454 f.b.setSidecarDescendant(true)
455 if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapDone {
456 t.Fatalf("Swap = %+v", res)
457 }
458 if got := f.a.strayCount(f.host); got != 1 {
459 t.Fatalf("tracked strays = %d after the replacement, want 1 (the old sidecar's descendant)", got)
460 }
461 // A supervisor pass that falls due must not start one either. It runs
462 // startSidecar, which refuses while the stray is tracked.
463 f.clk.advance(time.Minute)
464 f.a.sweepOnce()
465 waitFor(t, "the supervisor's refused start", func() bool { return f.a.sidecarSettled(f.host) })
466 if n := len(f.b.sidecarGenPaths()); n != 1 {
467 t.Fatalf("%d sidecar starts: a new sidecar started beside the old one's descendant", n)
468 }
469 rep := f.b.lastRep()
470 f.a.Bounce(f.host)
471 waitFor(t, "the stop to wait on the sidecar's cgroup", func() bool { return f.a.stopPending(f.host) })
472 // Each sweep re-proves the stray; the descendant is still there.
473 for range 2 {
474 before := f.b.sidecarKillCount()
475 f.a.sweepOnce()
476 waitFor(t, "a proof attempt", func() bool { return f.b.sidecarKillCount() > before })
477 waitFor(t, "the re-proof to finish", func() bool { return !f.a.strayReaping(f.host) })
478 if rep.isClosed() {
479 t.Fatal("the lease was released while the sidecar's descendant still ran")
480 }
481 }
482 if got := f.a.strayCount(f.host); got != 1 {
483 t.Fatalf("tracked strays = %d with the descendant alive, want 1", got)
484 }
485 f.b.setSidecarDescendant(false)
486 waitFor(t, "the lease release once the cgroup is empty", func() bool {
487 f.a.sweepOnce()
488 return rep.isClosed()
489 })
490}
491
492// A terminal stop's co-stop is held to the same proof: the sidecar's leader
493// exits but a process it forked is left in the cgroup, so the stop keeps the
494// lease (releasable) until the sweep proves the cgroup empty.
495func TestATerminalStopKeepsTheLeaseOverASidecarsDescendant(t *testing.T) {
496 t.Parallel()
497 f := newSwapFixture(t)
498 f.b.sidecarStaged = true
499 f.a.sweepOnce()
500 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
501 f.b.setSidecarDescendant(true)
502 rep := f.b.lastRep()
503 f.a.Bounce(f.host)
504 waitFor(t, "the stop to wait on the sidecar's cgroup", func() bool { return f.a.stopPending(f.host) })
505 if got := f.a.strayCount(f.host); got != 1 {
506 t.Fatalf("tracked strays = %d after the co-stop, want 1", got)
507 }
508 before := f.b.sidecarKillCount()
509 f.a.sweepOnce()
510 waitFor(t, "a proof attempt", func() bool { return f.b.sidecarKillCount() > before })
511 waitFor(t, "the re-proof to finish", func() bool { return !f.a.strayReaping(f.host) })
512 if rep.isClosed() {
513 t.Fatal("the lease was released while the sidecar's descendant still ran")
514 }
515 f.b.setSidecarDescendant(false)
516 waitFor(t, "the lease release once the cgroup is empty", func() bool {
517 f.a.sweepOnce()
518 return rep.isClosed()
519 })
520}
521
522// A supervised restart of a sidecar that exited on its own is a replacement
523// too: if something it forked is left in the cgroup, the old one becomes a
524// tracked stray and no new sidecar starts beside it.
525func TestASupervisedRestartWaitsForTheCrashedSidecarsDescendant(t *testing.T) {
526 t.Parallel()
527 f := newSwapFixture(t)
528 f.b.sidecarStaged = true
529 f.a.sweepOnce()
530 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
531 f.b.setSidecarDescendant(true)
532 f.b.lastSidecar().kill()
533 f.clk.advance(3 * time.Second)
534 f.a.sweepOnce()
535 waitFor(t, "the refused restart", func() bool { return f.a.sidecarSettled(f.host) && f.a.strayCount(f.host) == 1 })
536 if n := len(f.b.sidecarGenPaths()); n != 1 {
537 t.Fatalf("%d sidecar starts: a restart ran beside the crashed sidecar's descendant", n)
538 }
539 f.b.setSidecarDescendant(false)
540 waitFor(t, "the restart once the cgroup is empty", func() bool {
541 f.clk.advance(time.Minute)
542 f.a.sweepOnce()
543 return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 2
544 })
545 if got := f.a.strayCount(f.host); got != 0 {
546 t.Fatalf("tracked strays = %d after the proof, want 0", got)
547 }
548}
549
550// A legacy sidecar left on a retired generation's build (a replacement whose
551// stop of it was unproven) is replaced by the sweep's retry.
552func TestSweepReplacesASidecarLeftOnARetiredGeneration(t *testing.T) {
553 t.Parallel()
554 f := newSwapFixture(t)
555 f.b.sidecarStaged = true
556 f.a.sweepOnce()
557 waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
558 old := f.b.lastSidecar()
559 f.a.mu.Lock()
560 f.a.entries[f.host].sidecarGen = f.incumbent.N - 1 // a build the entry no longer serves
561 f.a.mu.Unlock()
562 f.a.sweepOnce()
563 waitFor(t, "the replacement", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 2 })
564 if !old.isStopped() {
565 t.Fatal("the retired generation's sidecar was not stopped")
566 }
567 f.a.mu.Lock()
568 got := f.a.entries[f.host].sidecarGen
569 f.a.mu.Unlock()
570 if got != f.incumbent.N {
571 t.Fatalf("sidecarGen = %d, want the current generation %d", got, f.incumbent.N)
572 }
573}
574
575// Under plan 2's containment the sidecar keeps its role-sidecar child: no
576// limit of its own, and the generation parent keeps memory.oom.group=0 and
577// holds no process (spec §7.3, plan 2 D4). Spawning through spawnInCgroup
578// instead writes the tier and memory.oom.group=1 onto the parent and makes
579// no role-sidecar child.
580func TestStartSidecarGenKeepsTheRoleSidecarChild(t *testing.T) {
581 const host = "sc.example.com"
582 b, parent := delegatedBackend(t, host)
583 cgroup.FakePlacementForTest(t) // a temp dir is no cgroupfs, and a generation is never moved by PID
584 b.GenContainment = true
585 v2 := filepath.Join(b.OptDir, "versions", "v2", "app")
586 if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil {
587 t.Fatal(err)
588 }
589 // It stays up until the stop: its exit removes its own child.
590 if err := os.WriteFile(v2, []byte("#!/bin/sh\nexec sleep 30\n"), 0o755); err != nil {
591 t.Fatal(err)
592 }
593 if err := os.WriteFile(filepath.Join(b.DataDir, host+"-sidecar.env"), []byte("X=1\n"), 0o600); err != nil {
594 t.Fatal(err)
595 }
596 rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(b.DataDir, host),
597 Socket: filepath.Join(provision.SocketDir, host+".sock")}
598 p, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: "v2", StagedPath: v2})
599 if err != nil {
600 t.Fatal(err)
601 }
602 // Checked before the stop: the exit's cleanup removes the empty child
603 // (with placement before exec nothing is written into it to keep it).
604 if fi, err := os.Stat(filepath.Join(parent, "role-sidecar")); err != nil || !fi.IsDir() {
605 _ = p.Stop(context.Background())
606 t.Fatalf("no role-sidecar child: %v", err)
607 }
608 if _, err := os.Stat(filepath.Join(parent, "role-sidecar", "memory.max")); !os.IsNotExist(err) {
609 t.Errorf("the sidecar's child got a limit of its own: %v", err)
610 }
611 _ = p.Stop(context.Background())
612 if got, err := os.ReadFile(filepath.Join(parent, "memory.oom.group")); err != nil || string(got) != "0" {
613 t.Errorf("parent memory.oom.group = %q, %v; want 0", got, err)
614 }
615}
616
617// ExecBackend: a flat sidecar execs the generation's staged path. A nested
618// sidecar is refused until Task 9 hands root the staged executable: it never
619// reads the live link to decide, because a repoint can land between that
620// read and the start.
621func TestExecBackendStartSidecarGen(t *testing.T) {
622 t.Parallel()
623 stage := func(t *testing.T, b *ExecBackend, host string) (v2, marker string) {
624 t.Helper()
625 marker = filepath.Join(t.TempDir(), "argv0")
626 v2 = filepath.Join(b.OptDir, "versions", "v2", "app")
627 if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil {
628 t.Fatal(err)
629 }
630 if err := os.WriteFile(v2, []byte("#!/bin/sh\nprintf '%s' \"$0\" > '"+marker+"'\n"), 0o755); err != nil {
631 t.Fatal(err)
632 }
633 if err := os.WriteFile(filepath.Join(b.DataDir, host+"-sidecar.env"), []byte("X=1\n"), 0o600); err != nil {
634 t.Fatal(err)
635 }
636 return v2, marker
637 }
638 t.Run("flat", func(t *testing.T) {
639 b := testExecBackend(t)
640 host := "sc.example.com"
641 stageLive(t, b, host, "v1")
642 v2, marker := stage(t, b, host)
643 // The default db and socket: Classify puts this route in the flat layout.
644 rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(b.DataDir, host),
645 Socket: filepath.Join(provision.SocketDir, host+".sock")}
646 if l, why := b.Layout(rt); l != LayoutFlat {
647 t.Fatalf("route classified %v (%s), want the flat layout", l, why)
648 }
649 p, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: "v2", StagedPath: v2})
650 if err != nil {
651 t.Fatal(err)
652 }
653 defer p.Stop(context.Background()) //nolint:errcheck
654 if got := waitForFile(t, marker, 2*time.Second); got != v2 {
655 t.Fatalf("the sidecar execd %q, want the generation's %q", got, v2)
656 }
657 })
658 t.Run("foreign", func(t *testing.T) {
659 b := testExecBackend(t)
660 host := "fsc.example.com"
661 stageLive(t, b, host, "v1")
662 v2, marker := stage(t, b, host)
663 rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "s.sock")}
664 if l, _ := b.Layout(rt); l != LayoutForeign {
665 t.Fatalf("route classified %v, want the foreign layout", l)
666 }
667 p, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: "v2", StagedPath: v2})
668 if err != nil {
669 t.Fatal(err)
670 }
671 defer p.Stop(context.Background()) //nolint:errcheck
672 if got := waitForFile(t, marker, 2*time.Second); got != v2 {
673 t.Fatalf("the sidecar execd %q, want the generation's %q", got, v2)
674 }
675 })
676 t.Run("nested", func(t *testing.T) {
677 b := testExecBackend(t)
678 rd := t.TempDir()
679 b.RunDirOverride = rd
680 host := "nsc.example.com"
681 stageLive(t, b, host, "v1")
682 rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.NestedDBPath(b.DataDir, host),
683 Socket: filepath.Join(rd, "instances", provision.InstanceID(host), "app.sock")}
684 for _, build := range []string{"v1", "v2"} { // the live build and another
685 // A generation with no staged executable (woken on the live
686 // link) has nothing to name to root, so it still refuses.
687 if _, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: build}); !errors.Is(err, errNestedSidecarGen) {
688 t.Fatalf("serving %s with no staged path: %v, want errNestedSidecarGen", build, err)
689 }
690 }
691 })
692}
693
694// KillSidecar: the nested arm asks the sidecar unit's stop, and with no
695// Control wired it answers an error, never a nil dereference. Flat without
696// containment has no cgroup of its own to prove: errNoGenContainment.
697func TestExecBackendKillSidecar(t *testing.T) {
698 t.Run("nested without a control channel", func(t *testing.T) {
699 b := testExecBackend(t)
700 rd := t.TempDir()
701 b.RunDirOverride = rd
702 host := "nsc.example.com"
703 rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.NestedDBPath(b.DataDir, host),
704 Socket: filepath.Join(rd, "instances", provision.InstanceID(host), "app.sock")}
705 if err := b.KillSidecar(context.Background(), rt); err == nil {
706 t.Fatal("KillSidecar with no Control proved a nested sidecar gone")
707 }
708 })
709 t.Run("flat without containment", func(t *testing.T) {
710 b := testExecBackend(t)
711 host := "sc.example.com"
712 rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(b.DataDir, host),
713 Socket: filepath.Join(provision.SocketDir, host+".sock")}
714 if err := b.KillSidecar(context.Background(), rt); !errors.Is(err, errNoGenContainment) {
715 t.Fatalf("KillSidecar = %v, want errNoGenContainment", err)
716 }
717 })
718}
719
720var _ SidecarGenStarter = (*SplitBackend)(nil)
721