carlos / platform Public

zdd plan 3 task 7: legacy sidecars exec the serving generation's staged path

startSidecar (ownership at the exec, refuses beside a tracked stray),
StartSidecarGen/startSidecarChildFrom, sidecarTerminated and KillSidecar,
tracked stray sidecars and their reaper, replaceSidecar as swap step 8,
discardSidecar, and the stop() join with no timeout when generations are on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev dee0cc88fe191bf66db4160f80380db5fe883842 parent 5f752be
7 files changed, +1169 −12
  • internal/activator/activator.go +320 −10
  • internal/activator/activator_test.go +9 −0
  • internal/activator/backend_exec.go +12 −2
  • internal/activator/genbackend.go +89 −0
  • internal/activator/swap.go +2 −0
  • internal/activator/swap_fixture_test.go +57 −0
  • internal/activator/swap_sidecar_test.go +680 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 83db92298..9d6af7eb6 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -63,6 +63,7 @@ import (
"net/http"
"os"
"path"
+ "slices"
"strconv"
"strings"
"sync"
@@ -412,6 +413,9 @@ type actEntry struct {
// sidecarStarting is true. stop() waits on it, bounded by
// sidecarStopTimeout, instead of polling sidecarStarting.
sidecarStartDone chan struct{}
+ // sidecarGen is the generation whose build the legacy sidecar runs;
+ // written under a.mu.
+ sidecarGen uint64
// pendingStop marks a stop whose proc.Stop returned ErrStillRunning
// (design §6.3): termination is unproven, so stop() returned without
@@ -583,6 +587,117 @@ func stopDiscardedSidecar(sc Proc) {
}()
}
+// discardSidecar is how a sidecar start that lost its entry is shed. With
+// generations off it is today's stopDiscardedSidecar, fire-and-forget. With
+// them on it is synchronous and bounded, and the caller runs it before it
+// closes the join (sidecarStartDone) a terminal stop waits on (spec §7.4).
+// A sidecar whose termination it cannot prove stays tracked
+// (trackStraySidecarLocked).
+func (a *Service) discardSidecar(rt registry.Route, sc Proc) {
+ if sc == nil {
+ return
+ }
+ if !a.gens {
+ stopDiscardedSidecar(sc)
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
+ err := sc.Stop(ctx)
+ cancel()
+ if !a.sidecarTerminated(rt, sc) {
+ log.Printf("activator: %s: a discarded SIDECAR is not proven gone (%v); keeping it tracked", rt.Host, err)
+ a.mu.Lock()
+ a.trackStraySidecarLocked(rt, sc)
+ a.mu.Unlock()
+ }
+}
+
+var errSidecarUnproven = errors.New("a legacy sidecar of this host is not proven gone")
+
+// sidecarKiller is the backend's legacy-sidecar kill: whatever is left in
+// the host's sidecar cgroup is killed, and it returns once that cgroup is
+// empty.
+type sidecarKiller interface {
+ KillSidecar(ctx context.Context, rt registry.Route) error
+}
+
+// sidecarTerminated is THE termination proof for a legacy sidecar (spec
+// §7.4, §7.5), as genTerminated is for a generation: its leader has exited
+// AND its sidecar cgroup is proven empty. childProc.Stop signals only the
+// leader, so its exit says nothing of a process it forked. A backend with
+// no sidecar kill (a test double), or one that answers errNoGenContainment
+// (the sidecar shares the host cgroup, flag off), has nothing more to
+// prove. A nil process never ran. Never called with a.mu held. The cgroup
+// is this host's legacy sidecar's alone: one entry per host, and the
+// interlock and the join mean no second sidecar of the host runs while one
+// is being proven.
+func (a *Service) sidecarTerminated(rt registry.Route, sc Proc) bool {
+ if sc == nil {
+ return true
+ }
+ if !sc.Exited() {
+ return false
+ }
+ k, ok := a.backend.(sidecarKiller)
+ if !ok {
+ return true
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
+ defer cancel()
+ if err := k.KillSidecar(ctx, rt); err != nil && !errors.Is(err, errNoGenContainment) {
+ log.Printf("activator: %s: the sidecar's leader exited but its cgroup is not proven empty: %v", rt.Host, err)
+ return false
+ }
+ return true
+}
+
+// trackStraySidecarLocked keeps a legacy sidecar whose termination is
+// unproven: it is never forgotten (spec §7.4). While one is tracked the host
+// takes no new wake (errWakeUnproven), starts no sidecar (startSidecar), and
+// no stop of it releases its lease (releasable); the sweep
+// (reapStraySidecarsLocked) re-stops and re-proves it. The caller holds a.mu.
+func (a *Service) trackStraySidecarLocked(rt registry.Route, sc Proc) {
+ if a.straySidecars == nil {
+ a.straySidecars = make(map[string][]*straySidecar)
+ }
+ a.straySidecars[rt.Host] = append(a.straySidecars[rt.Host], &straySidecar{proc: sc, rt: rt})
+}
+
+// reapStraySidecarsLocked re-stops each stray sidecar and re-proves it off
+// the lock (the proof is a kernel or root call), forgetting it only once
+// sidecarTerminated holds. The caller holds a.mu.
+func (a *Service) reapStraySidecarsLocked() {
+ for _, strays := range a.straySidecars {
+ for _, s := range strays {
+ if s.reaping {
+ continue
+ }
+ s.reaping = true
+ go func(s *straySidecar) {
+ ctx, cancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
+ if err := s.proc.Stop(ctx); err != nil && !errors.Is(err, ErrStillRunning) {
+ log.Printf("activator: re-stop stray sidecar %s: %v", s.rt.Host, err)
+ }
+ cancel()
+ proven := a.sidecarTerminated(s.rt, s.proc)
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ s.reaping = false
+ if !proven {
+ return
+ }
+ host := s.rt.Host
+ keep := slices.DeleteFunc(a.straySidecars[host], func(x *straySidecar) bool { return x == s })
+ if len(keep) == 0 {
+ delete(a.straySidecars, host)
+ } else {
+ a.straySidecars[host] = keep
+ }
+ }(s)
+ }
+ }
+}
+
// Build convergence: how long an awake child may serve a symlink-stale binary
// before the sweep gracefully cycles it, and how many to cycle per tick.
// - ConvergeGrace comfortably exceeds one sweep interval (≤30s) and a
@@ -1105,6 +1220,7 @@ func (a *Service) wake(e *actEntry) {
e.rep, e.proc = w.rep, w.proc
e.sidecar, e.sidecarWanted = w.sidecar, w.sidecarWanted
e.sidecarUncertain = w.sidecarUncertain
+ e.sidecarGen = w.sidecarGen
e.sidecarStarted = a.now()
e.sidecarBackoff = 0
e.sidecarRetryAt = time.Time{} // a wake-time spawn failure retries on the next sweep
@@ -1131,6 +1247,11 @@ func (a *Service) wake(e *actEntry) {
if e.gen.N != 0 {
a.noteCurrent(e.host, e.gen.N)
}
+ if e.sidecarGen == 0 {
+ // A wake that carried no generation number (the claim path) ran
+ // its sidecar on the live link, which is that wake's own build.
+ e.sidecarGen = e.gen.N
+ }
// Outside consumers of the canonical path reach the generation
// that just became current (spec §7.1). A failed link costs them
// the canonical path, not the wake: internal traffic never uses it.
@@ -1225,6 +1346,7 @@ type wokenInstance struct {
proc Proc
sidecar Proc
sidecarWanted bool
+ sidecarGen uint64 // the generation whose build the legacy sidecar runs
spawnVersion string // live/<host> target read at exec — for build convergence
spawnIdentity release.StagedIdentity // the build exec'd, as the backend captured it (workers; spec §7.9)
gen Gen // the spawned generation's handle (instances only; zero for a worker)
@@ -1306,9 +1428,12 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
// 0.6. A failed wake whose generation is not proven stopped still holds
// the host's lease, and its process may still run against the database
// (zero-downtime spec §7.4). No new wake starts until the sweep proves
- // it (reapUnprovenWakesLocked).
+ // it (reapUnprovenWakesLocked). A legacy sidecar of the host whose
+ // termination is unproven refuses it the same way
+ // (reapStraySidecarsLocked).
a.mu.Lock()
_, unproven := a.unprovenWakes[host]
+ unproven = unproven || len(a.straySidecars[host]) > 0
a.mu.Unlock()
if unproven {
return w, fmt.Errorf("%s: refusing to wake: %w", host, errWakeUnproven)
@@ -1533,7 +1658,8 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
}
if a.backend.HasSidecar(rt) {
w.sidecarWanted = true
- if sc, err := a.backend.StartSidecar(ctx, rt); err != nil {
+ w.sidecarGen = w.gen.N
+ if sc, err := a.startSidecar(ctx, e, rt, w.gen, true); err != nil {
w.sidecarUncertain = true
w.sidecar = sc
if sc == nil {
@@ -1649,6 +1775,127 @@ func (a *Service) bounceLocked(e *actEntry) bool {
return true
}
+// startSidecar is every legacy co-spawned sidecar spawn: the wake tail,
+// late attachment and the supervisor (sweepOnce), and a swap's replacement
+// (zero-downtime spec §7.5). With swaps on and a generation whose staged
+// path is known, it execs that path. Otherwise it is today's StartSidecar,
+// live link and all.
+//
+// Immediately before the spawn it asks the gate's ownership check (spec
+// §7.4): e must still be the host's, unfenced, with no shutdown, and (awake)
+// its lease held; waking is the wake tail, whose lease is the wake's own.
+// The check runs whatever the switch says (an accepted dormancy exception,
+// recorded with the controller's rulings for Task 7): a host that does not
+// own itself gets no co-spawned sidecar. While a stray sidecar of the host
+// is tracked (its termination unproven) nothing starts beside it.
+func (a *Service) startSidecar(ctx context.Context, e *actEntry, rt registry.Route, g Gen, waking bool) (Proc, error) {
+ a.mu.Lock()
+ err := a.ownedLocked(e, waking)
+ if err == nil && len(a.straySidecars[rt.Host]) > 0 {
+ err = errSidecarUnproven
+ }
+ a.mu.Unlock()
+ if err != nil {
+ return nil, err
+ }
+ if a.gens && g.StagedPath != "" {
+ if s, ok := a.backend.(SidecarGenStarter); ok {
+ return s.StartSidecarGen(ctx, rt, g)
+ }
+ }
+ return a.backend.StartSidecar(ctx, rt)
+}
+
+// replaceSidecar is §7.5 step 8: a legacy co-spawned sidecar is restarted,
+// stop-then-start, onto the generation just switched to. It claims the
+// supervisor's interlock (sidecarStarting, sidecarStartDone) for the whole
+// replacement. A supervised restart cannot race it, and stop() waits on it
+// exactly as it waits on a supervised restart.
+//
+// A supervised start already in flight captured the generation it read
+// when it began, which may be the one this swap just retired. Skipping the
+// replacement because of it would leave the old build's sidecar beside the
+// new server with nothing pending, so replaceSidecar joins it (its
+// sidecarStartDone, bounded by the supervisor's DeadlineControl) and then
+// replaces whatever it installed.
+func (a *Service) replaceSidecar(e *actEntry) {
+ a.mu.Lock()
+ for e.sidecarStarting && a.entries[e.host] == e && e.state == actAwake {
+ wait := e.sidecarStartDone
+ a.mu.Unlock()
+ if wait != nil {
+ <-wait
+ }
+ a.mu.Lock()
+ }
+ if a.entries[e.host] != e || e.state != actAwake || !e.sidecarWanted || len(a.straySidecars[e.host]) > 0 {
+ // A stray still unproven: the reaper proves it first, and the
+ // supervisor then starts the current generation's sidecar.
+ a.mu.Unlock()
+ return
+ }
+ if e.sidecar != nil && !e.sidecar.Exited() && e.sidecarGen == e.gen.N {
+ // Already on the current generation (the sweep's retry got here
+ // first): nothing to replace.
+ a.mu.Unlock()
+ return
+ }
+ e.sidecarStarting = true
+ done := make(chan struct{})
+ e.sidecarStartDone = done
+ old, rt, g := e.sidecar, e.route, e.gen
+ a.mu.Unlock()
+ defer close(done) // only after the old sidecar is proven gone, or retained
+ if old != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
+ if err := old.Stop(ctx); err != nil && !errors.Is(err, ErrStillRunning) {
+ log.Printf("activator: stop sidecar %s for its replacement: %v", e.host, err)
+ }
+ cancel()
+ if !a.sidecarTerminated(rt, old) {
+ // Unproven (spec §7.4, §7.5): the leader still runs, or something
+ // it forked is left in its cgroup. It becomes a tracked stray, in
+ // the same critical section that takes it off the entry, so no
+ // supervisor sees the entry without a sidecar and nothing to wait
+ // for. Nothing starts beside it, a stop keeps the lease
+ // (releasable), and the reaper proves it.
+ a.mu.Lock()
+ if a.entries[e.host] == e {
+ e.sidecar = nil
+ e.sidecarStarting, e.sidecarStartDone, e.sidecarUncertain = false, nil, true
+ }
+ a.trackStraySidecarLocked(rt, old)
+ a.mu.Unlock()
+ log.Printf("activator: SIDECAR for %s is not proven gone for its replacement; tracking it (the sweep re-proves it)", e.host)
+ return
+ }
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
+ sc, err := a.startSidecar(ctx, e, rt, g, false)
+ cancel()
+ var discard Proc
+ defer func() { a.discardSidecar(rt, discard) }() // after the unlock, before done closes
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.entries[e.host] != e {
+ discard = sc
+ return
+ }
+ e.sidecarStarting = false
+ e.sidecarStartDone = nil
+ if e.state != actAwake {
+ // A terminal stop is joined on this replacement (done). It stops
+ // e.sidecar after the join and proves it: install sc for it.
+ e.sidecar, e.sidecarGen = sc, g.N
+ return
+ }
+ e.sidecar, e.sidecarStarted, e.sidecarBackoff, e.sidecarGen = sc, a.now(), 0, g.N
+ if err != nil {
+ e.sidecarUncertain = true
+ log.Printf("activator: SIDECAR for %s failed to start on generation %d: %v (the sweep retries)", e.host, g.N, err)
+ }
+}
+
// BounceAdopted is Bounce for an ADOPT pass's restart callback, with one
// thing Bounce cannot know taken into account: the callback fires after the
// pass has repointed live/<host>, so a child already exec'd from the build the
@@ -2365,9 +2612,13 @@ func (a *Service) stop(e *actEntry) {
startDone := e.sidecarStartDone
a.mu.Unlock()
if startDone != nil {
- select {
- case <-startDone:
- case <-time.After(sidecarStopTimeout):
+ if a.gens {
+ <-startDone // the start, and every process it left, is accounted for
+ } else {
+ select {
+ case <-startDone:
+ case <-time.After(sidecarStopTimeout):
+ }
}
}
@@ -2392,6 +2643,19 @@ func (a *Service) stop(e *actEntry) {
log.Printf("activator: stop sidecar %s: %v", e.host, err)
}
scCancel()
+ if a.gens {
+ // A sidecar is gone only when its leader exited AND its cgroup is
+ // empty (spec §7.4, §7.5). Unproven, it is tracked: the lease is
+ // kept (releasable) and the sweep re-proves it.
+ a.mu.Lock()
+ rt := e.route
+ a.mu.Unlock()
+ if !a.sidecarTerminated(rt, e.sidecar) {
+ a.mu.Lock()
+ a.trackStraySidecarLocked(rt, e.sidecar)
+ a.mu.Unlock()
+ }
+ }
}
// The due-time index: ask the instance for its earliest pending scheduled
@@ -2528,6 +2792,7 @@ func (a *Service) finishPendingStops() {
// Failed wakes whose generation is not proven stopped hold their lease
// until it is (zero-downtime spec §7.4).
a.reapUnprovenWakesLocked()
+ a.reapStraySidecarsLocked()
for _, e := range a.entries {
// Members first (zero-downtime spec §7.4): a generation whose stop
// could not be proven is finished here once it exits.
@@ -2917,6 +3182,12 @@ func (a *Service) sweepOnce() {
if e.worker {
continue
}
+ if a.gens && e.state == actAwake && e.sidecarWanted && !e.sidecarStarting &&
+ e.sidecar != nil && !e.sidecar.Exited() && e.sidecarGen != e.gen.N {
+ // A legacy sidecar still on a retired generation's build: a
+ // replacement whose stop of it was unproven (spec §7.5).
+ go a.replaceSidecar(e)
+ }
if !e.sidecarWanted && a.backend.HasSidecar(e.route) {
e.sidecarWanted = true
log.Printf("activator: sidecar staged for awake %s — adopting", e.host)
@@ -2955,12 +3226,25 @@ func (a *Service) sweepOnce() {
e0 := e
host := e.host
route := e.route
+ gen := e.gen
+ prior := e.sidecar // exited, or nil
backoff := e.sidecarBackoff
go func() {
defer close(done)
+ if a.gens && prior != nil && !a.sidecarTerminated(route, prior) {
+ // The sidecar being restarted exited, but something it
+ // forked is left in its cgroup (spec §7.4, §7.5). It is
+ // not forgotten: it becomes a tracked stray, and the
+ // start below refuses beside it.
+ a.mu.Lock()
+ a.trackStraySidecarLocked(route, prior)
+ a.mu.Unlock()
+ }
ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
- sc, err := a.backend.StartSidecar(ctx, route)
+ sc, err := a.startSidecar(ctx, e0, route, gen, false)
cancel()
+ var discard Proc
+ defer func() { a.discardSidecar(route, discard) }() // after the unlock, before done closes
a.mu.Lock()
defer a.mu.Unlock()
if a.entries[host] != e0 {
@@ -2971,7 +3255,7 @@ func (a *Service) sweepOnce() {
// on it to update; just stop whatever came back so
// it doesn't outlive the wake it doesn't belong to,
// or clobber a fresh wake's own sidecar.
- stopDiscardedSidecar(sc)
+ discard = sc
return
}
// Still e0: this attempt owns its sidecarStarting flag
@@ -2986,7 +3270,33 @@ func (a *Service) sweepOnce() {
if e0.state != actAwake {
// Stopped mid-call, not yet forgotten: don't install
// onto a wake that's on its way out.
- stopDiscardedSidecar(sc)
+ if a.gens {
+ // stop() is joined on this goroutine (done). It
+ // stops e.sidecar after that join, synchronously,
+ // and records whether it exited: install sc so it
+ // is that stop's to prove.
+ e0.sidecar, e0.sidecarGen = sc, gen.N
+ return
+ }
+ discard = sc
+ return
+ }
+ if a.gens && e0.gen.N != gen.N {
+ // A swap switched generations while this start was in
+ // flight, so sc runs the retired generation's build
+ // (spec §7.5). It is NOT discarded asynchronously
+ // (stopDiscardedSidecar): the swap's replaceSidecar,
+ // joined on this goroutine, would then start the new
+ // sidecar while this one still ran, and a terminal
+ // stop could pass the join over it (§7.4). It is
+ // installed, so it stays tracked: replaceSidecar stops
+ // it synchronously and starts the current generation's
+ // only once it is proven gone, and a terminal stop
+ // stops it like any sidecar.
+ e0.sidecar, e0.sidecarStarted, e0.sidecarGen = sc, a.now(), gen.N
+ if err != nil {
+ e0.sidecarUncertain = true
+ }
return
}
if err != nil {
@@ -2996,11 +3306,11 @@ func (a *Service) sweepOnce() {
} else {
log.Printf("activator: SIDECAR restart for %s failed: %v — retaining returned process", host, err)
}
- e0.sidecar = sc
+ e0.sidecar, e0.sidecarGen = sc, gen.N
return
}
log.Printf("activator: sidecar for %s was down — restarted (backoff now %s)", host, backoff)
- e0.sidecar = sc
+ e0.sidecar, e0.sidecarGen = sc, gen.N
e0.sidecarStarted = a.now()
}()
}
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index 201938592..24b2bb720 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -107,6 +107,9 @@ type fakeProc struct {
// test can see the sweeper retrying a stop it hasn't been able to finish.
stuck bool
stopAttempts int
+ // stopEnters counts Stop calls ENTERED, before the gate: a test waits on
+ // it to know a gated Stop is in flight.
+ stopEnters int
// label + rec feed the backend's order log ("instance" / "sidecar"), so
// the co-spawn/co-stop tests can assert interleavings.
label string
@@ -115,6 +118,7 @@ type fakeProc struct {
func (p *fakeProc) Stop(context.Context) error {
p.mu.Lock()
+ p.stopEnters++
gate := p.stopGate
p.mu.Unlock()
if gate != nil {
@@ -172,6 +176,11 @@ func (p *fakeProc) isStopped() bool {
defer p.mu.Unlock()
return p.stopped
}
+func (p *fakeProc) stopEntered() int {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.stopEnters
+}
func (p *fakeProc) stopCalls() int {
p.mu.Lock()
defer p.mu.Unlock()
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 86ce93b63..688c29db0 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -1922,7 +1922,17 @@ func (b *ExecBackend) StartSidecar(ctx context.Context, rt registry.Route) (Proc
//
// This arm serves the LEGACY co-spawn only (see HasSidecar) — StartWorker is
// the native spawn path for a standalone registry.Route.IsSidecar row.
-func (b *ExecBackend) startSidecarChild(_ context.Context, rt registry.Route) (Proc, error) {
+func (b *ExecBackend) startSidecarChild(ctx context.Context, rt registry.Route) (Proc, error) {
+ return b.startSidecarChildFrom(ctx, rt, b.liveLink(rt.Host))
+}
+
+// startSidecarChildFrom is startSidecarChild with the binary named. That is
+// the live link for today's StartSidecar, and a generation's captured
+// staged path for StartSidecarGen (zero-downtime spec §7.5). Placement is
+// plan 2's: under GenContainment the sidecar lands in role-sidecar, with no
+// limit of its own, under the generation parent's backstop (§7.3, plan 2
+// D4). The parent holds no process and keeps memory.oom.group=0.
+func (b *ExecBackend) startSidecarChildFrom(_ context.Context, rt registry.Route, bin string) (Proc, error) {
fileEnv, err := readEnvFile(b.pathsFor(rt).sidecarEnv)
if err != nil {
return nil, fmt.Errorf("read sidecar env: %w", err)
@@ -1935,7 +1945,7 @@ func (b *ExecBackend) startSidecarChild(_ context.Context, rt registry.Route) (P
// work, and attributing it elsewhere would be a lie in the other
// direction.
cmd, settle, err := b.spawnInCgroupAt(rt, child, false, func() *exec.Cmd {
- cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run")
+ cmd := exec.Command(bin, "sidecar", "run")
cmd.Env = childEnv(fileEnv)
// Like the instance child: logs through the agent's own streams
// (journald under systemd), teed into the log sink when armed — a
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go
index cfe75f52b..99a017f45 100644
--- a/internal/activator/genbackend.go
+++ b/internal/activator/genbackend.go
@@ -7,6 +7,7 @@ import (
"log"
"github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/registry"
)
@@ -207,3 +208,91 @@ func (b *SplitBackend) GenSwitched(rt registry.Route, g Gen, p Proc) error {
}
return gb.GenSwitched(rt, g, p)
}
+
+var errNestedSidecarGen = errors.New("nested sidecar: a generation's sidecar needs Task 9's staged executable")
+
+// SidecarGenStarter starts a legacy co-spawned sidecar from a generation's
+// captured staged path (spec §7.5: "Every legacy co-spawned sidecar runs
+// its instance's serving artifact").
+type SidecarGenStarter interface {
+ StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error)
+}
+
+// StartSidecarGen execs g.StagedPath for a flat (or foreign) host. A
+// nested host's sidecar unit binds what root lays out for it, which is the
+// live link until Task 9 lets the agent name the generation's staged
+// executable. Until then the nested arm refuses. It never reads the live
+// link to decide: a repoint can land between that read and the start, and
+// after a fallback the live link names a build the instance is not serving.
+// The supervisor retries with its backoff.
+func (b *ExecBackend) StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error) {
+ p := b.pathsFor(rt)
+ switch p.layout {
+ case LayoutNested:
+ return nil, errNestedSidecarGen
+ case LayoutFenced, LayoutConflict:
+ return nil, fmt.Errorf("%s: refusing to start sidecar: %s", rt.Host, p.reason)
+ }
+ return b.startSidecarChildFrom(ctx, rt, g.StagedPath)
+}
+
+// StartSidecarGen forwards to the exec half for exec rows. A unit row keeps
+// its own StartSidecar.
+func (b *SplitBackend) StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error) {
+ if rt.Backing != registry.BackingUnit {
+ if s, ok := b.Exec.(SidecarGenStarter); ok {
+ return s.StartSidecarGen(ctx, rt, g)
+ }
+ }
+ return b.pick(rt).StartSidecar(ctx, rt)
+}
+
+// KillSidecar kills whatever is left of the host's legacy sidecar and
+// returns once its cgroup is empty (zero-downtime spec §7.4, §7.5): the
+// leader's exit says nothing of a process it forked. Flat with containment:
+// the role-sidecar child (plan 2 D4). Nested: the sidecar unit's stop, which
+// returns once systemd has emptied its cgroup (KillMode=mixed sends SIGKILL
+// to whatever SIGTERM left). Without containment the sidecar shares the
+// host cgroup with the instance, and the terminal stop's evidence on that
+// cgroup is the proof (errNoGenContainment, as KillGen answers).
+func (b *ExecBackend) KillSidecar(ctx context.Context, rt registry.Route) error {
+ switch p := b.pathsFor(rt); p.layout {
+ case LayoutNested:
+ if b.Control == nil {
+ return fmt.Errorf("%s: no instance control channel to prove the sidecar unit stopped", rt.Host)
+ }
+ return b.Control.Stop(ctx, rt.Host, instancectl.RoleSidecar)
+ case LayoutFenced, LayoutConflict:
+ return fmt.Errorf("%s: refusing to kill a sidecar: %s", rt.Host, p.reason)
+ }
+ if !b.GenContainment {
+ return errNoGenContainment
+ }
+ if b.Cgroups == nil {
+ return cgroup.ErrNoDelegation
+ }
+ c, err := b.Cgroups.OpenCleanupChild(rt.Host, cgroup.RoleChild("sidecar"))
+ if errors.Is(err, cgroup.ErrNoCgroup) {
+ return nil // removed after the leader's exit, so empty
+ }
+ if err != nil {
+ return err
+ }
+ if err := c.KillAndEmpty(ctx); err != nil {
+ _ = c.Close()
+ return err
+ }
+ return c.Close()
+}
+
+// KillSidecar forwards to the exec half for exec rows; systemd owns a unit
+// row's tree.
+func (b *SplitBackend) KillSidecar(ctx context.Context, rt registry.Route) error {
+ if rt.Backing == registry.BackingUnit {
+ return nil
+ }
+ if k, ok := b.Exec.(sidecarKiller); ok {
+ return k.KillSidecar(ctx, rt)
+ }
+ return nil
+}
diff --git a/internal/activator/swap.go b/internal/activator/swap.go
index 433752f9a..c0090be84 100644
--- a/internal/activator/swap.go
+++ b/internal/activator/swap.go
@@ -524,5 +524,7 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n
if !proven {
res.Reason = "retiring generation not yet proven stopped; the sweep finishes it"
}
+ // Step 8: a legacy co-spawned sidecar moves onto the new generation.
+ a.replaceSidecar(e)
return res
}
diff --git a/internal/activator/swap_fixture_test.go b/internal/activator/swap_fixture_test.go
index 619ed5040..80a5f71b1 100644
--- a/internal/activator/swap_fixture_test.go
+++ b/internal/activator/swap_fixture_test.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -45,6 +46,9 @@ type fakeSwapBackend struct {
// sidecarDescendant: a process a sidecar forked is still in its cgroup,
// so KillSidecar (Task 7) cannot prove it empty.
sidecarDescendant bool
+ // sidecarKills counts KillSidecar calls: a test waits on it to know a
+ // proof was attempted before it asserts nothing was released.
+ sidecarKills int
// tiers is each build's own tier (GenTier); a build not here is the
// default tier.
tiers map[string]cgroup.Limit
@@ -267,3 +271,56 @@ func indexPrefix(ev []string, p string) int {
}
return -1
}
+
+func (g *fakeSwapBackend) StartSidecarGen(_ context.Context, _ registry.Route, gen Gen) (Proc, error) {
+ g.record("startSidecarGen:" + gen.StagedPath)
+ g.gmu.Lock()
+ g.sidecarGens = append(g.sidecarGens, gen)
+ gate := g.sidecarGenGate
+ g.gmu.Unlock()
+ if gate != nil {
+ <-gate
+ }
+ g.gmu.Lock()
+ stopGate := g.sidecarStopGate
+ g.gmu.Unlock()
+ p := &fakeProc{label: "sidecar", rec: g.fakeBackend, stopGate: stopGate}
+ g.mu.Lock()
+ g.sidecars = append(g.sidecars, p)
+ g.mu.Unlock()
+ return p, nil
+}
+
+// KillSidecar is the sidecar cgroup's proof: it fails while a descendant
+// is "left" (setSidecarDescendant).
+func (g *fakeSwapBackend) KillSidecar(context.Context, registry.Route) error {
+ g.gmu.Lock()
+ defer g.gmu.Unlock()
+ g.sidecarKills++
+ if g.sidecarDescendant {
+ return errors.New("role-sidecar: still populated")
+ }
+ return nil
+}
+
+func (g *fakeSwapBackend) setSidecarDescendant(left bool) {
+ g.gmu.Lock()
+ g.sidecarDescendant = left
+ g.gmu.Unlock()
+}
+
+func (g *fakeSwapBackend) sidecarKillCount() int {
+ g.gmu.Lock()
+ defer g.gmu.Unlock()
+ return g.sidecarKills
+}
+
+func (g *fakeSwapBackend) sidecarGenPaths() []string {
+ g.gmu.Lock()
+ defer g.gmu.Unlock()
+ out := make([]string, len(g.sidecarGens))
+ for i, s := range g.sidecarGens {
+ out[i] = s.StagedPath
+ }
+ return out
+}
diff --git a/internal/activator/swap_sidecar_test.go b/internal/activator/swap_sidecar_test.go
new file mode 100644
index 000000000..2b4c79cc0
--- /dev/null
+++ b/internal/activator/swap_sidecar_test.go
@@ -0,0 +1,680 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// sidecarStartDoneOf is the host's supervised-start join channel, nil when
+// none is in flight.
+func (a *Service) sidecarStartDoneOf(host string) chan struct{} {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if e := a.entries[host]; e != nil {
+ return e.sidecarStartDone
+ }
+ return nil
+}
+
+// sidecarProc is the i'th sidecar the fake backend started.
+func (g *fakeSwapBackend) sidecarProc(i int) *fakeProc {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if i >= len(g.sidecars) {
+ return nil
+ }
+ return g.sidecars[i]
+}
+
+// sidecarStoppingIn reports whether the i'th sidecar's Stop has been
+// entered: with a stop gate on it, that Stop is in flight and blocked.
+func (g *fakeSwapBackend) sidecarStoppingIn(i int) bool {
+ p := g.sidecarProc(i)
+ return p != nil && p.stopEntered() > 0
+}
+
+// generationSwitched is true once the entry's current generation is not the
+// fixture's incumbent.
+func (f *swapFixture) generationSwitched() bool {
+ g, _ := f.a.genStateForTest(f.host)
+ return g.N != f.incumbent.N
+}
+
+// Flag off: every legacy sidecar spawn (the wake tail and the supervisor)
+// goes through StartSidecar and the live link, as today.
+func TestLegacySidecarUsesTheLiveLinkWithSwapOff(t *testing.T) {
+ f := newSwapFixture(t)
+ f.a.gens = false
+ f.b.sidecarStaged = true
+ f.a.Bounce(f.host)
+ waitFor(t, "the stop", func() bool { return f.a.entryCount() == 0 })
+ arrangeAwake(t, f.a, testRoute(f.host))
+ if got := f.b.sidecarStartCount(); got != 1 {
+ t.Fatalf("StartSidecar calls = %d after the wake, want 1", got)
+ }
+ f.b.lastSidecar().kill()
+ f.clk.advance(3 * time.Second)
+ f.a.sweepOnce()
+ waitFor(t, "the supervised restart", func() bool { return f.a.sidecarSettled(f.host) && f.b.sidecarStartCount() == 2 })
+ if paths := f.b.sidecarGenPaths(); len(paths) != 0 {
+ t.Fatalf("StartSidecarGen ran with swaps off: %v", paths)
+ }
+}
+
+// Flag off, ownership: startSidecar asks ownedLocked whatever the flag (an
+// accepted dormancy exception, recorded in task-7-adapt.md). Exactly what
+// changes against today, called directly so no earlier sweep check masks it:
+// a host that does not own itself (fenced, shutting down, lease lost, no
+// longer the host's entry) gets NO co-spawned sidecar, where today's
+// StartSidecar would have spawned one. A host that owns itself gets the
+// live-link sidecar exactly as before, and StartSidecarGen is never used.
+func TestLegacySidecarOwnershipCheckRunsWithSwapOff(t *testing.T) {
+ f := newSwapFixture(t)
+ f.a.gens = false
+ f.b.sidecarStaged = true
+ f.a.mu.Lock()
+ e := f.a.entries[f.host]
+ rt := e.route
+ f.a.mu.Unlock()
+ rep := f.b.lastRep()
+
+ // Owned: today's spawn, through the live link.
+ sc, err := f.a.startSidecar(context.Background(), e, rt, f.incumbent, false)
+ if err != nil || sc == nil {
+ t.Fatalf("an owned host's sidecar = %v, %v; want the live-link spawn", sc, err)
+ }
+ if got := f.b.sidecarStartCount(); got != 1 {
+ t.Fatalf("StartSidecar calls = %d, want 1", got)
+ }
+ if paths := f.b.sidecarGenPaths(); len(paths) != 0 {
+ t.Fatalf("StartSidecarGen ran with swaps off: %v", paths)
+ }
+
+ refused := func(name string, arm, disarm func(), want error) {
+ t.Helper()
+ arm()
+ sc, err := f.a.startSidecar(context.Background(), e, rt, f.incumbent, false)
+ disarm()
+ if !errors.Is(err, want) || sc != nil {
+ t.Fatalf("%s: startSidecar = %v, %v; want %v and no process", name, sc, err, want)
+ }
+ if got := f.b.sidecarStartCount(); got != 1 {
+ t.Fatalf("%s: StartSidecar calls = %d, want 1 (none beside a host that does not own itself)", name, got)
+ }
+ }
+ refused("fenced",
+ func() { f.a.mu.Lock(); f.a.fences = map[string]uint64{f.host: 1}; f.a.mu.Unlock() },
+ func() { f.a.mu.Lock(); f.a.fences = nil; f.a.mu.Unlock() }, errSwapFenced)
+ refused("shutting down",
+ func() { f.a.mu.Lock(); f.a.shuttingDown = true; f.a.mu.Unlock() },
+ func() { f.a.mu.Lock(); f.a.shuttingDown = false; f.a.mu.Unlock() }, errSwapShutdown)
+ refused("lease lost",
+ func() { rep.setErr(errors.New("lease lost")) },
+ func() { rep.setErr(nil) }, errSwapLeaseLost)
+ refused("not the host's entry",
+ func() { f.a.mu.Lock(); delete(f.a.entries, f.host); f.a.mu.Unlock() },
+ func() { f.a.mu.Lock(); f.a.entries[f.host] = e; f.a.mu.Unlock() }, errSwapNotAwake)
+
+ // And once owned again, the same call spawns as before.
+ if _, err := f.a.startSidecar(context.Background(), e, rt, f.incumbent, false); err != nil {
+ t.Fatalf("owned again: %v", err)
+ }
+ if got := f.b.sidecarStartCount(); got != 2 {
+ t.Fatalf("StartSidecar calls = %d, want 2", got)
+ }
+}
+
+// Flag on: late attachment, the supervisor, a swap's replacement and the
+// wake tail all exec the CURRENT generation's captured staged path (spec
+// §7.5). The live link names v3 throughout and is never used.
+func TestLegacySidecarRunsTheServingGeneration(t *testing.T) {
+ t.Run("late attachment and the supervisor", func(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.execVerFn = func() string { return "v3" }
+ f.b.sidecarStaged = true // staged after the wake: the sweep attaches it
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ f.b.lastSidecar().kill()
+ f.clk.advance(3 * time.Second)
+ f.a.sweepOnce()
+ waitFor(t, "the supervised restart", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 2 })
+ for _, p := range f.b.sidecarGenPaths() {
+ if p != f.incumbent.StagedPath {
+ t.Fatalf("a sidecar ran %q, want the serving generation's %q", p, f.incumbent.StagedPath)
+ }
+ }
+ if got := f.b.sidecarStartCount(); got != 0 {
+ t.Fatalf("StartSidecar (live link) ran %d time(s) with swaps on", got)
+ }
+ })
+ t.Run("a swap replaces the sidecar stop-then-start", func(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ old := f.b.lastSidecar()
+ if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapDone {
+ t.Fatalf("Swap = %+v", res)
+ }
+ paths := f.b.sidecarGenPaths()
+ if len(paths) != 2 || paths[1] != f.staged {
+ t.Fatalf("sidecar spawns %v: the replacement must run the new generation's %q", paths, f.staged)
+ }
+ if !old.isStopped() {
+ t.Fatal("the old sidecar outlived the replacement")
+ }
+ ev := f.b.events()
+ if i, j := slices.Index(ev, "stop:sidecar"), slices.Index(ev, "startSidecarGen:"+f.staged); i < 0 || j < 0 || i > j {
+ t.Fatalf("order %v: stop the old sidecar, then start the new one", ev)
+ }
+ })
+ t.Run("the wake tail of a hold", func(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapDone {
+ t.Fatalf("Swap = %+v", res)
+ }
+ if paths := f.b.sidecarGenPaths(); len(paths) == 0 || paths[len(paths)-1] != f.staged {
+ t.Fatalf("the hold's wake tail started sidecars %v, want %q last", paths, f.staged)
+ }
+ })
+}
+
+// The swap's replacement holds the supervisor's interlock, so a supervised
+// restart that falls due meanwhile cannot start a second sidecar (review
+// focus 4).
+func TestSwapSidecarReplacementHoldsTheSupervisorOff(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ gate := make(chan struct{})
+ f.b.gmu.Lock()
+ f.b.sidecarGenGate = gate
+ f.b.gmu.Unlock()
+ results := make(chan SwapResult, 1)
+ go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
+ waitFor(t, "the replacement's start", func() bool { return len(f.b.sidecarGenPaths()) == 2 })
+ held := f.a.sidecarStartDoneOf(f.host)
+ if held == nil {
+ t.Fatal("the replacement holds no interlock")
+ }
+ f.clk.advance(time.Minute)
+ f.a.sweepOnce() // the old sidecar is stopped (exited): the supervisor would restart it
+ // The supervisor claims sidecarStarting under the sweep's own lock, so a
+ // claim would have replaced the join channel before sweepOnce returned.
+ if now := f.a.sidecarStartDoneOf(f.host); now != held {
+ t.Fatal("a supervised restart claimed the interlock during the replacement")
+ }
+ if n := len(f.b.sidecarGenPaths()); n != 2 {
+ t.Fatalf("sidecar spawns = %d during the replacement, want 2", n)
+ }
+ close(gate)
+ if res := <-results; res.Outcome != SwapDone {
+ t.Fatalf("Swap = %+v", res)
+ }
+}
+
+// A supervised restart already in flight when the swap switches captured the
+// OLD generation. The swap's replacement joins it and then replaces what it
+// installed, so the new server never runs beside the old build's sidecar
+// (spec §7.5). Returning early because sidecarStarting is set (the old
+// replaceSidecar) leaves the supervisor's old-build sidecar installed with
+// no replacement pending: the third spawn never comes.
+func TestSwapSidecarReplacementJoinsAnInFlightSupervisor(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ gate := make(chan struct{})
+ f.b.gmu.Lock()
+ f.b.sidecarGenGate = gate
+ f.b.gmu.Unlock()
+ f.b.lastSidecar().kill()
+ f.clk.advance(3 * time.Second)
+ f.a.sweepOnce() // the supervisor starts a sidecar for the incumbent and blocks
+ waitFor(t, "the supervisor's start", func() bool { return len(f.b.sidecarGenPaths()) == 2 })
+ results := make(chan SwapResult, 1)
+ go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
+ waitFor(t, "the switch", f.generationSwitched)
+ close(gate)
+ if res := <-results; res.Outcome != SwapDone {
+ t.Fatalf("Swap = %+v", res)
+ }
+ waitFor(t, "the replacement onto the new generation", func() bool {
+ p := f.b.sidecarGenPaths()
+ return len(p) == 3 && f.a.sidecarSettled(f.host)
+ })
+ paths := f.b.sidecarGenPaths()
+ if paths[1] != f.incumbent.StagedPath || paths[2] != f.staged {
+ t.Fatalf("sidecar spawns %v: want the supervisor's on %q, then the replacement on %q", paths, f.incumbent.StagedPath, f.staged)
+ }
+ // The supervisor's sidecar is stopped by the replacement's
+ // stop-then-start.
+ f.b.mu.Lock()
+ supervised, current := f.b.sidecars[1], f.b.sidecars[len(f.b.sidecars)-1]
+ f.b.mu.Unlock()
+ if !supervised.isStopped() {
+ t.Fatal("the supervisor's old-build sidecar outlived the swap")
+ }
+ f.a.mu.Lock()
+ installed := f.a.entries[f.host].sidecar
+ f.a.mu.Unlock()
+ if installed != Proc(current) {
+ t.Fatal("the installed sidecar is not the replacement")
+ }
+}
+
+// The supervisor's start for the retired generation is joined until that
+// process is proven gone (spec §7.4, §7.5): the replacement never starts
+// while it still runs. Discarding it asynchronously let the supervisor
+// close its join at once, so the replacement started beside a sidecar
+// still draining on the old build.
+func TestSwapSidecarNeverRunsBesideTheRetiredGenerationsSidecar(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ startGate, stopGate := make(chan struct{}), make(chan struct{})
+ f.b.gmu.Lock()
+ f.b.sidecarGenGate, f.b.sidecarStopGate = startGate, stopGate
+ f.b.gmu.Unlock()
+ f.b.lastSidecar().kill()
+ f.clk.advance(3 * time.Second)
+ f.a.sweepOnce() // the supervisor starts the incumbent's sidecar and blocks
+ waitFor(t, "the supervisor's start", func() bool { return len(f.b.sidecarGenPaths()) == 2 })
+ results := make(chan SwapResult, 1)
+ go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
+ waitFor(t, "the switch", f.generationSwitched)
+ close(startGate) // the supervisor's sidecar starts; its Stop blocks on stopGate
+ // The retired generation's sidecar is being stopped, and the replacement
+ // is sequenced behind that Stop: nothing can have started beside it.
+ waitFor(t, "the retired sidecar's stop to be in flight", func() bool { return f.b.sidecarStoppingIn(1) })
+ if n := len(f.b.sidecarGenPaths()); n != 2 {
+ t.Fatalf("%d sidecar starts: the replacement started while the retired generation's sidecar still ran", n)
+ }
+ select {
+ case res := <-results:
+ t.Fatalf("the swap returned (%+v) before the retired sidecar was proven gone", res)
+ default:
+ }
+ close(stopGate)
+ if res := <-results; res.Outcome != SwapDone {
+ t.Fatalf("Swap = %+v", res)
+ }
+ waitFor(t, "the replacement", func() bool { return len(f.b.sidecarGenPaths()) == 3 })
+ if p := f.b.sidecarGenPaths(); p[2] != f.staged {
+ t.Fatalf("sidecar starts %v: want the new generation's last", p)
+ }
+}
+
+// A terminal stop that begins while a supervised sidecar start is in flight
+// joins that start, and the sidecar it produced, before the lease is
+// released (spec §7.4). Discarding it asynchronously let the supervisor
+// close its join at once, so the stop released the lease while the new
+// sidecar was still running.
+func TestSwapStopJoinsTheSidecarAStartLeftBehind(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ startGate, stopGate := make(chan struct{}), make(chan struct{})
+ f.b.gmu.Lock()
+ f.b.sidecarGenGate, f.b.sidecarStopGate = startGate, stopGate
+ f.b.gmu.Unlock()
+ f.b.lastSidecar().kill()
+ f.clk.advance(3 * time.Second)
+ f.a.sweepOnce() // the supervisor's start blocks
+ waitFor(t, "the supervisor's start", func() bool { return len(f.b.sidecarGenPaths()) == 2 })
+ rep := f.b.lastRep()
+ f.a.Bounce(f.host)
+ // The stop is joined on the supervisor's start: it goes no further (no
+ // instance stop, so no lease release) while the start is in flight. Only
+ // a bounded look can show an absence, so it fails the moment the instance
+ // is stopped.
+ for range 40 {
+ if slices.Contains(f.b.events(), "stop:instance") {
+ t.Fatal("the stop went past its join on the in-flight sidecar start")
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ close(startGate) // the start lands on a stopping entry; its sidecar's Stop blocks
+ waitFor(t, "the left-behind sidecar's stop to be in flight", func() bool { return f.b.sidecarStoppingIn(1) })
+ if rep.isClosed() {
+ t.Fatal("the lease was released while the sidecar a start left behind still ran")
+ }
+ close(stopGate)
+ waitFor(t, "the lease release", rep.isClosed)
+ if started := f.b.sidecarProc(1); !started.isStopped() {
+ t.Fatal("the sidecar the start left behind was never stopped")
+ }
+}
+
+// Spec §7.4: a discarded sidecar whose stop is unproven is never forgotten.
+// It stays tracked, the host takes no new wake beside it, and the sweep
+// stops it again until it exits. Logging it and moving on let the next wake
+// run a second sidecar beside a live one.
+func TestAStraySidecarIsNeverForgotten(t *testing.T) {
+ f := newSwapFixture(t)
+ f.a.Bounce(f.host)
+ waitFor(t, "the instance asleep", func() bool { return f.a.entryCount() == 0 })
+ stray := &fakeProc{label: "sidecar", rec: f.b.fakeBackend, stuck: true}
+ f.a.discardSidecar(testRoute(f.host), stray)
+ if got := f.a.strayCount(f.host); got != 1 {
+ t.Fatalf("tracked strays = %d, want 1", got)
+ }
+ if _, err := f.a.beginRequest(context.Background(), testRoute(f.host)); err == nil || !strings.Contains(err.Error(), errWakeUnproven.Error()) {
+ t.Fatalf("a wake beside an unproven sidecar = %v, want refused", err)
+ }
+ stray.exit()
+ f.a.sweepOnce() // the reaper re-proves it off the lock
+ waitFor(t, "the stray proven gone", func() bool { return f.a.strayCount(f.host) == 0 })
+ release, err := f.a.beginRequest(context.Background(), testRoute(f.host))
+ if err != nil {
+ t.Fatalf("the wake once the stray exited: %v", err)
+ }
+ release()
+}
+
+func (a *Service) strayCount(host string) int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return len(a.straySidecars[host])
+}
+
+// strayReaping is true while any stray of host has a re-proof in flight.
+func (a *Service) strayReaping(host string) bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ for _, s := range a.straySidecars[host] {
+ if s.reaping {
+ return true
+ }
+ }
+ return false
+}
+
+func (a *Service) stopPending(host string) bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ e := a.entries[host]
+ return e != nil && e.pendingStop
+}
+
+// Spec §7.4, §7.5: a legacy sidecar is gone only when its cgroup is empty.
+// childProc.Stop signals only the leader, so a process the sidecar forked
+// outlives its exit in role-sidecar. Here the leader exits but a descendant
+// stays (KillSidecar cannot empty the cgroup): the replacement does not
+// start beside it, and a terminal stop keeps the lease until it is proven
+// gone. Accepting Exited() started the new sidecar beside the descendant
+// and released the lease under it.
+func TestASidecarsDescendantBlocksItsReplacementAndTheLease(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ f.b.setSidecarDescendant(true)
+ if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapDone {
+ t.Fatalf("Swap = %+v", res)
+ }
+ if got := f.a.strayCount(f.host); got != 1 {
+ t.Fatalf("tracked strays = %d after the replacement, want 1 (the old sidecar's descendant)", got)
+ }
+ // A supervisor pass that falls due must not start one either. It runs
+ // startSidecar, which refuses while the stray is tracked.
+ f.clk.advance(time.Minute)
+ f.a.sweepOnce()
+ waitFor(t, "the supervisor's refused start", func() bool { return f.a.sidecarSettled(f.host) })
+ if n := len(f.b.sidecarGenPaths()); n != 1 {
+ t.Fatalf("%d sidecar starts: a new sidecar started beside the old one's descendant", n)
+ }
+ rep := f.b.lastRep()
+ f.a.Bounce(f.host)
+ waitFor(t, "the stop to wait on the sidecar's cgroup", func() bool { return f.a.stopPending(f.host) })
+ // Each sweep re-proves the stray; the descendant is still there.
+ for range 2 {
+ before := f.b.sidecarKillCount()
+ f.a.sweepOnce()
+ waitFor(t, "a proof attempt", func() bool { return f.b.sidecarKillCount() > before })
+ waitFor(t, "the re-proof to finish", func() bool { return !f.a.strayReaping(f.host) })
+ if rep.isClosed() {
+ t.Fatal("the lease was released while the sidecar's descendant still ran")
+ }
+ }
+ if got := f.a.strayCount(f.host); got != 1 {
+ t.Fatalf("tracked strays = %d with the descendant alive, want 1", got)
+ }
+ f.b.setSidecarDescendant(false)
+ waitFor(t, "the lease release once the cgroup is empty", func() bool {
+ f.a.sweepOnce()
+ return rep.isClosed()
+ })
+}
+
+// A terminal stop's co-stop is held to the same proof: the sidecar's leader
+// exits but a process it forked is left in the cgroup, so the stop keeps the
+// lease (releasable) until the sweep proves the cgroup empty.
+func TestATerminalStopKeepsTheLeaseOverASidecarsDescendant(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ f.b.setSidecarDescendant(true)
+ rep := f.b.lastRep()
+ f.a.Bounce(f.host)
+ waitFor(t, "the stop to wait on the sidecar's cgroup", func() bool { return f.a.stopPending(f.host) })
+ if got := f.a.strayCount(f.host); got != 1 {
+ t.Fatalf("tracked strays = %d after the co-stop, want 1", got)
+ }
+ before := f.b.sidecarKillCount()
+ f.a.sweepOnce()
+ waitFor(t, "a proof attempt", func() bool { return f.b.sidecarKillCount() > before })
+ waitFor(t, "the re-proof to finish", func() bool { return !f.a.strayReaping(f.host) })
+ if rep.isClosed() {
+ t.Fatal("the lease was released while the sidecar's descendant still ran")
+ }
+ f.b.setSidecarDescendant(false)
+ waitFor(t, "the lease release once the cgroup is empty", func() bool {
+ f.a.sweepOnce()
+ return rep.isClosed()
+ })
+}
+
+// A supervised restart of a sidecar that exited on its own is a replacement
+// too: if something it forked is left in the cgroup, the old one becomes a
+// tracked stray and no new sidecar starts beside it.
+func TestASupervisedRestartWaitsForTheCrashedSidecarsDescendant(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ f.b.setSidecarDescendant(true)
+ f.b.lastSidecar().kill()
+ f.clk.advance(3 * time.Second)
+ f.a.sweepOnce()
+ waitFor(t, "the refused restart", func() bool { return f.a.sidecarSettled(f.host) && f.a.strayCount(f.host) == 1 })
+ if n := len(f.b.sidecarGenPaths()); n != 1 {
+ t.Fatalf("%d sidecar starts: a restart ran beside the crashed sidecar's descendant", n)
+ }
+ f.b.setSidecarDescendant(false)
+ waitFor(t, "the restart once the cgroup is empty", func() bool {
+ f.clk.advance(time.Minute)
+ f.a.sweepOnce()
+ return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 2
+ })
+ if got := f.a.strayCount(f.host); got != 0 {
+ t.Fatalf("tracked strays = %d after the proof, want 0", got)
+ }
+}
+
+// A legacy sidecar left on a retired generation's build (a replacement whose
+// stop of it was unproven) is replaced by the sweep's retry.
+func TestSweepReplacesASidecarLeftOnARetiredGeneration(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ old := f.b.lastSidecar()
+ f.a.mu.Lock()
+ f.a.entries[f.host].sidecarGen = f.incumbent.N - 1 // a build the entry no longer serves
+ f.a.mu.Unlock()
+ f.a.sweepOnce()
+ waitFor(t, "the replacement", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 2 })
+ if !old.isStopped() {
+ t.Fatal("the retired generation's sidecar was not stopped")
+ }
+ f.a.mu.Lock()
+ got := f.a.entries[f.host].sidecarGen
+ f.a.mu.Unlock()
+ if got != f.incumbent.N {
+ t.Fatalf("sidecarGen = %d, want the current generation %d", got, f.incumbent.N)
+ }
+}
+
+// Under plan 2's containment the sidecar keeps its role-sidecar child: no
+// limit of its own, and the generation parent keeps memory.oom.group=0 and
+// holds no process (spec §7.3, plan 2 D4). Spawning through spawnInCgroup
+// instead writes the tier and memory.oom.group=1 onto the parent and makes
+// no role-sidecar child.
+func TestStartSidecarGenKeepsTheRoleSidecarChild(t *testing.T) {
+ const host = "sc.example.com"
+ b, parent := delegatedBackend(t, host)
+ b.GenContainment = true
+ v2 := filepath.Join(b.OptDir, "versions", "v2", "app")
+ if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(v2, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(b.DataDir, host+"-sidecar.env"), []byte("X=1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(b.DataDir, host),
+ Socket: filepath.Join(provision.SocketDir, host+".sock")}
+ p, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: "v2", StagedPath: v2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = p.Stop(context.Background())
+ if fi, err := os.Stat(filepath.Join(parent, "role-sidecar")); err != nil || !fi.IsDir() {
+ t.Fatalf("no role-sidecar child: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(parent, "role-sidecar", "memory.max")); !os.IsNotExist(err) {
+ t.Errorf("the sidecar's child got a limit of its own: %v", err)
+ }
+ if got, err := os.ReadFile(filepath.Join(parent, "memory.oom.group")); err != nil || string(got) != "0" {
+ t.Errorf("parent memory.oom.group = %q, %v; want 0", got, err)
+ }
+}
+
+// ExecBackend: a flat sidecar execs the generation's staged path. A nested
+// sidecar is refused until Task 9 hands root the staged executable: it never
+// reads the live link to decide, because a repoint can land between that
+// read and the start.
+func TestExecBackendStartSidecarGen(t *testing.T) {
+ stage := func(t *testing.T, b *ExecBackend, host string) (v2, marker string) {
+ t.Helper()
+ marker = filepath.Join(t.TempDir(), "argv0")
+ v2 = filepath.Join(b.OptDir, "versions", "v2", "app")
+ if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(v2, []byte("#!/bin/sh\nprintf '%s' \"$0\" > '"+marker+"'\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(b.DataDir, host+"-sidecar.env"), []byte("X=1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return v2, marker
+ }
+ t.Run("flat", func(t *testing.T) {
+ b := testExecBackend(t)
+ host := "sc.example.com"
+ stageLive(t, b, host, "v1")
+ v2, marker := stage(t, b, host)
+ // The default db and socket: Classify puts this route in the flat layout.
+ rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(b.DataDir, host),
+ Socket: filepath.Join(provision.SocketDir, host+".sock")}
+ if l, why := b.Layout(rt); l != LayoutFlat {
+ t.Fatalf("route classified %v (%s), want the flat layout", l, why)
+ }
+ p, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: "v2", StagedPath: v2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer p.Stop(context.Background()) //nolint:errcheck
+ if got := waitForFile(t, marker, 2*time.Second); got != v2 {
+ t.Fatalf("the sidecar execd %q, want the generation's %q", got, v2)
+ }
+ })
+ t.Run("foreign", func(t *testing.T) {
+ b := testExecBackend(t)
+ host := "fsc.example.com"
+ stageLive(t, b, host, "v1")
+ v2, marker := stage(t, b, host)
+ rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "s.sock")}
+ if l, _ := b.Layout(rt); l != LayoutForeign {
+ t.Fatalf("route classified %v, want the foreign layout", l)
+ }
+ p, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: "v2", StagedPath: v2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer p.Stop(context.Background()) //nolint:errcheck
+ if got := waitForFile(t, marker, 2*time.Second); got != v2 {
+ t.Fatalf("the sidecar execd %q, want the generation's %q", got, v2)
+ }
+ })
+ t.Run("nested", func(t *testing.T) {
+ b := testExecBackend(t)
+ rd := t.TempDir()
+ b.RunDirOverride = rd
+ host := "nsc.example.com"
+ stageLive(t, b, host, "v1")
+ rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.NestedDBPath(b.DataDir, host),
+ Socket: filepath.Join(rd, "instances", provision.InstanceID(host), "app.sock")}
+ for _, build := range []string{"v1", "v2"} { // the live build and another
+ if _, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: build}); !errors.Is(err, errNestedSidecarGen) {
+ t.Fatalf("serving %s: %v, want errNestedSidecarGen until Task 9", build, err)
+ }
+ }
+ })
+}
+
+// KillSidecar: the nested arm asks the sidecar unit's stop, and with no
+// Control wired it answers an error, never a nil dereference. Flat without
+// containment has no cgroup of its own to prove: errNoGenContainment.
+func TestExecBackendKillSidecar(t *testing.T) {
+ t.Run("nested without a control channel", func(t *testing.T) {
+ b := testExecBackend(t)
+ rd := t.TempDir()
+ b.RunDirOverride = rd
+ host := "nsc.example.com"
+ rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.NestedDBPath(b.DataDir, host),
+ Socket: filepath.Join(rd, "instances", provision.InstanceID(host), "app.sock")}
+ if err := b.KillSidecar(context.Background(), rt); err == nil {
+ t.Fatal("KillSidecar with no Control proved a nested sidecar gone")
+ }
+ })
+ t.Run("flat without containment", func(t *testing.T) {
+ b := testExecBackend(t)
+ host := "sc.example.com"
+ rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(b.DataDir, host),
+ Socket: filepath.Join(provision.SocketDir, host+".sock")}
+ if err := b.KillSidecar(context.Background(), rt); !errors.Is(err, errNoGenContainment) {
+ t.Fatalf("KillSidecar = %v, want errNoGenContainment", err)
+ }
+ })
+}
+
+var _ SidecarGenStarter = (*SplitBackend)(nil)