zdd plan 3 task 11: worker children are numbered generations; a respawn joins the operation set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+363
−7
internal/activator/activator.go+77 −7internal/activator/genbackend.go+36 −0internal/activator/opset.go+22 −0internal/activator/worker_gen_test.go+228 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 39a404c18..f009447a3 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -1254,6 +1254,13 @@ func (a *Service) wake(e *actEntry) { |
| if e.gen.N == 0 && !e.worker { |
| e.gen = canonicalGen(e.route, nextGen(), w.spawnVersion) |
| } |
| + if a.gens && e.worker && e.gen.N == 0 { |
| + // A worker woken some way that carried no number (doWake gives |
| + // every worker spawn its own): numbered here, with the build it |
| + // runs, so the barrier and the generations file see it. Worker: |
| + // its termination is proven on its host cgroup (Task 11). |
| + e.gen = Gen{N: nextGen(), Build: w.spawnVersion, Worker: true} |
| + } |
| if e.gen.N != 0 { |
| a.noteCurrent(e.host, e.gen.N) |
| } |
| @@ -1592,6 +1599,14 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro |
| a.mu.Unlock() |
| _ = a.persistGenerations() |
| } |
| + if a.gens { |
| + // The wake's generation, whether or not the worker becomes |
| + // ready (zero-downtime spec §7.4): a worker binds nothing, but |
| + // it is numbered and carries the build it execed, and |
| + // cleanupFailedWake proves its host cgroup before the lease |
| + // goes back (Worker, genChildEmpty). |
| + w.gen = Gen{N: nextGen(), Build: spawnVer, Worker: true} |
| + } |
| } else if e.pin != nil { // swapsim:M3 |
| var g Gen |
| proc, spawnVer, g, err = a.startPinnedChecked(ctx, e, rt, *e.pin) |
| @@ -2333,6 +2348,17 @@ func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string, ont |
| e.workerRespawning = true |
| e.respawnDone = make(chan struct{}) |
| done, rt, host := e.respawnDone, e.route, e.host |
| + // With generations on, the respawn is a member of the operation set |
| + // until it has finished with every child it made: a stop's barrier joins |
| + // it, and cancelling it tears down the exec (zero-downtime plan 3, |
| + // Task 11). Off, m stays nil and spawnParent is just lifeCtx's child. |
| + spawnParent, cancelSpawn := context.WithCancel(a.lifeCtx) |
| + var m *genMember |
| + oldGen := e.gen // the retiring child's generation, for its proof |
| + if a.gens { |
| + m = &genMember{gen: Gen{N: nextGen(), Worker: true}, role: roleSpawning, cancel: cancelSpawn, done: make(chan struct{})} |
| + a.addMemberLocked(e, m) |
| + } |
| go func() { |
| // respawnDone closes LAST, once this goroutine is finished with every |
| // child it created — including one it had to SHED. That ordering is |
| @@ -2343,6 +2369,15 @@ func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string, ont |
| // shed (as this first did) only narrowed that race; it did not close |
| // it. |
| defer close(done) |
| + defer cancelSpawn() |
| + shed := false // a shed child went to dropMember, which leaves the set itself |
| + if m != nil { |
| + defer func() { // runs before close(done): defers are LIFO |
| + if !shed { |
| + a.leaveSet(e, m) |
| + } |
| + }() |
| + } |
| var ( |
| proc Proc |
| ident release.StagedIdentity |
| @@ -2364,17 +2399,36 @@ func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string, ont |
| cancel() |
| } |
| + // The retired child must be proven gone, its host cgroup included, |
| + // before anything new execs beside its descendants (zero-downtime |
| + // spec §7.4, §7.9). Unproven: nothing spawns; the entry keeps its |
| + // lease, and the sweep's crash path retires it again through |
| + // genTerminated. stopOld may be nil (a crash restart, a restore): |
| + // the old child is gone, but what it forked lives in the host |
| + // cgroup, and genTerminated proves a Worker generation there with |
| + // or without a handle. |
| + retired := true |
| + if a.gens { |
| + oldGen.Worker = true |
| + retired = a.genTerminated(rt, oldGen, stopOld) |
| + } |
| + |
| // Still wanted? Asked HERE, before anything is spawned: past this |
| - // point a stop can only be raced, never prevented. |
| + // point a stop can only be raced, never prevented. The gate's |
| + // ownership check (spec §7.4): every exec asks it, whatever the |
| + // switch says. It covers the entry still being the host's, awake |
| + // and not shutting down, and adds the lease still held and no |
| + // fence. |
| a.mu.Lock() |
| - wanted := a.entries[host] == e && e.state == actAwake && !a.shuttingDown |
| + owned := a.ownedLocked(e, false) == nil |
| a.mu.Unlock() |
| + wanted := owned && retired |
| if wanted { |
| // Off the service lifetime, so Shutdown cancels this exec instead |
| // of blocking on it. The timeout is the slow-materialize bound; |
| // the parent is the "are we still here at all" one. |
| - ctx, cancel := context.WithTimeout(a.lifeCtx, workerSpawnTimeout) |
| + ctx, cancel := context.WithTimeout(spawnParent, workerSpawnTimeout) |
| if g, ok := a.backend.(configAdmitter); ok { |
| refused = g.AdmitConfig(ctx, rt) |
| } |
| @@ -2391,15 +2445,28 @@ func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string, ont |
| e.workerRespawning = false |
| live := a.entries[host] == e && e.state == actAwake && !a.shuttingDown |
| switch { |
| + case owned && !retired && live: |
| + a.mu.Unlock() |
| + log.Printf("activator: worker %s NOT respawned (%s): the old generation is not proven terminated (its host cgroup still holds a process), so nothing execs beside it — the sweep retries", host, why) |
| case !wanted || !live: |
| a.mu.Unlock() |
| log.Printf("activator: worker %s stopped mid-respawn (%s) — abandoning it", host, why) |
| if proc != nil { |
| // Spawned into a window that closed under us: shed it rather |
| - // than leave a child nothing owns. |
| - shedCtx, shedCancel := context.WithTimeout(context.Background(), sidecarStopTimeout) |
| - _ = proc.Stop(shedCtx) |
| - shedCancel() |
| + // than leave a child nothing owns. With generations on it is |
| + // the member's process: dropMember stops it and proves its |
| + // host cgroup empty, or keeps it in the set for the reaper. |
| + if m != nil { |
| + a.mu.Lock() |
| + m.proc = proc |
| + a.mu.Unlock() |
| + shed = true |
| + a.dropMember(e, m) |
| + } else { |
| + shedCtx, shedCancel := context.WithTimeout(context.Background(), sidecarStopTimeout) |
| + _ = proc.Stop(shedCtx) |
| + shedCancel() |
| + } |
| } |
| if pend != 0 { |
| // Its published build stays listed until the child is shed. |
| @@ -2433,6 +2500,9 @@ func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string, ont |
| e.spawnVersion = ident.Version |
| e.workerServing = ident |
| e.workerVersion = "" // the new child has not reported yet |
| + if m != nil { |
| + e.gen = Gen{N: m.gen.N, Build: ident.Version, Worker: true} |
| + } |
| // The captured build replaces the one published before the exec. |
| a.dropPendingGenerationLocked(pend) |
| // Only an exec from the live link starts a fresh drift clock. A |
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go| index 96d4a55a3..4c2c6c237 100644 |
| --- a/internal/activator/genbackend.go |
| +++ b/internal/activator/genbackend.go |
| @@ -358,3 +358,39 @@ func (b *SplitBackend) GenPaths(rt registry.Route, n uint64) (Gen, bool) { |
| } |
| return Gen{}, false |
| } |
| + |
| +// KillHost kills whatever is left in rt's own host cgroup and returns once |
| +// it is empty: a worker's termination proof (zero-downtime spec §7.4, §7.9). |
| +// It does not look at the row's layout: StartWorker spawns every worker |
| +// through spawnInCgroup into carlos-exec@<host>, whatever the layout says, |
| +// so a nested-looking worker is proven there too. An absent cgroup is |
| +// empty. No cgroup delegation at all is an unproven kill. |
| +func (b *ExecBackend) KillHost(ctx context.Context, rt registry.Route) error { |
| + if b.Cgroups == nil { |
| + return cgroup.ErrNoDelegation |
| + } |
| + c, err := b.Cgroups.OpenCleanup(rt.Host) |
| + if errors.Is(err, cgroup.ErrNoCgroup) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if err := c.KillAndEmpty(ctx); err != nil { |
| + _ = c.Close() |
| + return err |
| + } |
| + return c.Close() |
| +} |
| + |
| +// KillHost dispatches like Start: a unit-backed row's one process tree is |
| +// systemd's, so there is no host cgroup of ours to kill. |
| +func (b *SplitBackend) KillHost(ctx context.Context, rt registry.Route) error { |
| + if rt.Backing == registry.BackingUnit { |
| + return nil // systemd owns the unit tenant's tree |
| + } |
| + if k, ok := b.Exec.(hostKiller); ok { |
| + return k.KillHost(ctx, rt) |
| + } |
| + return nil |
| +} |
diff --git a/internal/activator/opset.go b/internal/activator/opset.go| index 7eda235a5..3688b2ac5 100644 |
| --- a/internal/activator/opset.go |
| +++ b/internal/activator/opset.go |
| @@ -127,6 +127,13 @@ type genKiller interface { |
| KillGen(ctx context.Context, rt registry.Route, n uint64) error |
| } |
| +// hostKiller is the exec backend's host-cgroup kill: cgroup.kill on host's |
| +// own cgroup, then wait until it is empty. It proves a worker generation |
| +// terminated (plan 2 D4 keeps workers there). |
| +type hostKiller interface { |
| + KillHost(ctx context.Context, rt registry.Route) error |
| +} |
| + |
| // stopMember stops m's process, bounded by actStopTimeout: SIGTERM, the |
| // generation's own graceful drain, then SIGKILL (spec §7.5 step 6). It |
| // reports whether termination is proven, which takes two things: the |
| @@ -222,6 +229,21 @@ func (a *Service) genChildEmpty(rt registry.Route, m *genMember) bool { |
| defer cancel() |
| return cp.provenStopped(ctx) |
| } |
| + if m.gen.Worker { |
| + // A worker runs in its host cgroup (plan 2 D4): that cgroup, not a |
| + // g<n> it never ran in, is what must be empty (spec §7.4, §7.9). |
| + hk, ok := a.backend.(hostKiller) |
| + if !ok { |
| + return true // a backend with no host cgroup: the leader's exit is all there is |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl) |
| + defer cancel() |
| + if err := hk.KillHost(ctx, rt); err != nil { |
| + log.Printf("activator: %s: worker generation %d's host cgroup is not proven empty: %v", rt.Host, m.gen.N, err) |
| + return false |
| + } |
| + return true |
| + } |
| k, ok := a.backend.(genKiller) |
| if !ok || m.gen.N == 0 { |
| return true |
diff --git a/internal/activator/worker_gen_test.go b/internal/activator/worker_gen_test.go| new file mode 100644 |
| index 000000000..4cb4b9401 |
| --- /dev/null |
| +++ b/internal/activator/worker_gen_test.go |
| @@ -0,0 +1,228 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "path/filepath" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestWorkerGenerations(t *testing.T) { |
| + for _, on := range []bool{true, false} { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + a.gens = on |
| + old := sidecarSettle |
| + sidecarSettle = 20 * time.Millisecond |
| + t.Cleanup(func() { sidecarSettle = old }) |
| + rt := testWorkerRoute("bot.example.com") |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + first, members := a.genStateForTest(rt.Host) |
| + if on != (first.N != 0) || first.Socket != "" || len(members) != 0 { |
| + t.Fatalf("gens %v: worker generation %+v, members %v", on, first, members) |
| + } |
| + b.mu.Lock() |
| + b.workerStartDelay = 200 * time.Millisecond // hold the respawn open |
| + b.mu.Unlock() |
| + b.lastWorker().crash() |
| + a.sweepOnce() // a crashed hibernating worker respawns in place |
| + waitFor(t, "the respawn to begin", func() bool { return b.workerEnters() == 2 }) |
| + _, members = a.genStateForTest(rt.Host) |
| + if on != (len(members) == 1) { |
| + t.Fatalf("gens %v: members during the respawn = %v", on, members) |
| + } |
| + waitFor(t, "the respawn to finish", func() bool { return b.workerStarts() == 2 }) |
| + waitFor(t, "the member to leave", func() bool { _, m := a.genStateForTest(rt.Host); return len(m) == 0 }) |
| + if on { |
| + if second, _ := a.genStateForTest(rt.Host); second.N <= first.N || !second.Worker { |
| + t.Fatalf("respawned worker generation %+v, want a Worker above %d", second, first.N) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// StartWorker spawns every worker into the flat host cgroup, whatever the |
| +// row's layout, so KillHost proves that cgroup for a nested-looking row too. |
| +// Here the cgroup is a fixture, not cgroup v2, so the proof is refused. |
| +// Answering nil for a nested layout skipped the proof for a worker that |
| +// runs in carlos-exec@<host> all the same. |
| +func TestKillHostProvesANestedLayoutWorkersHostCgroup(t *testing.T) { |
| + host := "nw.example.com" |
| + b, _ := delegatedBackend(t, host) |
| + rd := t.TempDir() |
| + b.RunDirOverride = rd |
| + rt := registry.Route{Host: host, Kind: "sidecar", DBPath: provision.NestedDBPath(b.DataDir, host), |
| + Socket: filepath.Join(rd, "instances", provision.InstanceID(host), "app.sock")} |
| + if err := b.KillHost(context.Background(), rt); err == nil { |
| + t.Fatal("KillHost answered a nested-layout worker's host cgroup as proven without proving it") |
| + } |
| +} |
| + |
| +// hostKillBackend is fakeBackend plus KillHost, failing while bad. |
| +type hostKillBackend struct { |
| + *fakeBackend |
| + hmu sync.Mutex |
| + bad bool |
| + kills int |
| +} |
| + |
| +func (h *hostKillBackend) KillHost(context.Context, registry.Route) error { |
| + h.hmu.Lock() |
| + defer h.hmu.Unlock() |
| + h.kills++ |
| + if h.bad { |
| + return errors.New("the host cgroup still holds a process") |
| + } |
| + return nil |
| +} |
| + |
| +func (h *hostKillBackend) heal() { |
| + h.hmu.Lock() |
| + h.bad = false |
| + h.hmu.Unlock() |
| +} |
| + |
| +// Spec §7.4, §7.9: a worker runs in its host cgroup (plan 2 D4), so that is |
| +// what proves it terminated, not a g<n> it never ran in (whose absence |
| +// KillGen counts as empty). While the host cgroup still holds a process |
| +// (a descendant the worker forked), a terminal stop keeps the lease and a |
| +// respawn execs nothing beside it. |
| +func TestWorkerTerminationProvesItsHostCgroup(t *testing.T) { |
| + t.Run("a terminal stop keeps the lease", func(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + a.gens = true |
| + hk := &hostKillBackend{fakeBackend: b, bad: true} |
| + a.backend = hk |
| + rt := testWorkerRoute("bot.example.com") |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + rep := b.lastRep() |
| + a.Bounce(rt.Host) |
| + waitFor(t, "the stop to pend", func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + e := a.entries[rt.Host] |
| + return e != nil && e.pendingStop |
| + }) |
| + time.Sleep(50 * time.Millisecond) |
| + if rep.isClosed() { |
| + t.Fatal("the lease was released while the worker's host cgroup still held a process") |
| + } |
| + hk.heal() |
| + a.sweepOnce() |
| + waitFor(t, "the lease release", rep.isClosed) |
| + }) |
| + t.Run("a respawn with no old handle still proves the host cgroup", func(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + a.gens = true |
| + hk := &hostKillBackend{fakeBackend: b} |
| + a.backend = hk |
| + rt := testWorkerRoute("bot3.example.com") |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + hk.hmu.Lock() |
| + hk.bad = true |
| + hk.hmu.Unlock() |
| + a.mu.Lock() |
| + // main's signature; once plan 4 Task 9 has merged, pass nil as its |
| + // fourth (onto) argument. |
| + a.respawnWorkerLocked(a.entries[rt.Host], nil, "test: a restart with no old handle", nil) |
| + a.mu.Unlock() |
| + waitFor(t, "the respawn to finish", func() bool { return !a.entryRespawning(rt.Host) }) |
| + if got := b.workerStarts(); got != 1 { |
| + t.Fatalf("worker starts = %d: a nil-stopOld respawn skipped the host cgroup proof", got) |
| + } |
| + }) |
| + t.Run("a failed worker wake keeps its lease", func(t *testing.T) { |
| + old := sidecarSettle |
| + sidecarSettle = 20 * time.Millisecond |
| + t.Cleanup(func() { sidecarSettle = old }) |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + a.gens = true |
| + hk := &hostKillBackend{fakeBackend: b, bad: true} |
| + a.backend = hk |
| + b.mu.Lock() |
| + b.workerDeadOnStart = true // the wake fails its settle |
| + b.mu.Unlock() |
| + if err := a.WakeWorker(testWorkerRoute("bot4.example.com")); err == nil { |
| + t.Fatal("a worker dead on start woke") |
| + } |
| + // The lease goes back by Release, or by closing the replica when the |
| + // wake had one (cleanupFailedWake). |
| + given := func() bool { |
| + b.mu.Lock() |
| + released, reps := b.releases, append([]*fakeRep(nil), b.reps...) |
| + b.mu.Unlock() |
| + if released > 0 { |
| + return true |
| + } |
| + for _, r := range reps { |
| + if r.isClosed() { |
| + return true |
| + } |
| + } |
| + return false |
| + } |
| + if given() { |
| + t.Fatal("a failed worker wake gave its lease back over an unproven host cgroup") |
| + } |
| + hk.heal() |
| + a.sweepOnce() |
| + waitFor(t, "the lease given back", given) |
| + }) |
| + t.Run("a respawn execs nothing beside the old child", func(t *testing.T) { |
| + old := sidecarSettle |
| + sidecarSettle = 20 * time.Millisecond |
| + t.Cleanup(func() { sidecarSettle = old }) |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + a.gens = true |
| + hk := &hostKillBackend{fakeBackend: b} |
| + a.backend = hk |
| + rt := testWorkerRoute("bot2.example.com") |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + hk.hmu.Lock() |
| + hk.bad = true |
| + hk.hmu.Unlock() |
| + b.lastWorker().crash() |
| + a.sweepOnce() // the respawn retires the crashed child, and its proof fails |
| + waitFor(t, "the respawn to finish", func() bool { return !a.entryRespawning(rt.Host) }) |
| + if got := b.workerStarts(); got != 1 { |
| + t.Fatalf("worker starts = %d: a respawn exec'd beside an unproven old child", got) |
| + } |
| + }) |
| +} |
| + |
| +// Index P5/P6: every exec asks the gate's ownership check, whatever the |
| +// switch says. A respawn whose lease has been lost execs nothing, with |
| +// generations on or off. |
| +func TestWorkerRespawnAsksOwnershipAtItsExec(t *testing.T) { |
| + for _, on := range []bool{true, false} { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + a.gens = on |
| + rt := testWorkerRoute("bot5.example.com") |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + b.lastRep().setErr(errors.New("renewal failed")) |
| + a.mu.Lock() |
| + a.respawnWorkerLocked(a.entries[rt.Host], nil, "test: the lease is lost", nil) |
| + a.mu.Unlock() |
| + waitFor(t, "the respawn to finish", func() bool { return !a.entryRespawning(rt.Host) }) |
| + if got := b.workerStarts(); got != 1 { |
| + t.Fatalf("gens %v: worker starts = %d: a respawn exec'd without the lease", on, got) |
| + } |
| + if _, members := a.genStateForTest(rt.Host); len(members) != 0 { |
| + t.Fatalf("gens %v: members after the refused respawn = %v", on, members) |
| + } |
| + } |
| +} |