carlos / platform Public

plans: all 16 Astra P1 findings fixed; spec rev 17 clarifications (per-request generation dial, foreign rows hold)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev cf29e6153e46132fc394b5511bbe45ff20cb63c5 parent bcecbb3
6 files changed, +2038 −272
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md +80 −21
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md +203 −48
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md +1132 −166
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md +582 −35
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-surfaces.md +31 −1
  • docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md +10 −1

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md
index 5cd28bc2e..866298032 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md
@@ -230,7 +230,9 @@ type StagedIdentity struct {
- **Root validates the staged path for a nested executable mount** (plan
3). It must lie under `/opt/carlos/versions/<acct>/<app>/<version>/`
for that host's own app, resolved without following symlinks, and its
- digest must match the attempt's.
+ digest must match the attempt's. The same rule covers the nested legacy
+ sidecar's mount (plan 3 Task 9, `StartSidecarExe`): the sidecar runs the
+ serving generation's captured staged build, never the live link.
## Reconciliation (2026-10-07, after all six plans were written)
@@ -242,13 +244,17 @@ named; the ledger below records the result.
flat and nested spawns (`startChildAt`, `startUnitAt`, `prepareAt`) take
a socket, not a `Gen`. Plan 2 adds a trailing `n uint64` (`gen` for
`prepareAt`); `StartGen` passes its `n`, `Start` passes 0. Plan 3's
- `startChildFrom` and `startUnitFrom` carry it through.
+ `spawnChildFrom` and `spawnUnitFrom` carry it through.
- **The kill is plan 2's.** `KillGen(ctx, rt, n uint64)` on `ExecBackend`,
- plus a `SplitBackend` forward in plan 2 Task 10. Plan 5 calls it for a
- failed first wake; plan 3 does not call it.
+ plus a `SplitBackend` forward in plan 2 Task 10. Plan 3 Task 2 calls it
+ after every operation-set member's graceful drain (a leader's exit does
+ not prove its child empty), through the `genKiller` capability plan 3
+ declares. Plan 5 calls it for a failed first wake, through the same
+ `genKiller`.
- **A planned wake spawns through plan 3's `SwapBackend.StartStaged`**
(under `startPinned`), never through plan 1's `GenBackend.StartGen`,
- which execs the live link.
+ which execs the live link. `StartStaged` spawns and returns at once; the
+ caller owns the health deadline and the termination.
- **There is no `Config.Swap`.** Plan 3's `Swap` is armed by plan 1's
`SetGenerations` alone. Plan 5 sets `SetGenerations` and
`ExecBackend.GenContainment` from `CARLOS_SWAP=overlap`.
@@ -279,6 +285,46 @@ named; the ledger below records the result.
and `retryBuild` call `Manager.changed()`, so a box of sleepers
republishes its status within seconds of a new install.
+## Fixes for Astra's P1 review of the plans (2026-10-07)
+
+`CODEX-FINDINGS-ZDD-PLANS-P1.md`, 16 findings. Each fix is in the plan and
+task named, with a test that fails without it. Earlier plans kept their
+names except where a finding said otherwise.
+
+- **Termination is the leader's exit and an empty generation child**
+ (finding 1, plan 3 Task 2). `stopMember` runs plan 2's `KillGen` after
+ the graceful drain; a failed proof keeps the member, as
+ `ErrStillRunning` does.
+- **Staged spawning is not health checking** (finding 3, plan 3 Tasks 1
+ and 9, plan 5 Task 9). Plan 1's `startChildAt`/`startUnitAt` bodies are
+ split at the health wait (`spawnChildFrom`, `spawnUnitFrom`);
+ `StartStaged` returns the process at once.
+- **Each artifact's own tier** (finding 8, plan 3 Tasks 1, 4, 9, 10).
+ `Gen.Tier` is captured at admission from the build's staged manifest
+ (`ExecBackend.TierOf`); the overlap parent is incumbent plus successor;
+ the restore is the current generation's tier.
+- **Foreign rows** (finding 9, plan 3 Tasks 1 and 6): cold, pinned and hold
+ starts on the canonical socket; `Swap` runs an overlap attempt on one as
+ a hold.
+- **Sidecars** (findings 2, 10, 11, plan 3 Tasks 7 and 9): plan 2's
+ `role-sidecar` placement is kept; the nested sidecar gets the captured
+ staged executable, validated by root; a sidecar replacement joins an
+ in-flight supervised start.
+- **Hold canaries settle** (finding 4, plan 3 Task 6).
+- **Scheduler** (findings 5, 6, 7, 12, 14, plan 5 Tasks 8, 9, 11, 12, 13):
+ the socketless respawn re-runs the attempt's checks at the exec and
+ before the result; the cohort halts in the critical section that frees
+ the failed slot; a spawn whose build cannot be published to the
+ generations file does not run; planned worker wakes use §6.5's readiness;
+ a cancelled first-wake observation emits `GenCancelled`.
+- **Edge attribution** (finding 13, plan 1 Task 5): a generation's pool
+ dials only that generation's socket, so the response is attributed to the
+ connection's generation.
+- **Deploy watch** (finding 15, plan 6 Task 10): every rollout host is
+ refreshed until the watch ends.
+- **Test-type clash** (finding 16, plan 3 Task 4): plan 3's fixture is
+ `genServingProc`; plan 1's `servingProc` keeps its name.
+
## Cross-plan interface ledger
Every type, function, method or field that one plan defines and another
@@ -286,13 +332,14 @@ plan uses. "Defined" names the plan and task that own it; "Used by" lists
the consuming plans with task and line (line numbers as of this
reconciliation; `hdr` is the plan's header tables). Earlier plans win: a
later plan adapts to the definition here. A later plan that needed a new
-interface had the owning plan define it.
+interface had the owning plan define it. The P1 review fixes above moved
+many line numbers in plans 1, 3 and 5; find each use by its quoted name.
### Plan 1 (generations)
| Interface | Defined | Used by |
|---|---|---|
-| `type Gen struct{ N uint64; Socket, Control, Build, StagedPath, Digest, Demand string }` | P1 T1 | P2 hdr L61; P3 hdr L62, T1 L130, T4 L2208; P5 hdr L42, T1 L158 |
+| `type Gen struct{ N uint64; Socket, Control, Build, StagedPath, Digest, Demand string }`; plan 3 Task 1 adds `Tier cgroup.Limit` (the artifact's own tier, captured at admission) | P1 T1 (`Tier`: P3 T1) | P2 hdr L61; P3 hdr L62, T1, T4, T6, T9, T10; P5 hdr L42, T1 L158 |
| `func SetGenerations(on bool)` | P1 T1 | P3 hdr L34, L62; P5 T1 L168, T14 L7115 |
| `func nextGen() uint64` | P1 T1 | P3 T4 L2208, T6 L3006, T10 L4735, L4831, T11 L5240, L5263 |
| `func canonicalGen(rt registry.Route, n uint64, build string) Gen` | P1 T1 | P3 hdr L64 |
@@ -301,10 +348,12 @@ interface had the owning plan define it.
| `func (b *ExecBackend) flatGenSocket(host string, n uint64) string` | P1 T2 | P3 T1 L567, T10 L4588, L4900 |
| `func clearOwnGenSocket(sock string) error` | P1 T2 | P3 T1 L568 |
| `type GenBackend interface{ StartGen(ctx, rt, n uint64, perGen bool) (Proc, Gen, error); NextDueVia(ctx, rt, socket string) (int64, error) }` (on `ExecBackend`, `SplitBackend`) | P1 T2 | P2 hdr L54, T10 L3509; P3 hdr L66 (the wake's live-link spawn, which plan 3 leaves alone) |
-| `func (b *ExecBackend) startChildAt(ctx, rt, sock string, n uint64) (Proc, string, error)` (plan 2 adds `n`) | P1 T2, P2 T10 | P3 T1 L453 (`startChildFrom(ctx, rt, sock string, n uint64, exe string)` forwards) |
-| `func (b *ExecBackend) startUnitAt(ctx, rt, sock string, n uint64) (Proc, string, error)` (plan 2 adds `n`) | P1 T2, P2 T11 | P3 T9 L4400 (`startUnitFrom(ctx, rt, sock string, gen uint64, exe *instancectl.StagedExe)` forwards) |
-| `func (b *ExecBackend) prepareAt(rt, role instancectl.Role, sock string, gen uint64) (…)` (plan 2 adds `gen`) | P1 T2, P2 T11 | P3 T9 L4400 |
-| `func (b *ExecBackend) normalizeSocketMode(rt registry.Route, sock string, p Proc)` | P1 T2 | P3 T1 L624 |
+| `func (b *ExecBackend) startChildAt(ctx, rt, sock string, n uint64) (Proc, string, error)` (plan 2 adds `n`) | P1 T2, P2 T10 | P3 T1 splits its body at the health wait: `spawnChildFrom(ctx, rt, sock string, n uint64, exe string, tier cgroup.Limit) (Proc, string, error)` spawns only; `startChildAt` = `spawnChildFrom` + today's health tail |
+| `func (b *ExecBackend) startUnitAt(ctx, rt, sock string, n uint64) (Proc, string, error)` (plan 2 adds `n`) | P1 T2, P2 T11 | P3 T9 splits it the same way: `spawnUnitFrom(ctx, rt, sock string, gen uint64, exe *instancectl.StagedExe, tier cgroup.Limit) (Proc, string, error)` |
+| `func (b *ExecBackend) prepareAt(rt, role instancectl.Role, sock string, gen uint64) (…)` (plan 2 adds `gen`) | P1 T2, P2 T11 | P3 T9 |
+| `func (b *ExecBackend) normalizeSocketMode(rt registry.Route, sock string, p Proc)` | P1 T2 | P3 T1 (`GenSwitched`, `GenReady`) |
+| `servingProc` (test type in `canonlink_test.go`) | P1 T4 | P1 only; plan 3's own fixture is `genServingProc` (P1 review finding 16) |
+| `GenPools` internals: `genPool{tr, gen}`, `dialGen`, `genMovedError` (each pool dials only its own generation's socket) | P1 T5 | P1 only; plans 1's Tasks 6 and 8 use only `NewGenPools`, `Retire`, `Len`, `AppSocketProxy` |
| `func (b *ExecBackend) listenControl(rt registry.Route, control, envPath string) *edge.ControlListener` | P1 T2 | P3 T8 L3586, L3822 |
| `actEntry.gen Gen`; `wokenInstance.gen Gen` | P1 T3 | P3 T4 L1799, T6 L3048, T11 L5233; P5 T1 L188–189, T9 L4712, L5030 |
| `Service.gens bool` (read once in `New`); `func (a *Service) GenerationsOn() bool` | P1 T3 | P3 T2 L706, T4 L1761, T10 L4734, T11 L5236; P5 T1 L184, T9 L4340 |
@@ -318,15 +367,17 @@ interface had the owning plan define it.
| Interface | Defined | Used by |
|---|---|---|
-| `ExecBackend.GenContainment bool` | P2 T10 | P3 T1 L314, T9 L4158 (tests); P5 T1 L185, T14 L7116 (arms it) |
-| `func (b *ExecBackend) ParentLimit(ctx, rt registry.Route, l cgroup.Limit) error` | P2 T10 | P3 T1 L586 (`RaiseGenParent`, `RestoreGenParent`) |
-| `func (b *ExecBackend) KillGen(ctx, rt registry.Route, n uint64) error`; `func (b *SplitBackend) KillGen(ctx, rt registry.Route, n uint64) error` | P2 T10 | P5 T1 L169–170, T9 L4174, L4751 (through `genKiller`) |
+| `ExecBackend.GenContainment bool` | P2 T10 | P3 T1, T7, T9 (tests); P5 T1 L185, T14 (arms it) |
+| `func (b *ExecBackend) ParentLimit(ctx, rt registry.Route, l cgroup.Limit) error` | P2 T10 | P3 T1 (`RaiseGenParent(ctx, rt, incumbent, successor)`, `RestoreGenParent(ctx, rt, tier)`) |
+| `func (b *ExecBackend) KillGen(ctx, rt registry.Route, n uint64) error`; `func (b *SplitBackend) KillGen(ctx, rt registry.Route, n uint64) error`; `errNoGenContainment` | P2 T10 | P3 T2 (`stopMember` → `genChildEmpty`, through `genKiller`); P5 T1 L169–170, T9 (`killFailed`, through `genKiller`) |
+| `func (b *ExecBackend) placeFor(kind spawnKind, n uint64) (string, bool, error)`; `func (b *ExecBackend) spawnInCgroupAt(rt, child string, ownTier bool, build) (*exec.Cmd, error)`; `startSidecarChild`'s `role-sidecar` placement and `RemoveChild` | P2 T10 | P3 T1 (`spawnInCgroupAtTier`, to which `spawnInCgroupAt` forwards), T7 (`startSidecarChildFrom` keeps the placement) |
+| `delegatedBackend(t, host) (*ExecBackend, string)` (test helper) | P2 T10 | P3 T1, T7 |
| `func cgroup.OverlapLimit(incumbent, successor cgroup.Limit) cgroup.Limit` | P2 T1 | P3 T1 L592, T10 L5003 |
| `func (m *cgroup.Manager) PrepareStrictParent(host string, tier cgroup.Limit) (*cgroup.Prepared, error)`; `(*Prepared).SetParentLimit(l)`; `(*Prepared).CloseRemove()` | P2 T3 | P3 T10 L4881, L4987, L4994 |
| `func spawnClaimedGen(ctx, parent *cgroup.Prepared, n uint64, tier cgroup.Limit, selected hostwrite.Work, identity hostwrite.Identity, build func() *exec.Cmd, before func() error) (*exec.Cmd, *cgroup.Prepared, error)` | P2 T12 | P3 T10 L4708, L4771 |
-| `type GenControl interface{ StartGen, StopGen, ResetGen, Parent }` (plan 3 Task 9 adds `StartGenExe`) | P2 T10 | P3 T9 L3934, L4379 |
-| `func (b *ExecBackend) startRole(ctx, rt, role instancectl.Role, gen uint64) (string, error)` (plan 3 Task 9 adds a trailing `exe *instancectl.StagedExe`) | P2 T11 | P3 T9 L3935, L4163 |
-| `instancectl.Client.StartGen(ctx, host string, n uint64, memoryMax int64) (string, error)`; `Server.ensureGenExe(host, n)`; `Server.refuseStart(w, host, unit, exe func() error)`; `genValues(host, n)` | P2 T9 | P3 T9 L3930, L4337, L4360 |
+| `type GenControl interface{ StartGen, StopGen, ResetGen, Parent }` (plan 3 Task 9 adds `StartGenExe` and `StartSidecarExe`) | P2 T10 | P3 T9 |
+| `func (b *ExecBackend) startRole(ctx, rt, role instancectl.Role, gen uint64) (string, error)` (plan 3 Task 9 adds trailing `exe *instancectl.StagedExe, tier cgroup.Limit`) | P2 T11 | P3 T9 |
+| `instancectl.Client.StartGen(ctx, host string, n uint64, memoryMax int64) (string, error)`; `Server.ensureGenExe(host, n)`; `Server.refuseStart(w, host, unit, exe func() error)`; `genValues(host, n)`; `handleStart`'s original-template `refuseStart(…ensureHostExe…)` call | P2 T9 | P3 T9 (`StartGenExe`, `StartSidecarExe`, `ensureGenExeAt`, `ensureHostExeAt`) |
### Plan 3 (swap)
@@ -335,13 +386,18 @@ interface had the owning plan define it.
| `Cause`, `SwapMode`, `Attempt`, `SwapOutcome` and their constants (index shared interfaces) | P3 T1 | P5 T1 L158–164, T5, T8 |
| `type SwapResult struct{ Outcome SwapOutcome; Gen Gen; Reason string; Drain time.Duration }` | P3 T1 (Drain set in T4, T6) | P5 T1 L163, T8 L4113 (`textSwapped(build, res.Drain)`), T11 L5898 |
| `func (a *Service) Swap(ctx context.Context, at Attempt, newest func(host string) uint64) SwapResult` | P3 T4 | P5 T8 L2595, L3381 (the `Activator` interface), L4085 |
-| `type SwapBackend interface{ StartStaged(ctx, rt, g Gen) (Gen, Proc, error); RaiseGenParent(ctx, rt) error; RestoreGenParent(ctx, rt) error; GenSwitched(rt, g Gen, p Proc) error }` | P3 T1 | P5 T1 L171, T9 L4223–4259 (`planGenBackend` implements it) |
+| `type SwapBackend interface{ StartStaged(ctx, rt, g Gen) (Gen, Proc, error); GenTier(rt, build string) cgroup.Limit; GenReady(rt, g Gen, p Proc); RaiseGenParent(ctx, rt, incumbent, successor cgroup.Limit) error; RestoreGenParent(ctx, rt, tier cgroup.Limit) error; GenSwitched(rt, g Gen, p Proc) error }`. `StartStaged` spawns and returns at once (no health wait); a foreign row spawns on its canonical socket | P3 T1 | P5 T1 L171, T9 (`planGenBackend` and `execStagedBackend` implement it) |
+| `ExecBackend.TierOf func(rt, version string) cgroup.Limit`; `host.memTierOf` | P3 T1 | P3 only (wired in `agent.go` by plan 3) |
+| `type genKiller interface{ KillGen(ctx, rt registry.Route, n uint64) error }`; `func (a *Service) stopMember(e *actEntry, m *genMember) bool`; `genChildEmpty`; `reapExited` | P3 T2 | P5 T1 (surface pin), T9 (`killFailed`); plan 5 declares no `genKiller` of its own |
+| `func (a *Service) foreignRow(host string) bool`: `Swap` runs a `ModeOverlap` attempt on a foreign row as a hold (its result reads as a hold's, `SwapDown` on failure) | P3 T6 | P5 T8 (`done` records the attempt's own mode, so no `HeldDown` for it) |
+| `actEntry.settleCancel context.CancelFunc` (a hold canary's settle, cancelled by `beginStopLocked`) | P3 T6 | P5 T9 adds its own `firstWatch` cancel beside it in `beginStopLocked` |
+| `instancectl.Client.StartSidecarExe(ctx, host string, memoryMax int64, x StagedExe) (string, error)`; `Server.ensureHostExeAt(host, x)` | P3 T9 | P3 only |
| `func (a *Service) swapChecksLocked(e *actEntry, at Attempt, newest func(string) uint64) error`; `errSwapNotAwake`, `errSwapBusy`, `errSwapKind`, `errSwapStopped`; `func abandon(err error) SwapResult` | P3 T3, T4 | P5 T1 L172, T11 L5830 (`ReplaceWorker`) |
| `actEntry.ops opSet` (`members map[uint64]*genMember`, `genMember.gen`); `addMemberLocked`; `leaveSet` | P3 T2 | P5 T1, T12 L6170, L6366, L6419 |
-| `swapOverlap`'s `g, proc, spawnErr := a.spawnSuccessor(mctx, gb, e, rt, work, m.gen)` | P3 T10 | P5 T12 L6419 (persist point) |
+| `swapOverlap`'s `g, proc, spawnErr := a.spawnSuccessor(mctx, gb, e, rt, work, m.gen)` | P3 T10 | P5 T12 (persist point; a failed write abandons the attempt there) |
| `Service.genHealth func(ctx context.Context, g Gen) error` (test seam) | P3 T4 | P5 T1 L186, T9 L4344 |
| `func observeSettle(ctx context.Context, p Proc, window time.Duration) settleResult`; `settleOK`, `settleExited`, `settleCancelled` | P3 T5 | P5 T1 L174, T9 L4859, T11 L5883 |
-| `actEntry.pin *Gen`; `func (a *Service) startPinned(ctx, rt, pin Gen) (Proc, string, Gen, error)` (plan 5 Task 9 adds `healthBy time.Time`) | P3 T6 | P5 T1 L173, T9 L4160, L5043 |
+| `actEntry.pin *Gen`; `func (a *Service) startPinned(ctx, rt, pin Gen) (Proc, string, Gen, error)` (plan 5 Task 9 adds `healthBy time.Time`). Its health check is the only one (StartStaged waits for none), and a failed build comes back with its `Proc` | P3 T6 | P5 T1 L173, T9 |
| `StartPolicy.Pin *Gen` (T6); `StartPolicy.Gen Gen`, `StartPolicy.Fallback *Gen`, `StartPolicy.HealthBy time.Time`, `actEntry.pinFailed *Gen`, `actEntry.pinFailReason string` (T10) | P3 T6, T10 | P5 T1 L177, L191, T9 L4531, L5075 (`doClaimWake`, `claimedPlanResult`) |
| `Service.RetiredControl(host, control string) bool`; `schedule.MountGated`, `schedule.RetiredWriteMessage` | P3 T8 | P3 only (wired in `agent.go` by plan 3); plan 6 does not reword the 409 (plan 3 Task 8 reviews it) |
@@ -370,7 +426,10 @@ interface had the owning plan define it.
| `func (m *rollout.Manager) RetryHost(host string) bool` | P5 T8 | P6 T11 L3444, L3458, L3544 |
| event texts `textSwapping(build string, cause activator.Cause)`, `textSwapped(build string, drain time.Duration)`, `textSwapFailed(build, serving, reason string)`, `textSwapDown(build string)`, `textCohortHalted(build, box, reason string)`, `textFallback(failed, good string)`, `textConfigPending(reason string, wait time.Duration)`, `textStagingRefused(build string)` | P5 T8 | P6 T14 L4480–4560 (rewords the bodies) |
| `func bounceText(worker bool, cause Cause) string` (used by `BounceFor` and `ReplaceWorker`) | P5 T9 | P6 T14 L4471–4480 |
-| `func (a *Service) ReplaceWorker(ctx, at Attempt, fallback *release.StagedIdentity, newest func(host string) uint64) SwapResult` | P5 T11 | P5 T8 L2596, L3382 (`Activator`), L2731 (`fakeAct`), T11 L6078 (`executeWorker`) |
+| `func (a *Service) ReplaceWorker(ctx, at Attempt, fallback *release.StagedIdentity, newest func(host string) uint64) SwapResult`; `actEntry.attemptGate`, `attemptRestore`, `attemptVeto` (the attempt's checks, re-run by `respawnWorkerLocked` at the exec) | P5 T11 | P5 T8 (`Activator`, `fakeAct`), T11 (`executeWorker`) |
+| `GenEventKind` `GenCancelled` (a first-wake observation ended with no verdict) | P5 T9 | P5 T13 (`OnGen` clears `observing`) |
+| `func plannedWorkerReady(ctx context.Context, p Proc) error` (spec §6.5 for planned socketless wakes) | P5 T9 | P5 only |
+| `func (a *Service) persistGenerations() error`; `errGenerationsUnpublished` (a spawn whose build is not published does not run) | P5 T12 | P5 only |
| `status.RolloutReport`, `HostRollout`, `AppRollout`, `RolloutFailure`; `Attempt*` and `Reason*` words; `Probes.Rollout`, `BoxMetrics.Rollout`; `func reasonWord(text string) string`; `Deps.OnChange` | P5 T13 | P6 T2, T8 L2110–2183, T10 L2779, T11 L3670, L4022, T12 L4064 (through `rolloutView`), T14 L4553 |
| `func armedRollout() *rollout.Manager` | P5 T14 | P6 T11 L3458, L3627, L3632 |
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md
index 7f2187765..1c989087b 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md
@@ -2230,6 +2230,9 @@ git commit -m "zdd plan 1 task 4: the canonical socket path is a symlink to the
```go
var errNoGeneration error // wraps syscall.ENOENT
func dialUnixRetrying(ctx context.Context, d *net.Dialer, resolve func() (string, bool)) (net.Conn, error)
+ type genMovedError struct{ to GenInfo } // a pool's own generation is gone and another is current
+ func dialGen(ctx context.Context, d *net.Dialer, src GenSource, host string, own GenInfo) (net.Conn, error)
+ type genPool struct{ tr *http.Transport; gen GenInfo }
type GenPools struct{ … }
func NewGenPools() *GenPools
func (p *GenPools) Retire(host, socket string) // OnGenRetired's hook shape
@@ -2239,7 +2242,9 @@ git commit -m "zdd plan 1 task 4: the canonical socket path is a symlink to the
func noteServedGen(r *http.Request, host string, n uint64)
func servedGen(r *http.Request) (host string, n uint64, ok bool)
```
- Test helpers (in `genproxy_test.go`, used by Tasks 6 and 7): `fakeGens` (`newFakeGens(on)`, `switchTo(host, GenInfo)`, `sleep(host)`, `askCount()`, field `retired`), `genSockDir(t)`, `genApp` (fields `sock`, `opened`, `closed`, `srv`), `startGenApp(t, sock, h)`, `body(b, cc string) http.Handler`, `serve(t, h, url) *httptest.ResponseRecorder`.
+ Test helpers (in `genproxy_test.go`, used by Tasks 6 and 7): `fakeGens` (`newFakeGens(on)`, `switchTo(host, GenInfo)`, `sleep(host)`, `askCount()`, fields `retired`, `onServing`), `genSockDir(t)`, `genApp` (fields `sock`, `opened`, `closed`, `srv`), `startGenApp(t, sock, h)`, `body(b, cc string) http.Handler`, `serve(t, h, url) *httptest.ResponseRecorder`.
+
+ **A pool's connections are its own generation's** (spec §7.2, P1 review finding 13). The response's version header and the cache's generation must name the generation whose connection carried the request. So a generation's pool dials that generation's socket and nothing else (`dialGen`), and the response is attributed to the pool's generation, which is then exact. A switch between the pool choice and the dial is harmless: the retiring generation still accepts until its SIGTERM, which comes after the switch (plan 3's review focus 1). Only when the pool's own socket is gone *and* another generation is current does the dial give up with `genMovedError`, and `RoundTrip` sends the request once more on the current generation's pool if it can be replayed (no body, or `GetBody`).
- [ ] **Step 1: Write the failing tests**
@@ -2274,6 +2279,10 @@ type fakeGens struct {
last map[string]uint64 // host -> CacheGen
asks int // AppSocket calls
retired []func(host, socket string)
+ // onServing, when set, runs after every ServingGen answer, with no lock
+ // held: a test's way to land a switch between the pool choice and the
+ // dial.
+ onServing func()
}
func newFakeGens(on bool) *fakeGens {
@@ -2292,8 +2301,12 @@ func (f *fakeGens) AppSocket(host string) (string, bool) {
func (f *fakeGens) ServingGen(host string) (GenInfo, bool) {
f.mu.Lock()
- defer f.mu.Unlock()
g, ok := f.cur[host]
+ hook := f.onServing
+ f.mu.Unlock()
+ if hook != nil {
+ hook()
+ }
return g, ok
}
@@ -2476,6 +2489,60 @@ func TestAppSocketProxyFollowsTheCurrentGeneration(t *testing.T) {
}
}
+// Spec §7.2: the version header and the cache's generation name the
+// generation whose connection carried the request. Here the switch lands
+// between the pool choice and the dial. With a pool whose dial re-resolves
+// AppSocket, the request reached B while it was stamped and tagged as A.
+func TestAppSocketProxyAttributesTheConnectionsGeneration(t *testing.T) {
+ const host = "inst.example.com"
+ t.Run("the retiring generation still accepts", func(t *testing.T) {
+ dir := genSockDir(t)
+ a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
+ b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", ""))
+ src := newFakeGens(true)
+ src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"})
+ var once sync.Once
+ src.onServing = func() {
+ once.Do(func() { src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"}) })
+ }
+ p := appSocketProxy(host, src, NewGenPools())
+ r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil))
+ rec := httptest.NewRecorder()
+ p.ServeHTTP(rec, r)
+ _, n, _ := servedGen(r)
+ if rec.Body.String() != "A" || rec.Header().Get("X-Carlos-Version") != "v1" || n != 1 {
+ t.Fatalf("served %q, version %q, generation %d: want A, v1, 1 (the pool's own generation)",
+ rec.Body.String(), rec.Header().Get("X-Carlos-Version"), n)
+ }
+ if got := b.opened.Load(); got != 0 {
+ t.Fatalf("generation 2 got %d connection(s) through generation 1's pool", got)
+ }
+ })
+ t.Run("the sampled generation is gone", func(t *testing.T) {
+ dir := genSockDir(t)
+ a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
+ b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", ""))
+ src := newFakeGens(true)
+ src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"})
+ var once sync.Once
+ src.onServing = func() {
+ once.Do(func() {
+ a.srv.Close() // A has exited: its socket file is gone
+ src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"})
+ })
+ }
+ p := appSocketProxy(host, src, NewGenPools())
+ r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil))
+ rec := httptest.NewRecorder()
+ p.ServeHTTP(rec, r)
+ _, n, _ := servedGen(r)
+ if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" || n != 2 {
+ t.Fatalf("served %q, version %q, generation %d: want B, v2, 2 (sent again on the current pool)",
+ rec.Body.String(), rec.Header().Get("X-Carlos-Version"), n)
+ }
+ })
+}
+
// The generation that carried a request is recorded on its outcome, for the
// cache to tag with.
func TestAppSocketProxyNotesTheServedGeneration(t *testing.T) {
@@ -2582,6 +2649,7 @@ package edge
import (
"context"
+ "errors"
"fmt"
"net"
"net/http"
@@ -2595,10 +2663,12 @@ import (
// genproxy.go is the edge half of generations (zero-downtime spec §7.2): a
// reverse proxy for an activator-managed route whose app socket moves from
-// one generation to the next. Every new connection asks the activator for
-// the current generation's socket, idle keep-alives are pooled per socket
-// path so a connection to generation n is never reused for n+1, and a
-// generation's idle connections are closed when it stops being current.
+// one generation to the next. Every request asks the activator for the
+// current generation and uses that generation's pool, whose connections all
+// reach that generation's socket (dialGen), so the response is attributed to
+// the generation that served it. A connection to generation n is never
+// reused for n+1, and a generation's idle connections are closed when it
+// stops being current.
//
// Used only when the activator's GenerationsOn is true. With it false the
// edge builds unixProxy(t.Socket) exactly as before.
@@ -2639,30 +2709,79 @@ func dialUnixRetrying(ctx context.Context, d *net.Dialer, resolve func() (string
}
}
+// genMovedError is a generation pool's dial giving up: its own socket is
+// gone and another generation is current. Nothing was sent, so the request
+// can go again on the current generation's pool.
+type genMovedError struct{ to GenInfo }
+
+func (e *genMovedError) Error() string {
+ return fmt.Sprintf("edge: generation moved to %d while dialling the one before it", e.to.N)
+}
+
+// dialGen is a generation pool's dial: own's socket and nothing else, so
+// every connection in the pool is own's and a response through it is
+// attributed to own exactly (spec §7.2). The retiring generation still
+// accepts until its SIGTERM, which comes after the switch, so a request
+// that chose own's pool an instant before the switch is served there. A
+// retryable failure is retried inside the usual window while own is still
+// current or no generation is; once another one is current, own is gone for
+// good and the dial returns genMovedError.
+func dialGen(ctx context.Context, d *net.Dialer, src GenSource, host string, own GenInfo) (net.Conn, error) {
+ const window = 13 * time.Second
+ deadline := time.Now().Add(window)
+ for {
+ conn, err := unixdial.Checked(ctx, d, own.Socket)
+ if err == nil {
+ return conn, nil
+ }
+ if ctx.Err() != nil || !isRetryableDialErr(err) || time.Now().After(deadline) {
+ return nil, err
+ }
+ if cur, ok := src.ServingGen(host); ok && (cur.N != own.N || cur.Socket != own.Socket) {
+ return nil, &genMovedError{to: cur}
+ }
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+}
+
+// genPool is one generation's transport and the generation it reaches.
+type genPool struct {
+ tr *http.Transport
+ gen GenInfo
+}
+
// GenPools is the per-socket-path set of transports, one idle pool per
// generation socket. One set per consumer (the edge has one, scheduled
// delivery has its own) and shared across route reloads, because the pool
// belongs to the generation, not to the table build that first reached it.
type GenPools struct {
mu sync.Mutex
- m map[string]*http.Transport
+ m map[string]*genPool
}
// NewGenPools returns an empty set.
-func NewGenPools() *GenPools { return &GenPools{m: map[string]*http.Transport{}} }
+func NewGenPools() *GenPools { return &GenPools{m: map[string]*genPool{}} }
-// transport is socket's transport, made on first use. Its dial still asks
-// src for the CURRENT socket on every attempt (spec §7.2): socket only
-// chooses the pool.
-func (p *GenPools) transport(host, socket string, src GenSource) *http.Transport {
+// pool is generation g's pool, made on first use. Its dial reaches g's own
+// socket only (dialGen). A foreign row reuses its canonical path for every
+// generation (plan 1's reading), so a pool found for the path under another
+// generation number is replaced, and its idle connections closed.
+func (p *GenPools) pool(host string, g GenInfo, src GenSource) *genPool {
p.mu.Lock()
defer p.mu.Unlock()
- if tr, ok := p.m[socket]; ok {
- return tr
+ if gp, ok := p.m[g.Socket]; ok {
+ if gp.gen.N == g.N {
+ return gp
+ }
+ gp.tr.CloseIdleConnections()
}
- tr := genTransportFor(host, src, false)
- p.m[socket] = tr
- return tr
+ gp := &genPool{tr: genTransportFor(host, src, &g), gen: g}
+ p.m[g.Socket] = gp
+ return gp
}
// Retire closes socket's idle connections and forgets its pool. It has the
@@ -2670,11 +2789,11 @@ func (p *GenPools) transport(host, socket string, src GenSource) *http.Transport
// retired pool finishes; its connection is not reused by anyone after.
func (p *GenPools) Retire(_, socket string) {
p.mu.Lock()
- tr, ok := p.m[socket]
+ gp, ok := p.m[socket]
delete(p.m, socket)
p.mu.Unlock()
if ok {
- tr.CloseIdleConnections()
+ gp.tr.CloseIdleConnections()
}
}
@@ -2686,50 +2805,86 @@ func (p *GenPools) Len() int {
}
// genTransportFor builds the transport behind one pool: unixProxy's idle
-// bounds, and a dial that resolves the socket per attempt. oneShot disables
-// keep-alives, for a request that reached no current generation and so has
-// no pool to belong to.
-func genTransportFor(host string, src GenSource, oneShot bool) *http.Transport {
+// bounds, and a dial that reaches own's socket only (dialGen). With own nil
+// it is the one-shot transport for a request that reached no current
+// generation: keep-alives off, and a dial that resolves the socket per
+// attempt, waiting for a generation to appear.
+func genTransportFor(host string, src GenSource, own *GenInfo) *http.Transport {
dialer := &net.Dialer{}
+ dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return dialUnixRetrying(ctx, dialer, func() (string, bool) { return src.AppSocket(host) })
+ }
+ if own != nil {
+ g := *own
+ dial = func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return dialGen(ctx, dialer, src, host, g)
+ }
+ }
return &http.Transport{
- DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
- return dialUnixRetrying(ctx, dialer, func() (string, bool) { return src.AppSocket(host) })
- },
+ DialContext: dial,
MaxIdleConns: 32,
IdleConnTimeout: 90 * time.Second,
- DisableKeepAlives: oneShot,
+ DisableKeepAlives: own == nil,
}
}
// genRoundTripper picks the pool of host's current generation for each
-// request, stamps the response with that generation's build, and records
-// the generation on the request's outcome for the cache (spec §7.2).
+// request, stamps the response with the build of the generation whose
+// connection carried it, and records that generation on the request's
+// outcome for the cache (spec §7.2). A pool's connections are all its own
+// generation's (dialGen), so the pool's generation is that generation.
type genRoundTripper struct {
host string
src GenSource
pools *GenPools
}
+// genRetries bounds how often one request is sent again after its
+// generation moved under it.
+const genRetries = 2
+
func (t *genRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
- g, ok := t.src.ServingGen(t.host)
- if !ok {
- // No current generation: the dial waits for one inside its retry
- // window. Nothing is pooled and nothing is stamped or cached,
- // because which generation answers is not known here.
- return genTransportFor(t.host, t.src, true).RoundTrip(req)
- }
- resp, err := t.pools.transport(t.host, g.Socket, t.src).RoundTrip(req)
- if err != nil {
- return nil, err
- }
- // X-Carlos-Version names the build of the generation that served the
- // request, not the registry's Version (spec §7.2). Set, not Add: the
- // platform's voice on this header is the only one.
- if g.Build != "" {
- resp.Header.Set("X-Carlos-Version", g.Build)
+ for attempt := 0; ; attempt++ {
+ g, ok := t.src.ServingGen(t.host)
+ if !ok {
+ // No current generation: the dial waits for one inside its retry
+ // window. Nothing is pooled and nothing is stamped or cached,
+ // because which generation answers is not known here.
+ return genTransportFor(t.host, t.src, nil).RoundTrip(req)
+ }
+ gp := t.pools.pool(t.host, g, t.src)
+ resp, err := gp.tr.RoundTrip(req)
+ var moved *genMovedError
+ if errors.As(err, &moved) && attempt < genRetries && replayable(req) {
+ // The generation this request chose has gone and another is
+ // current. Nothing was sent: send it again, on the current one.
+ if req.GetBody != nil {
+ body, berr := req.GetBody()
+ if berr != nil {
+ return nil, err
+ }
+ req.Body = body
+ }
+ continue
+ }
+ if err != nil {
+ return nil, err
+ }
+ // X-Carlos-Version names the build of the generation that served the
+ // request, not the registry's Version (spec §7.2). Set, not Add: the
+ // platform's voice on this header is the only one.
+ if gp.gen.Build != "" {
+ resp.Header.Set("X-Carlos-Version", gp.gen.Build)
+ }
+ noteServedGen(req, t.host, gp.gen.N)
+ return resp, nil
}
- noteServedGen(req, t.host, g.N)
- return resp, nil
+}
+
+// replayable reports whether req can be sent again after a dial that never
+// connected: it has no body, or a way to make the body again.
+func replayable(req *http.Request) bool {
+ return req.Body == nil || req.Body == http.NoBody || req.GetBody != nil
}
// appSocketProxy is unixProxy for an activator-managed route with
@@ -2796,7 +2951,7 @@ The loop is the same loop, the resolver always answers `socketPath`, so `unixPro
- [ ] **Step 6: Run the tests to verify they pass**
Run: `TMPDIR=/tmp/k go test ./internal/edge -run 'TestDialUnixRetrying|TestAppSocketProxy|TestUnixSocketUpstream' -count=1 -v 2>&1 | grep -E '^(--- |ok|FAIL)'`
-Expected: five `--- PASS` lines, then `ok github.com/carlosframework/platform/internal/edge`.
+Expected: six top-level `--- PASS` lines, then `ok github.com/carlosframework/platform/internal/edge`.
- [ ] **Step 7: Gate and commit**
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
index a18fe9ad2..5fe09af8f 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
@@ -42,7 +42,7 @@ Every command below runs from `/home/paulca/amadan.net/carlos/platform-zdd-3-swa
- **Nothing calls `Service.Swap` in this plan.** Plan 5 wires it.
- **Spec values, verbatim:** "`GET /` below 500 within `spawnHealthTimeout` (30 s)". "A canary must then survive the 10 s settle." The retiring generation gets "SIGTERM … with `actStopTimeout` (20 s) before SIGKILL". The hold holds requests "up to `wakeWaitBound`" (30 s). "There is one successor per entry at a time." The canonical path is "replaced atomically (temp link plus rename) at every switch".
- **The §7.4 checks** are: still awake, lease still held, still the newest attempt, and no stop-evidence operation in progress. They run before the spawn and again before the switch. Claims off (`hostwrite.Legacy`) runs them under `a.mu`, against the barrier, the lease and `a.fences`. Claims on runs them inside `Work.With`, which validates the claim and the installation stamp first. **A failed claims-on check abandons the successor. It is never treated as permission to take the claims-off path.**
-- **Foreign-layout rows** keep their canonical socket (plan 1's accepted reading). They never overlap. An overlap attempt on one is abandoned before any spawn. A `hold` works on any layout.
+- **Foreign-layout rows** keep their canonical socket (plan 1's accepted reading). They never overlap. `StartStaged` spawns one on its canonical socket for a cold, pinned or hold start (Task 1), and `Swap` runs an overlap attempt on one as a hold (Task 6). A `hold` works on any layout.
- **#231 test trap:** `Service.Owns` stays true while an entry drains. Prove a stop from the backend's spawn count after a fresh request (`startCount()`, `genCalls`), never from `Owns`.
- **Copy:**
- No new member-facing event text. The one event this plan emits is the existing `"instance stopping"`, for the retiring generation.
@@ -96,7 +96,9 @@ The cross-plan items this plan owns (index review focus 2 and 4) are Task 12.
| `internal/activator/staged.go` (create) | `verifyStaged` |
| `internal/activator/claimgen.go` (create) | Claims-on generations (Task 10): `ClaimRuntime.Generations`, the entry's strict parent, `startClaimedSuccessor` |
| `internal/activator/activator.go` (modify) | `actEntry.ops`, `pin`, `holdNext`; `Service.genHealth`; `wake()`; `doWake` (pinned spawn, sidecar); `stop()` barrier; `finishPendingStops`; `finishStop` (hold, parent); `sweepOnce` sidecar; `Shutdown` (waking); `startSidecar`, `replaceSidecar`; `respawnWorkerLocked` (Task 11) |
-| `internal/activator/backend_exec.go` (modify) | `ExecBackend.Retired`; `startChildFrom`; `startSidecarChildFrom`; `MountGated` in the flat spawn |
+| `internal/activator/backend_exec.go` (modify) | `ExecBackend.Retired`, `ExecBackend.TierOf`; `spawnChildFrom` (spawn without health); `startSidecarChildFrom`; `startSidecarUnitFrom`; `MountGated` in the flat spawn |
+| `internal/activator/containment.go` (modify) | `spawnInCgroupAtTier` |
+| `internal/host/memtier.go` (modify) | `memTierOf` |
| `internal/activator/prepare.go` (modify) | `MountGated` in `listenControl` |
| `internal/activator/claimwake.go` (modify) | `StartPolicy.Pin`, `StartPolicy.Gen`, `StartPolicy.Fallback`, `StartPolicy.HealthBy`; `ClaimStart.Gen`; the checks, the health cap and the one-time fallback in `startAdmitted` |
| `internal/instancectl/client.go`, `server.go`, `stagedexe.go` (modify, create) | The staged executable on the nested generation start, and root's validation of it (Task 9) |
@@ -114,11 +116,11 @@ The cross-plan items this plan owns (index review focus 2 and 4) are Task 12.
**Files:**
- Create: `internal/activator/swap.go`, `internal/activator/genbackend.go`, `internal/activator/staged.go`
-- Modify: `internal/activator/backend_exec.go` (plan 1's `startChildAt`)
-- Test: `internal/activator/genbackend_test.go`
+- Modify: `internal/activator/backend_exec.go` (plan 1's `startChildAt`, `ExecBackend` fields), `internal/host/memtier.go` (`memTierOf`), `internal/host/agent.go` (`TierOf` wiring)
+- Test: `internal/activator/genbackend_test.go`, `internal/host/memtier_test.go` (append)
**Interfaces:**
-- Consumes: C1, C5, C6 and C7.
+- Consumes: C1, C5, C6 and C7; plan 2 Task 10's `placeFor`, `spawnInCgroupAt` and `Cgroups.StartChild(host, child, parent, own, build)`; `release.ReadStagedManifest`, `memTier` (`internal/host/memtier.go`).
- Produces:
```go
type Cause string; const CauseRelease, CauseInstall, CauseRetry, CauseRestart, CauseConfig Cause
@@ -126,18 +128,30 @@ The cross-plan items this plan owns (index review focus 2 and 4) are Task 12.
type Attempt struct{ Host string; Cause Cause; Build, StagedPath, Digest, Demand string; Mode SwapMode; ConfigRev, RestartID string; SchedRev uint64; Canary bool }
type SwapOutcome string; const SwapDone, SwapFailed, SwapAbandoned, SwapDown SwapOutcome
type SwapResult struct{ Outcome SwapOutcome; Gen Gen; Reason string; Drain time.Duration }
+ // Gen gains (index: "a plan may add fields"): Tier cgroup.Limit, the artifact's own tier, captured at attempt time
type SwapBackend interface {
+ // StartStaged spawns and returns at once: no health wait inside. The caller owns the health deadline and the termination.
StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error)
- RaiseGenParent(ctx context.Context, rt registry.Route) error
- RestoreGenParent(ctx context.Context, rt registry.Route) error
+ GenTier(rt registry.Route, build string) cgroup.Limit
+ GenReady(rt registry.Route, g Gen, p Proc)
+ RaiseGenParent(ctx context.Context, rt registry.Route, incumbent, successor cgroup.Limit) error
+ RestoreGenParent(ctx context.Context, rt registry.Route, tier cgroup.Limit) error
GenSwitched(rt registry.Route, g Gen, p Proc) error
}
var errSwapNoGens, errUnitNoGens, errSwapLayout, errNestedStaged error
func verifyStaged(path, digest string) error
- func (b *ExecBackend) startChildFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe string) (Proc, string, error)
+ // ExecBackend gains: TierOf func(rt registry.Route, version string) cgroup.Limit
+ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe string, tier cgroup.Limit) (Proc, string, error) // spawn only
+ func (b *ExecBackend) spawnInCgroupAtTier(rt registry.Route, child string, ownTier bool, tier cgroup.Limit, build func() *exec.Cmd) (*exec.Cmd, error)
+ func memTierOf(load func(rt registry.Route, version string) (*release.Manifest, error)) func(registry.Route, string) cgroup.Limit // internal/host
```
`SwapBackend` is new and separate from plan 1's `GenBackend` (`StartGen(ctx, rt, n, perGen)`, the wake's live-link spawn). Plan 3 never changes plan 1's interface.
+ Three rules this seam keeps (findings 3, 8 and 9 of the P1 review):
+ - **Spawning is not health.** Plan 1's `startChildAt` (and `startUnitAt`) wait for health inside the spawn, and on a failure they stop the process and return a nil handle. A caller that reads nil as "nothing executed" then never records the failure or falls back (spec §8.2), and its own shorter deadline only starts after the backend's 30 s. So the body is split at the health wait: `spawnChildFrom` returns the process the moment it runs, and `startChildAt` is `spawnChildFrom` plus today's health tail, byte for byte. `StartStaged` uses the spawn half only. Every caller (`swapOverlap`, `startPinned`, plan 5's `spawnPlanned`) health-checks through the handle with its own deadline and stops or kills what failed. `GenReady` widens the socket mode once a pinned wake is healthy, which the health tail used to do (issue #103).
+ - **Each artifact's own tier** (spec §7.3, plan 2 D3). `b.tier(rt)` reads the live link's manifest, which root can repoint at any time, and which names neither the incumbent's build nor the successor's after a fallback. `GenTier(rt, build)` reads that build's staged manifest (immutable once referenced, plan 4). The swap captures the incumbent's tier and the successor's at admission into `Gen.Tier`, the parent is raised to their sum, each staged child gets its own `g.Tier`, and the restore uses the current generation's captured tier.
+ - **Foreign rows** keep their canonical socket (plan 1). `StartStaged` spawns one on it, for a cold, pinned or hold start, where only one generation exists at a time. `Swap` runs an overlap attempt on a foreign row as a hold (Task 4).
+
- [ ] **Step 1: Check the plan 1 and 2 contract**
Run:
@@ -167,11 +181,13 @@ import (
"encoding/hex"
"errors"
"os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/registry"
)
@@ -221,9 +237,15 @@ func (r *recordingGenExec) StartStaged(_ context.Context, _ registry.Route, g Ge
r.started++
return g, &fakeProc{}, nil
}
-func (r *recordingGenExec) RaiseGenParent(context.Context, registry.Route) error { return nil }
-func (r *recordingGenExec) RestoreGenParent(context.Context, registry.Route) error { return nil }
-func (r *recordingGenExec) GenSwitched(registry.Route, Gen, Proc) error { return nil }
+func (r *recordingGenExec) GenTier(registry.Route, string) cgroup.Limit { return cgroup.TierDefault }
+func (r *recordingGenExec) GenReady(registry.Route, Gen, Proc) {}
+func (r *recordingGenExec) RaiseGenParent(context.Context, registry.Route, cgroup.Limit, cgroup.Limit) error {
+ return nil
+}
+func (r *recordingGenExec) RestoreGenParent(context.Context, registry.Route, cgroup.Limit) error {
+ return nil
+}
+func (r *recordingGenExec) GenSwitched(registry.Route, Gen, Proc) error { return nil }
// Swaps are for exec rows only (spec §2 decision 9): a unit-backed row is
// refused, and an exec half that cannot run staged generations refuses
@@ -236,7 +258,7 @@ func TestSplitBackendSwapsAreExecOnly(t *testing.T) {
if _, _, err := s.StartStaged(ctx, unitRt, Gen{N: 1}); !errors.Is(err, errUnitNoGens) {
t.Fatalf("unit row StartStaged: %v, want errUnitNoGens", err)
}
- if err := s.RaiseGenParent(ctx, unitRt); !errors.Is(err, errUnitNoGens) {
+ if err := s.RaiseGenParent(ctx, unitRt, cgroup.TierDefault, cgroup.TierDefault); !errors.Is(err, errUnitNoGens) {
t.Fatalf("unit row RaiseGenParent: %v", err)
}
execRt := registry.Route{Host: "e.example.com"}
@@ -252,12 +274,8 @@ func TestSplitBackendSwapsAreExecOnly(t *testing.T) {
// A flat staged generation execs the attempt's staged path, not the live
// link, on its own generation socket (spec §7.5 step 3). The fake binary
-// never binds, so the spawn's own health wait fails. What it recorded as
-// argv[0] is the proof.
+// never binds. What it recorded as argv[0] is the proof.
func TestExecBackendStartStagedExecsTheStagedPath(t *testing.T) {
- old := spawnHealthTimeout
- spawnHealthTimeout = 200 * time.Millisecond
- t.Cleanup(func() { spawnHealthTimeout = old })
b := testExecBackend(t)
b.RunDirOverride = t.TempDir()
host := "staged.example.com"
@@ -281,12 +299,126 @@ func TestExecBackendStartStagedExecsTheStagedPath(t *testing.T) {
}
}
-// A foreign row has no generation socket (plan 1) and never overlaps.
-func TestExecBackendStartStagedRefusesAForeignRow(t *testing.T) {
+// StartStaged is a spawn, not a health check (spec §8.2): it hands back the
+// running process at once, so the caller's own deadline governs and a build
+// that never answers is the caller's to record and kill. This runs the real
+// flat spawn with the real 30 s spawnHealthTimeout. A StartStaged that still
+// waits for health inside blocks for those 30 s and then returns a nil
+// handle, which a planned wake reads as "nothing executed".
+func TestExecBackendStartStagedReturnsTheProcessAtOnce(t *testing.T) {
b := testExecBackend(t)
- rt := registry.Route{Host: "f.example.com", Kind: "instance", Socket: filepath.Join(t.TempDir(), "odd.sock")}
- if _, _, err := b.StartStaged(context.Background(), rt, Gen{N: 1}); !errors.Is(err, errSwapLayout) {
- t.Fatalf("foreign row: %v, want errSwapLayout", err)
+ b.RunDirOverride = t.TempDir()
+ host := "silent.example.com"
+ stageLive(t, b, host, "v1")
+ 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\nexec sleep 30\n"), 0o755); err != nil { // never binds
+ t.Fatal(err)
+ }
+ rt := registry.Route{Host: host, Kind: "instance", DBPath: filepath.Join(b.DataDir, host+".db"),
+ Socket: filepath.Join(b.RunDirOverride, host+".sock")}
+ type out struct {
+ g Gen
+ proc Proc
+ err error
+ }
+ done := make(chan out, 1)
+ go func() {
+ g, p, err := b.StartStaged(context.Background(), rt, Gen{N: 6, Build: "v2", StagedPath: v2})
+ done <- out{g, p, err}
+ }()
+ var o out
+ select {
+ case o = <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("StartStaged waited for health: it must return the running process at once")
+ }
+ if o.err != nil || o.proc == nil {
+ t.Fatalf("StartStaged = %v, %v: want the running process", o.proc, o.err)
+ }
+ defer o.proc.Stop(context.Background()) //nolint:errcheck
+ if o.proc.Exited() {
+ t.Fatal("the returned process has already exited")
+ }
+ if o.g.Socket != b.flatGenSocket(host, 6) {
+ t.Fatalf("Socket = %q, want generation 6's", o.g.Socket)
+ }
+}
+
+// A foreign row keeps its canonical socket (plan 1): a cold, pinned or hold
+// start execs the staged build there, one generation at a time. Refusing it
+// would break every planned wake of a foreign row once the flag is on.
+func TestExecBackendStartStagedRunsAForeignRowOnItsCanonicalSocket(t *testing.T) {
+ b := testExecBackend(t)
+ host := "f.example.com"
+ 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)
+ }
+ rt := registry.Route{Host: host, Kind: "instance", DBPath: filepath.Join(b.DataDir, host+".db"),
+ Socket: filepath.Join(t.TempDir(), "odd.sock")} // hand-set: LayoutForeign
+ if l, _ := b.Layout(rt); l != LayoutForeign {
+ t.Fatalf("fixture layout = %v, want LayoutForeign", l)
+ }
+ g, proc, err := b.StartStaged(context.Background(), rt, Gen{N: 1, Build: "v2", StagedPath: v2})
+ if err != nil {
+ t.Fatalf("foreign row: %v", err)
+ }
+ defer proc.Stop(context.Background()) //nolint:errcheck
+ if g.Socket != rt.Socket || g.Control != rt.Socket+".control" {
+ t.Fatalf("foreign generation sockets %q %q, want the canonical %q", g.Socket, g.Control, rt.Socket)
+ }
+ if got := waitForFile(t, marker, 2*time.Second); got != v2 {
+ t.Fatalf("the generation execd %q, want the staged %q", got, v2)
+ }
+}
+
+// Each artifact's own tier (spec §7.3): a staged child gets g.Tier, not the
+// live link's tier, and the overlap parent is the incumbent's tier plus the
+// successor's. Here the live link (b.Tier) says 512 MiB throughout, while
+// the incumbent declares children (1 GiB) and the successor does not. Using
+// b.tier(rt) for both terms gives a 1 GiB parent, and an incumbent that
+// fills its own 1 GiB is OOM-killed by the successor's first allocation.
+func TestExecBackendUsesEachArtifactsTier(t *testing.T) {
+ const host = "t.example.com"
+ b, parent := delegatedBackend(t, host)
+ b.GenContainment = true
+ b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierDefault }
+ b.TierOf = func(_ registry.Route, v string) cgroup.Limit {
+ if v == "v1" {
+ return cgroup.TierChildren
+ }
+ return cgroup.TierDefault
+ }
+ rt := registry.Route{Host: host}
+ if got := b.GenTier(rt, "v1"); got != cgroup.TierChildren {
+ t.Fatalf("GenTier(v1) = %v, want the children tier", got)
+ }
+ if err := b.RaiseGenParent(context.Background(), rt, b.GenTier(rt, "v1"), b.GenTier(rt, "v2")); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := os.ReadFile(filepath.Join(parent, "memory.max")); err != nil || string(got) != "1610612736" {
+ t.Fatalf("overlap parent memory.max = %q, %v; want 1 GiB + 512 MiB", got, err)
+ }
+ cmd, err := b.spawnInCgroupAtTier(rt, "g9", true, cgroup.TierChildren, func() *exec.Cmd { return exec.Command("/bin/true") })
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = cmd.Wait()
+ if got, err := os.ReadFile(filepath.Join(parent, "g9", "memory.max")); err != nil || string(got) != "1073741824" {
+ t.Fatalf("g9 memory.max = %q, %v; want its own artifact's 1 GiB", got, err)
+ }
+ if err := b.RestoreGenParent(context.Background(), rt, cgroup.TierChildren); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.ReadFile(filepath.Join(parent, "memory.max")); string(got) != "1073741824" {
+ t.Fatalf("restored parent memory.max = %q, want the current generation's 1 GiB", got)
}
}
@@ -311,7 +443,7 @@ func TestExecBackendGenSwitchedRepointsTheCanonicalLink(t *testing.T) {
// and the swap is refused before anything spawns.
func TestExecBackendRaiseNeedsContainment(t *testing.T) {
b := testExecBackend(t)
- if err := b.RaiseGenParent(context.Background(), registry.Route{Host: "h.example.com"}); !errors.Is(err, errNoGenContainment) {
+ if err := b.RaiseGenParent(context.Background(), registry.Route{Host: "h.example.com"}, cgroup.TierDefault, cgroup.TierDefault); !errors.Is(err, errNoGenContainment) {
t.Fatalf("RaiseGenParent without GenContainment: %v", err)
}
}
@@ -322,10 +454,39 @@ var (
)
```
+Append to `internal/host/memtier_test.go`:
+
+```go
+// memTierOf reads the NAMED version's manifest, never the live link's: a
+// generation's limits are its own artifact's (zero-downtime spec §7.3).
+func TestMemTierOfReadsTheNamedVersion(t *testing.T) {
+ rt := registry.Route{Host: "h.example.com", App: "app"}
+ var asked []string
+ tier := memTierOf(func(_ registry.Route, v string) (*release.Manifest, error) {
+ asked = append(asked, v)
+ if v == "v1" {
+ return &release.Manifest{Capabilities: &caps.Set{V: caps.Version, Children: true}}, nil
+ }
+ return &release.Manifest{}, nil
+ })
+ if got := tier(rt, "v1"); got != cgroup.TierChildren {
+ t.Errorf("v1 = %v, want the children tier", got)
+ }
+ if got := tier(rt, "v2"); got != cgroup.TierDefault {
+ t.Errorf("v2 = %v, want the default tier", got)
+ }
+ if strings.Join(asked, ",") != "v1,v2" {
+ t.Errorf("read the manifests of %v, want v1 then v2", asked)
+ }
+}
+```
+
+(add `strings` to that file's imports).
+
- [ ] **Step 3: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestVerifyStaged|TestSplitBackendSwaps|TestExecBackendStartStaged|TestExecBackendGenSwitched|TestExecBackendRaise' -count=1`
-Expected: build failure: `undefined: verifyStaged`, `undefined: SwapBackend`, `b.StartStaged undefined`.
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator ./internal/host -run 'TestVerifyStaged|TestSplitBackendSwaps|TestExecBackendStartStaged|TestExecBackendGenSwitched|TestExecBackendRaise|TestExecBackendUsesEachArtifactsTier|TestMemTierOf' -count=1`
+Expected: build failure: `undefined: verifyStaged`, `undefined: SwapBackend`, `b.StartStaged undefined`, `undefined: memTierOf`.
- [ ] **Step 4: Write the types and `verifyStaged`**
@@ -448,24 +609,87 @@ func verifyStaged(path, digest string) error {
}
```
-- [ ] **Step 5: Give the flat spawn an executable parameter**
+Add a field to plan 1's `Gen` (`internal/activator/generations.go`), after `Demand` (the index allows a plan to add fields), and the `internal/cgroup` import:
-In `backend_exec.go`, rename the body of `startChildAt` (plan 1's, with plan 2's trailing generation number `n`, which places the spawn in its `g<n>` child) to `startChildFrom`, add an `exe string` parameter after `n`, and keep `startChildAt` as a forwarder:
+```go
+ // Tier is the generation's own artifact's tier (zero-downtime spec
+ // §7.3), captured when an attempt is admitted (plan 3). Zero for a
+ // live-link wake until a swap captures it from Build.
+ Tier cgroup.Limit
+```
+
+- [ ] **Step 5: Split the flat spawn from its health wait, and give it an executable and a tier**
+
+Add to `ExecBackend`, after plan 2's `GenContainment` field:
+
+```go
+ // TierOf is one named version's tier, read from that version's staged
+ // manifest (zero-downtime spec §7.3). A generation's limits are its own
+ // artifact's, never whatever the live link names at the moment. Nil
+ // falls back to Tier (tests, and every flag-off spawn).
+ TierOf func(rt registry.Route, version string) cgroup.Limit
+```
+
+In `backend_exec.go`, split plan 1's `startChildAt` body (with plan 2's trailing generation number `n`, which places the spawn in its `g<n>` child) at its health wait. Everything from the top of the body down to, but not including, `if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil {` becomes `spawnChildFrom`, which ends with `return p, spawnVer, nil`. The health tail stays in `startChildAt`, unchanged:
```go
func (b *ExecBackend) startChildAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error) {
- return b.startChildFrom(ctx, rt, sock, n, "")
+ p, spawnVer, err := b.spawnChildFrom(ctx, rt, sock, n, "", cgroup.Limit{})
+ if err != nil {
+ return p, "", err
+ }
+ if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil {
+ stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout)
+ defer cancel()
+ _ = p.Stop(stopCtx)
+ return nil, "", err
+ }
+ b.normalizeSocketMode(rt, sock, p)
+ return p, spawnVer, nil
}
-// startChildFrom is the flat spawn with the executable named. Empty is the
-// live link, which is every wake. A staged path is a swap's successor or a
-// hold's pinned wake (zero-downtime spec §7.5 step 3): the bytes the attempt
-// chose, whatever root does to the live link meanwhile. n is the generation
-// number plan 2's containment places the child by.
-func (b *ExecBackend) startChildFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe string) (Proc, string, error) {
+// spawnChildFrom is the flat spawn with the executable and the tier named,
+// and nothing after the exec: it returns the moment the process runs. Empty
+// exe is the live link, which is every wake (startChildAt adds today's
+// health wait). A staged path is a swap's successor or a pinned wake
+// (zero-downtime spec §7.5 step 3): the bytes the attempt chose, whatever
+// root does to the live link meanwhile, with the caller owning the health
+// deadline and the termination. tier is that artifact's own tier; zero is
+// the live tier (b.tier). n is the generation number plan 2's containment
+// places the child by.
+func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe string, tier cgroup.Limit) (Proc, string, error) {
```
-Inside the body, make three replacements.
+`spawnChildFrom`'s early failure returns keep their shape; a failure after the exec (there is none today once `cmd.Start` succeeded) would return the process with the error, never nil. Inside the body, make four replacements.
+
+Plan 2's spawn call, `cmd, err := b.spawnInCgroupAt(rt, child, ownTier, func() *exec.Cmd {`, becomes:
+```go
+ cmd, err := b.spawnInCgroupAtTier(rt, child, ownTier, tier, func() *exec.Cmd {
+```
+
+and add, beside plan 2's `spawnInCgroupAt` in `containment.go`:
+
+```go
+// spawnInCgroupAtTier is spawnInCgroupAt with the artifact's tier named:
+// the generation child's own limit, and the parent's floor for StartChild
+// (which never lowers a parent a swap has raised). Zero is b.tier(rt), so
+// spawnInCgroupAt is this with a zero tier.
+func (b *ExecBackend) spawnInCgroupAtTier(rt registry.Route, child string, ownTier bool, tier cgroup.Limit, build func() *exec.Cmd) (*exec.Cmd, error) {
+ if child == "" {
+ return b.spawnInCgroup(rt, build)
+ }
+ if tier == (cgroup.Limit{}) {
+ tier = b.tier(rt)
+ }
+ own := cgroup.Limit{}
+ if ownTier {
+ own = tier
+ }
+ return b.Cgroups.StartChild(rt.Host, child, tier, own, build)
+}
+```
+
+Then make plan 2's `spawnInCgroupAt` body a forwarder: `return b.spawnInCgroupAtTier(rt, child, ownTier, cgroup.Limit{}, build)`. (If `main`'s `spawnInCgroup` returns a `settle` func by then, carry it through both exactly as plan 2's merged `spawnInCgroupAt` does.)
The live-link lines:
```go
@@ -507,6 +731,28 @@ The spawn-time cache purge, `if b.Cache != nil {`, becomes:
if b.Cache != nil && exe == "" {
```
+Append to `internal/host/memtier.go`:
+
+```go
+// memTierOf is memTier for one named version: that version's staged
+// manifest, never the live link's (zero-downtime spec §7.3: a generation's
+// limits are its own artifact's). A staged copy is immutable once anything
+// refers to it (plan 4), so the answer for a version does not move.
+func memTierOf(load func(rt registry.Route, version string) (*release.Manifest, error)) func(registry.Route, string) cgroup.Limit {
+ return func(rt registry.Route, version string) cgroup.Limit {
+ return memTier(func(rt registry.Route) (*release.Manifest, error) { return load(rt, version) })(rt)
+ }
+}
+```
+
+In `internal/host/agent.go`, beside `Tier: memTier(stagedManifestOfLiveVersion(release.OptDir())),`, add:
+
+```go
+ TierOf: memTierOf(func(rt registry.Route, v string) (*release.Manifest, error) {
+ return release.ReadStagedManifest(release.OptDir(), release.RouteAccount(rt), v, rt.App)
+ }),
+```
+
- [ ] **Step 6: Write the seam**
Create `internal/activator/genbackend.go`:
@@ -527,7 +773,7 @@ import (
var (
errSwapNoGens = errors.New("swap: backend cannot run staged generations")
errUnitNoGens = errors.New("swap: unit-backed routes keep stop-then-start")
- errSwapLayout = errors.New("swap: only flat and nested rows have generation sockets")
+ errSwapLayout = errors.New("swap: this row's layout has no generation sockets")
errNestedStaged = errors.New("swap: nested staged generations need Task 9")
)
@@ -538,17 +784,26 @@ var (
// decision 9).
type SwapBackend interface {
// StartStaged spawns generation g.N of rt from g.StagedPath, never
- // through the live link, on the generation's own socket and in its own
- // child cgroup. It returns g with Socket and Control filled in. The
- // caller checks health through that handle; a health wait inside is
- // harmless.
+ // through the live link, in its own child cgroup at g.Tier, on the
+ // generation's own socket (a foreign row: its canonical socket). It
+ // returns as soon as the process runs, with g's Socket and Control
+ // filled in, and never waits for health: the caller checks health
+ // through that handle under its own deadline, and stops (or kills) a
+ // process that fails. A nil Proc means nothing ran.
StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error)
- // RaiseGenParent sets the host's cgroup parent to the overlap limit
- // before the successor spawns (spec §7.3).
- RaiseGenParent(ctx context.Context, rt registry.Route) error
- // RestoreGenParent sets the parent back to the tier, once every
- // generation but the current one has proven empty.
- RestoreGenParent(ctx context.Context, rt registry.Route) error
+ // GenTier is build's own tier, from its staged manifest (spec §7.3).
+ // The swap captures it into Gen.Tier at admission.
+ GenTier(rt registry.Route, build string) cgroup.Limit
+ // GenReady runs once a pinned wake's generation is healthy: it widens
+ // the socket's mode (issue #103), as the live-link spawn's health tail
+ // does. A swap's successor gets the same from GenSwitched.
+ GenReady(rt registry.Route, g Gen, p Proc)
+ // RaiseGenParent sets the host's cgroup parent to the incumbent's tier
+ // plus the successor's, before the successor spawns (spec §7.3).
+ RaiseGenParent(ctx context.Context, rt registry.Route, incumbent, successor cgroup.Limit) error
+ // RestoreGenParent sets the parent back to tier, the current
+ // generation's captured tier, once every other generation is empty.
+ RestoreGenParent(ctx context.Context, rt registry.Route, tier cgroup.Limit) error
// GenSwitched runs under the entry lock at the switch (spec §7.5 step
// 5), before the in-memory switch. It points the canonical path at the
// new generation's socket (rev 15), purges the host's cached responses
@@ -557,10 +812,15 @@ type SwapBackend interface {
}
// StartStaged is plan 1's StartGen with perGen true, except for the
-// executable. A flat child execs g.StagedPath directly. A nested unit's
-// executable mount names it, which is Task 9's leg; until then a nested row
-// is refused. A foreign row has no generation path and never overlaps.
+// executable, the tier and the health wait, which is the caller's. A flat
+// child execs g.StagedPath directly. A nested unit's executable mount names
+// it, which is Task 9's leg; until then a nested row is refused. A foreign
+// row keeps its canonical socket (plan 1): it gets one generation at a time
+// (a cold, pinned or hold start), and Swap never overlaps it.
func (b *ExecBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error) {
+ if g.Tier == (cgroup.Limit{}) {
+ g.Tier = b.GenTier(rt, g.Build)
+ }
p := b.pathsFor(rt)
switch p.layout {
case LayoutFlat:
@@ -568,12 +828,19 @@ func (b *ExecBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen)
if err := clearOwnGenSocket(sock); err != nil {
return g, nil, fmt.Errorf("%s: generation socket: %w", rt.Host, err)
}
- proc, _, err := b.startChildFrom(ctx, rt, sock, g.N, g.StagedPath)
+ proc, _, err := b.spawnChildFrom(ctx, rt, sock, g.N, g.StagedPath, g.Tier)
if err != nil {
return g, proc, err
}
g.Socket, g.Control = sock, sock+".control"
return g, proc, nil
+ case LayoutForeign:
+ proc, _, err := b.spawnChildFrom(ctx, rt, rt.Socket, g.N, g.StagedPath, g.Tier)
+ if err != nil {
+ return g, proc, err
+ }
+ g.Socket, g.Control = rt.Socket, rt.Socket+".control"
+ return g, proc, nil
case LayoutNested:
return g, nil, errNestedStaged
case LayoutFenced, LayoutConflict:
@@ -582,19 +849,31 @@ func (b *ExecBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen)
return g, nil, errSwapLayout
}
+// GenTier is build's own tier: TierOf when it is wired (the agent reads
+// the build's staged manifest), the live tier otherwise.
+func (b *ExecBackend) GenTier(rt registry.Route, build string) cgroup.Limit {
+ if b.TierOf != nil && build != "" {
+ return b.TierOf(rt, build)
+ }
+ return b.tier(rt)
+}
+
+// GenReady widens a healthy pinned generation's socket mode (issue #103).
+func (b *ExecBackend) GenReady(rt registry.Route, g Gen, p Proc) {
+ b.normalizeSocketMode(rt, g.Socket, p)
+}
+
// RaiseGenParent sets the parent to the incumbent's tier plus the
-// successor's (plan 2, D3), through plan 2's ParentLimit. That keeps the
-// parent's expected-limit record in step. b.tier reads the live build's
-// manifest, so both terms are the live tier here. See the plan's open
-// question on a tier change across a swap.
-func (b *ExecBackend) RaiseGenParent(ctx context.Context, rt registry.Route) error {
- t := b.tier(rt)
- return b.ParentLimit(ctx, rt, cgroup.OverlapLimit(t, t))
+// successor's (spec §7.3, plan 2 D3), each captured from its own artifact
+// at the attempt's admission, through plan 2's ParentLimit. That keeps the
+// parent's expected-limit record in step.
+func (b *ExecBackend) RaiseGenParent(ctx context.Context, rt registry.Route, incumbent, successor cgroup.Limit) error {
+ return b.ParentLimit(ctx, rt, cgroup.OverlapLimit(incumbent, successor))
}
-// RestoreGenParent puts the parent back at the tier.
-func (b *ExecBackend) RestoreGenParent(ctx context.Context, rt registry.Route) error {
- return b.ParentLimit(ctx, rt, b.tier(rt))
+// RestoreGenParent puts the parent back at the current generation's tier.
+func (b *ExecBackend) RestoreGenParent(ctx context.Context, rt registry.Route, tier cgroup.Limit) error {
+ return b.ParentLimit(ctx, rt, tier)
}
// GenSwitched points the canonical socket path at g's socket first, with
@@ -646,20 +925,34 @@ func (b *SplitBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen
return gb.StartStaged(ctx, rt, g)
}
-func (b *SplitBackend) RaiseGenParent(ctx context.Context, rt registry.Route) error {
+func (b *SplitBackend) GenTier(rt registry.Route, build string) cgroup.Limit {
+ gb, err := b.genExec(rt)
+ if err != nil {
+ return cgroup.TierDefault
+ }
+ return gb.GenTier(rt, build)
+}
+
+func (b *SplitBackend) GenReady(rt registry.Route, g Gen, p Proc) {
+ if gb, err := b.genExec(rt); err == nil {
+ gb.GenReady(rt, g, p)
+ }
+}
+
+func (b *SplitBackend) RaiseGenParent(ctx context.Context, rt registry.Route, incumbent, successor cgroup.Limit) error {
gb, err := b.genExec(rt)
if err != nil {
return err
}
- return gb.RaiseGenParent(ctx, rt)
+ return gb.RaiseGenParent(ctx, rt, incumbent, successor)
}
-func (b *SplitBackend) RestoreGenParent(ctx context.Context, rt registry.Route) error {
+func (b *SplitBackend) RestoreGenParent(ctx context.Context, rt registry.Route, tier cgroup.Limit) error {
gb, err := b.genExec(rt)
if err != nil {
return err
}
- return gb.RestoreGenParent(ctx, rt)
+ return gb.RestoreGenParent(ctx, rt, tier)
}
func (b *SplitBackend) GenSwitched(rt registry.Route, g Gen, p Proc) error {
@@ -673,7 +966,7 @@ func (b *SplitBackend) GenSwitched(rt registry.Route, g Gen, p Proc) error {
- [ ] **Step 7: Run the tests to verify they pass**
-Run: `go build ./... && go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestVerifyStaged|TestSplitBackendSwaps|TestExecBackendStartStaged|TestExecBackendGenSwitched|TestExecBackendRaise' -count=1`
+Run: `go build ./... && go vet ./internal/activator ./internal/host && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator ./internal/host -run 'TestVerifyStaged|TestSplitBackendSwaps|TestExecBackendStartStaged|TestExecBackendGenSwitched|TestExecBackendRaise|TestExecBackendUsesEachArtifactsTier|TestMemTierOf' -count=1`
Expected: `ok github.com/carlosframework/platform/internal/activator`
- [ ] **Step 8: Run the whole package (flag off: wakes still exec the live link)**
@@ -684,8 +977,8 @@ Expected: no gofmt output; `ok`. Plan 1's and 2's spawn tests pass unchanged, be
- [ ] **Step 9: Commit**
```bash
-git add internal/activator/swap.go internal/activator/genbackend.go internal/activator/staged.go internal/activator/backend_exec.go internal/activator/genbackend_test.go
-git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged exec, canonical link at the switch"
+git add internal/activator/swap.go internal/activator/genbackend.go internal/activator/staged.go internal/activator/backend_exec.go internal/activator/containment.go internal/activator/generations.go internal/activator/genbackend_test.go internal/host/memtier.go internal/host/memtier_test.go internal/host/agent.go
+git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged spawn without health, each artifact's tier, canonical link at the switch"
```
---
@@ -1225,6 +1518,12 @@ func (a *Service) stopMember(e *actEntry, m *genMember) bool {
// is the proof (plan 2, "stop evidence on the parent"). Any other error is
// an unproven termination.
func (a *Service) genChildEmpty(e *actEntry, m *genMember) bool {
+ if _, ok := m.proc.(*claimProcess); ok {
+ // A claims-on generation (Task 10): claimProcess.Stop already ran
+ // KillAndEmpty on its own child and succeeded, or Stop failed and
+ // stopMember never got here.
+ return true
+ }
k, ok := a.backend.(genKiller)
if !ok || m.gen.N == 0 {
return true
@@ -1262,16 +1561,18 @@ func (a *Service) dropMember(e *actEntry, m *genMember) bool {
// last member and the parent was raised, the parent is restored FIRST,
// while the member still holds the set. That way no new attempt can raise
// the parent again in between (spec §7.3: the restore happens after the
-// retiring child is empty). done closes last.
+// retiring child is empty). The restore is to the CURRENT generation's
+// captured tier (spec §7.3): the incumbent's after a failed successor, the
+// successor's after a switch. done closes last.
func (a *Service) leaveSet(e *actEntry, m *genMember) {
a.mu.Lock()
restore := e.ops.raised && len(e.ops.members) == 1 && e.ops.members[m.gen.N] == m
- rt := e.route
+ rt, tier := e.route, e.gen.Tier
a.mu.Unlock()
if restore {
if gb, ok := a.backend.(SwapBackend); ok {
rctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
- if err := gb.RestoreGenParent(rctx, rt); err != nil {
+ if err := gb.RestoreGenParent(rctx, rt, tier); err != nil {
log.Printf("activator: restore parent limit for %s: %v", rt.Host, err)
}
cancel()
@@ -1797,11 +2098,13 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/registry"
)
// fakeSwapBackend is fakeBackend plus the swap seam (SwapBackend). Every call is recorded in
-// the shared order log: raise, startGen:<path>, switch:<n>, restore,
+// the shared order log: raise:<incumbent>+<successor>, startGen:<path>,
+// switch:<n>, restore:<tier>,
// startSidecarGen:<path>. Generation children are fakeProcs labelled
// gen:<n>.
type fakeSwapBackend struct {
@@ -1821,6 +2124,9 @@ type fakeSwapBackend struct {
// Task 7: legacy sidecars started from a generation.
sidecarGens []Gen
sidecarGenGate chan struct{}
+ // tiers is each build's own tier (GenTier); a build not here is the
+ // default tier.
+ tiers map[string]cgroup.Limit
}
func (g *fakeSwapBackend) StartStaged(ctx context.Context, _ registry.Route, gen Gen) (Gen, Proc, error) {
@@ -1856,16 +2162,28 @@ func (g *fakeSwapBackend) StartStaged(ctx context.Context, _ registry.Route, gen
return gen, proc, nil
}
-func (g *fakeSwapBackend) RaiseGenParent(context.Context, registry.Route) error {
- g.record("raise")
+// GenTier answers from tiers (by build), and the default tier otherwise.
+func (g *fakeSwapBackend) GenTier(_ registry.Route, build string) cgroup.Limit {
+ g.gmu.Lock()
+ defer g.gmu.Unlock()
+ if t, ok := g.tiers[build]; ok {
+ return t
+ }
+ return cgroup.TierDefault
+}
+
+func (g *fakeSwapBackend) GenReady(registry.Route, Gen, Proc) {}
+
+func (g *fakeSwapBackend) RaiseGenParent(_ context.Context, _ registry.Route, inc, succ cgroup.Limit) error {
+ g.record(fmt.Sprintf("raise:%d+%d", inc.MemoryMax, succ.MemoryMax))
g.gmu.Lock()
g.raises++
g.gmu.Unlock()
return nil
}
-func (g *fakeSwapBackend) RestoreGenParent(context.Context, registry.Route) error {
- g.record("restore")
+func (g *fakeSwapBackend) RestoreGenParent(_ context.Context, _ registry.Route, tier cgroup.Limit) error {
+ g.record(fmt.Sprintf("restore:%d", tier.MemoryMax))
g.gmu.Lock()
g.restores++
g.gmu.Unlock()
@@ -2023,6 +2341,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/registry"
)
@@ -2072,6 +2391,42 @@ func TestSwapOverlapSwitchesAfterHealthAndRetiresTheIncumbent(t *testing.T) {
}
}
+// During the overlap the parent holds the incumbent's tier plus the
+// successor's, each captured from its own artifact (spec §7.3), and the
+// restore is to whichever generation is current afterwards. Here the
+// incumbent (v1) declares children (1 GiB) and the successor (v2) does
+// not (512 MiB). Summing one tier twice gives a 1 GiB parent (or 2 GiB),
+// and restoring to the live tier leaves the wrong limit after a failure.
+func TestSwapOverlapUsesEachArtifactsTier(t *testing.T) {
+ const gib, half = int64(1 << 30), int64(1 << 29)
+ t.Run("switched: restored to the successor's tier", func(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.tiers = map[string]cgroup.Limit{"v1": cgroup.TierChildren, "v2": cgroup.TierDefault}
+ res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
+ if res.Outcome != SwapDone || res.Gen.Tier != cgroup.TierDefault {
+ t.Fatalf("Swap = %+v, want done with the successor's own tier", res)
+ }
+ ev := f.b.events()
+ if indexPrefix(ev, fmt.Sprintf("raise:%d+%d", gib, half)) < 0 || indexPrefix(ev, fmt.Sprintf("restore:%d", half)) < 0 {
+ t.Fatalf("events %v: want raise:%d+%d and restore:%d", ev, gib, half, half)
+ }
+ if g, _ := f.b.lastGen(); g.Tier != cgroup.TierDefault {
+ t.Fatalf("the successor spawned at %v, want its own 512 MiB tier", g.Tier)
+ }
+ })
+ t.Run("failed: restored to the incumbent's tier", func(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.tiers = map[string]cgroup.Limit{"v1": cgroup.TierChildren, "v2": cgroup.TierDefault}
+ f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 503") }
+ if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapFailed {
+ t.Fatalf("Swap = %+v", res)
+ }
+ if ev := f.b.events(); indexPrefix(ev, fmt.Sprintf("restore:%d", gib)) < 0 {
+ t.Fatalf("events %v: want restore:%d (the incumbent is still current)", ev, gib)
+ }
+ })
+}
+
// The "swapped" event carries the retiring generation's drain time (spec
// §9.4), so the result measures it: from SIGTERM to the proven exit.
func TestSwapOverlapReportsTheDrainTime(t *testing.T) {
@@ -2297,7 +2652,7 @@ Add to `Service` in `activator.go` (after `OnWake`, line 682):
genHealth func(ctx context.Context, g Gen) error
```
-Append to `internal/activator/swap.go`, and add its imports (`context`, `errors`, `fmt`, `time`, and `internal/registry`):
+Append to `internal/activator/swap.go`, and add its imports (`context`, `errors`, `fmt`, `time`, `internal/cgroup` and `internal/registry`):
```go
var (
@@ -2384,8 +2739,9 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n
done: make(chan struct{}),
}
var (
- e *actEntry
- rt registry.Route
+ e *actEntry
+ rt registry.Route
+ incumbent Gen // the current generation at admission, for its tier
)
// Step 1: the checks and joining the set, inside the selected work. Only
// the registration runs there. A claims-on spawn re-enters the work
@@ -2413,7 +2769,7 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n
return errSwapBusy
}
a.addMemberLocked(e, m)
- rt = e.route
+ rt, incumbent = e.route, e.gen
return nil
})
if err != nil {
@@ -2423,8 +2779,24 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n
return abandon(err)
}
+ // Each artifact's own tier, captured now (spec §7.3): the incumbent's
+ // from the build it runs, the successor's from the attempt's build.
+ // Neither is read from the live link, which root may repoint. They size
+ // the overlap parent, the successor's child, and the restore (leaveSet
+ // restores to the current generation's Tier).
+ if incumbent.Tier == (cgroup.Limit{}) {
+ incumbent.Tier = gb.GenTier(rt, incumbent.Build)
+ }
+ succTier := gb.GenTier(rt, at.Build)
+ a.mu.Lock()
+ if e.gen.N == incumbent.N {
+ e.gen.Tier = incumbent.Tier
+ }
+ m.gen.Tier = succTier
+ a.mu.Unlock()
+
// Step 2: the parent raise, before the successor spawns (spec §7.3).
- if err := gb.RaiseGenParent(ctx, rt); err != nil {
+ if err := gb.RaiseGenParent(ctx, rt, incumbent.Tier, succTier); err != nil {
a.dropMember(e, m)
return abandon(fmt.Errorf("raise parent limit: %w", err))
}
@@ -2818,7 +3190,7 @@ git commit -m "zdd plan 3 task 5: canary settle; an intentional stop cancels it
**Files:**
- Create: `internal/activator/swaphold.go`, `internal/activator/swap_hold_test.go`
- Modify:
- - `internal/activator/activator.go`: the `actEntry` fields; `doWake` step 4 (lines 1281–1283); `finishStop` (lines 2369–2375);
+ - `internal/activator/activator.go`: the `actEntry` fields; `doWake` step 4 (lines 1281–1283); `beginStopLocked`; `finishStop` (lines 2369–2375);
- `internal/activator/swap.go`: `Swap`'s mode switch;
- `internal/activator/claimwake.go`: `StartPolicy` (line 21), `ClaimRuntime` (line 34), `doClaimWake` (line 108), `startAdmitted` (after line 159).
@@ -2826,16 +3198,18 @@ git commit -m "zdd plan 3 task 5: canary settle; an intentional stop cancels it
- Consumes: Tasks 1–4; C3 (`w.gen`, copied into `e.gen` by `wake()`); `cleanupFailedWake`.
- Produces:
```go
- // actEntry gains: pin *Gen; holdNext *holdSpec
- type holdSpec struct{ gen Gen; at Attempt; newest func(string) uint64; woke chan holdWoke }
+ // actEntry gains: pin *Gen; holdNext *holdSpec; settleCancel context.CancelFunc
+ type holdSpec struct{ gen Gen; at Attempt; newest func(string) uint64; woke chan holdWoke; settleCancel context.CancelFunc }
type holdWoke struct{ next *actEntry; err error }
func (a *Service) swapHold(ctx context.Context, at Attempt, newest func(string) uint64) SwapResult
func (a *Service) holdWakeLocked(e *actEntry, h *holdSpec) (*actEntry, error)
func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) (Proc, string, Gen, error)
func (a *Service) lifeDone() <-chan struct{}
+ func (a *Service) foreignRow(host string) bool // Swap runs an overlap attempt on a foreign row as a hold
var errHoldNeedsPins error
// StartPolicy gains: Pin *Gen. ClaimRuntime gains: Pins bool.
```
+ **A `hold` canary settles too** (spec §§6.2, 6.4, 8.5). Plan 5 sends a pinned wake straight to `startPinned` and does not run its own first-wake observer on it, so the observation is this task's. When `at.Canary` is set, `swapHold` watches the new generation for `swapSettle` (Task 5's `observeSettle`) after it is healthy, and only then answers `SwapDone`. An exit in the window is `SwapDown` with `errSettleExit`, and nothing falls back. An intentional stop of the new entry in the window cancels the observation (`settleCancel`, called by `beginStopLocked` while the process has not exited) and is `SwapAbandoned`, never a failure. A process that had already exited when the stop began died of its build, so its stop does not cancel.
- [ ] **Step 1: Write the failing tests**
@@ -2912,6 +3286,73 @@ func TestSwapWitnessHoldExecsTheStagedBuild(t *testing.T) {
}
}
+// A hold canary is not done when it is healthy: it must survive the 10 s
+// settle (spec §6.2, §8.5). Without the settle, swapHold answers SwapDone
+// the moment the pinned wake is ready, and a crash a second later neither
+// fails the attempt nor halts the rollout.
+func TestSwapHoldCanarySettles(t *testing.T) {
+ old := swapSettle
+ t.Cleanup(func() { swapSettle = old })
+ start := func(t *testing.T, f *swapFixture) (chan SwapResult, Gen, *fakeProc) {
+ t.Helper()
+ at := f.attempt(ModeHold)
+ at.Canary = true
+ results := make(chan SwapResult, 1)
+ go func() { results <- f.a.Swap(context.Background(), at, f.newest) }()
+ var g Gen
+ var p *fakeProc
+ waitFor(t, "the pinned generation awake", func() bool {
+ g, p = f.b.lastGen()
+ f.a.mu.Lock()
+ defer f.a.mu.Unlock()
+ e := f.a.entries[f.host]
+ return p != nil && e != nil && e.state == actAwake && e.gen.N == g.N
+ })
+ return results, g, p
+ }
+ t.Run("an exit in the window is down, with no fallback", func(t *testing.T) {
+ swapSettle = 5 * time.Second
+ f := newSwapFixture(t)
+ results, _, p := start(t, f)
+ select {
+ case res := <-results:
+ t.Fatalf("Swap answered %+v before the settle ended", res)
+ case <-time.After(50 * time.Millisecond):
+ }
+ p.exit()
+ res := <-results
+ if res.Outcome != SwapDown || !strings.Contains(res.Reason, errSettleExit.Error()) {
+ t.Fatalf("Swap = %+v, want down: %v", res, errSettleExit)
+ }
+ if gens, _, _ := f.b.calls(); gens != 1 || f.b.startCount() != 1 {
+ t.Fatalf("a failed hold canary fell back: StartGen %d, Start %d", gens, f.b.startCount())
+ }
+ })
+ t.Run("surviving the window is done", func(t *testing.T) {
+ swapSettle = 100 * time.Millisecond
+ f := newSwapFixture(t)
+ began := time.Now()
+ results, g, _ := start(t, f)
+ res := <-results
+ if res.Outcome != SwapDone || res.Gen.N != g.N {
+ t.Fatalf("Swap = %+v", res)
+ }
+ if took := time.Since(began); took < swapSettle {
+ t.Fatalf("done after %s, inside the %s settle", took, swapSettle)
+ }
+ })
+ t.Run("an intentional stop in the window is not a failure", func(t *testing.T) {
+ swapSettle = 5 * time.Second
+ f := newSwapFixture(t)
+ results, _, _ := start(t, f)
+ f.a.Bounce(f.host)
+ res := <-results
+ if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapStopped.Error()) {
+ t.Fatalf("Swap = %+v, want abandoned: %v", res, errSwapStopped)
+ }
+ })
+}
+
// A hold build that fails its health check leaves nothing serving and does
// not fall back (spec §8.3).
func TestSwapHoldFailureIsDown(t *testing.T) {
@@ -3031,7 +3472,7 @@ func TestStartAdmittedRefusesAPinMismatch(t *testing.T) {
- [ ] **Step 2: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessHold|TestSwapHold|TestStartAdmittedRefusesAPin' -count=1`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessHold|TestSwapHold|TestStartAdmittedRefusesAPin|TestSwapOverlapOnAForeignRow' -count=1`
Expected: build failure: `undefined: errHoldNeedsPins`, `unknown field Pin in struct literal of type StartPolicy`.
- [ ] **Step 3: Write `swaphold.go`**
@@ -3047,6 +3488,7 @@ import (
"fmt"
"time"
+ "github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/registry"
)
@@ -3059,6 +3501,10 @@ type holdSpec struct {
at Attempt
newest func(string) uint64
woke chan holdWoke // buffered 1
+ // settleCancel ends a canary's settle observation. holdWakeLocked puts
+ // it on the pinned entry, so an intentional stop of that entry cancels
+ // the observation before anything is stopped (spec §6.4).
+ settleCancel context.CancelFunc
}
// holdWoke is finishStop's answer: the pinned wake it installed, or why it
@@ -3094,12 +3540,17 @@ func (a *Service) swapHold(ctx context.Context, at Attempt, newest func(string)
if err != nil {
return abandon(fmt.Errorf("admission: %w", err))
}
+ sctx, scancel := context.WithCancel(ctx)
+ defer scancel()
h := &holdSpec{
gen: Gen{Build: at.Build, StagedPath: at.StagedPath, Digest: at.Digest, Demand: at.Demand},
at: at,
newest: newest,
woke: make(chan holdWoke, 1),
}
+ if at.Canary {
+ h.settleCancel = scancel
+ }
var stopAt time.Time // wall clock: the old generation's stop, for the drain time (spec §9.4)
err = a.admit(ctx, work, func() error {
a.mu.Lock()
@@ -3134,9 +3585,25 @@ func (a *Service) swapHold(ctx context.Context, at Attempt, newest func(string)
}
<-hw.next.ready
a.mu.Lock()
- g, werr := hw.next.gen, hw.next.err
+ g, werr, proc := hw.next.gen, hw.next.err, hw.next.proc
a.mu.Unlock()
switch {
+ case werr == nil && at.Canary:
+ // A canary must survive the settle before it counts (spec §6.2,
+ // §8.5). Plan 5 does not observe a pinned wake itself.
+ r := observeSettle(sctx, proc, swapSettle)
+ a.mu.Lock()
+ hw.next.settleCancel = nil
+ a.mu.Unlock()
+ switch r {
+ case settleExited:
+ // Nothing falls back from a hold (spec §8.3): the instance is
+ // down until the sweep notices the exit and a later wake runs.
+ return SwapResult{Outcome: SwapDown, Gen: g, Reason: errSettleExit.Error(), Drain: drain}
+ case settleCancelled:
+ return abandon(errSwapStopped)
+ }
+ return SwapResult{Outcome: SwapDone, Gen: g, Drain: drain}
case werr == nil:
return SwapResult{Outcome: SwapDone, Gen: g, Drain: drain}
case errors.Is(werr, ErrFenced) || errors.Is(werr, context.Canceled):
@@ -3159,15 +3626,19 @@ func (a *Service) holdWakeLocked(e *actEntry, h *holdSpec) (*actEntry, error) {
return nil, errSwapSuperseded
}
pin := h.gen
- next := &actEntry{host: e.host, route: e.route, state: actWaking, ready: make(chan struct{}), pin: &pin}
+ next := &actEntry{host: e.host, route: e.route, state: actWaking, ready: make(chan struct{}), pin: &pin,
+ settleCancel: h.settleCancel}
a.entries[e.host] = next
return next, nil
}
// startPinned is a hold's spawn: the attempt's staged build, verified and
-// exec'd by path, then the health check through the generation handle.
-// The proc comes back even on failure, so doWake's cleanupFailedWake stops
-// it and records the outcome.
+// exec'd by path at its own tier, then the health check through the
+// generation handle. StartStaged returns as soon as the process runs (Task
+// 1), so this health check, and its deadline, is the only one. The proc
+// comes back even on failure, so doWake's cleanupFailedWake stops it and
+// records the outcome, and a caller that reads a nil proc as "nothing ran"
+// is right to.
func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) (Proc, string, Gen, error) {
gb, ok := a.backend.(SwapBackend)
if !ok {
@@ -3177,6 +3648,9 @@ func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) (
return nil, "", pin, fmt.Errorf("staging: %w", err)
}
pin.N = nextGen()
+ if pin.Tier == (cgroup.Limit{}) {
+ pin.Tier = gb.GenTier(rt, pin.Build) // the pinned artifact's own tier (spec §7.3)
+ }
g, proc, err := gb.StartStaged(ctx, rt, pin)
if err != nil {
return proc, "", pin, err
@@ -3187,23 +3661,88 @@ func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) (
if err := a.genHealthy(ctx, g); err != nil {
return proc, "", g, err
}
+ gb.GenReady(rt, g, proc)
return proc, g.Build, g, nil
}
```
- [ ] **Step 4: Wire the hold into the activator and the claims path**
-`swap.go`, in `Swap`'s mode switch, add the hold case:
+`swap.go`, in `Swap`'s mode switch, add the hold case, and send an overlap attempt on a foreign row through the hold:
```go
switch at.Mode {
case ModeOverlap:
+ if a.foreignRow(at.Host) {
+ // A foreign row keeps its canonical socket (plan 1), so two
+ // generations of it cannot coexist: its replacement is a hold
+ // (plan 1's reading, accepted in the index). Its result reads
+ // as a hold's: SwapDown when the new build fails.
+ return a.swapHold(ctx, at, newest)
+ }
return a.swapOverlap(ctx, gb, at, newest)
case ModeHold:
return a.swapHold(ctx, at, newest)
}
```
+and add to `swaphold.go`:
+
+```go
+// foreignRow reports whether host's row has the foreign layout (a
+// hand-set socket path), which keeps its canonical socket.
+func (a *Service) foreignRow(host string) bool {
+ c, ok := a.backend.(Classifier)
+ if !ok {
+ return false
+ }
+ a.mu.Lock()
+ e := a.entries[host]
+ var rt registry.Route
+ if e != nil {
+ rt = e.route
+ }
+ a.mu.Unlock()
+ if e == nil {
+ return false
+ }
+ l, _ := c.Layout(rt)
+ return l == LayoutForeign
+}
+```
+
+`swapOverlap` keeps its own layout check (`errSwapLayout`) under the admission lock, as the backstop for a row that turns foreign between this read and the admission.
+
+In Task 4's `TestSwapRefusals`, delete the `"foreign layout"` case (an overlap on a foreign row is no longer refused), and add to `swap_hold_test.go`:
+
+```go
+// A foreign row keeps its canonical socket, so an overlap attempt on it
+// runs as a hold (plan 1's reading): the old process stops, then the staged
+// build starts on the same socket. Refusing it (errSwapLayout) left a
+// foreign row unable to take any release with the flag on.
+func TestSwapOverlapOnAForeignRowRunsAsAHold(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutForeign, "hand-set socket" }
+ incumbent := f.b.lastProc()
+ res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
+ if res.Outcome != SwapDone || res.Gen.StagedPath != f.staged {
+ t.Fatalf("Swap = %+v, want done through the hold", res)
+ }
+ ev := f.b.events()
+ if i, j := slices.Index(ev, "stop:instance"), indexPrefix(ev, "startGen:"+f.staged); i < 0 || j < 0 || i > j {
+ t.Fatalf("order %v: a foreign row stops before its replacement starts", ev)
+ }
+ if indexPrefix(ev, "raise") >= 0 {
+ t.Fatalf("events %v: a foreign row never raises an overlap parent", ev)
+ }
+ if !incumbent.isStopped() {
+ t.Fatal("the old process outlived the hold")
+ }
+}
+```
+
+(add `slices` to that file's imports).
+
`activator.go`, add to `actEntry` after `ops opSet`:
```go
@@ -3216,6 +3755,22 @@ func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) (
// holdNext is a hold swap waiting for this entry's stop to finish.
// finishStop installs its pinned wake. Written under a.mu.
holdNext *holdSpec
+ // settleCancel, on a hold canary's pinned entry, ends its settle
+ // observation. beginStopLocked calls it while the process has not
+ // exited, so an intentional stop is never read as a crash (spec §6.4).
+ // Written under a.mu.
+ settleCancel context.CancelFunc
+```
+
+`activator.go`, `beginStopLocked`: insert as its first statement:
+
+```go
+ // A hold canary's settle ends with an intentional stop, before anything
+ // is stopped (zero-downtime spec §6.4). A process that already exited
+ // died of its build, and its stop must not hide that.
+ if e.settleCancel != nil && (e.proc == nil || !e.proc.Exited()) {
+ e.settleCancel()
+ }
```
`activator.go`, `doWake` step 4. Plan 1 has turned the `else` arm at lines 1281–1283 into a call that also fills `w.gen` (contract C3). Insert the pinned arm before it:
@@ -3302,7 +3857,7 @@ In `startAdmitted`, after the `if !s.valid() || ...` block (line 157–159), ins
- [ ] **Step 5: Run the tests to verify they pass**
-Run: `go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessHold|TestSwapHold|TestStartAdmittedRefusesAPin' -count=1 -v 2>&1 | tail -20`
+Run: `go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessHold|TestSwapHold|TestStartAdmittedRefusesAPin|TestSwapOverlapOnAForeignRow' -count=1 -v 2>&1 | tail -20`
Expected: every test `--- PASS`, then `ok`.
- [ ] **Step 6: Run the whole package**
@@ -3330,17 +3885,21 @@ git commit -m "zdd plan 3 task 6: the hold swap, a pinned wake installed by fini
- Test: `internal/activator/swap_sidecar_test.go`; `swap_fixture_test.go` (append `StartSidecarGen`)
**Interfaces:**
-- Consumes: C2 (`e.gen`), C3 (`w.gen`); `startSidecarUnit`, `LiveVersion`, `stopDiscardedSidecar`, the `sidecarStarting`/`sidecarStartDone` interlock.
+- Consumes: C2 (`e.gen`), C3 (`w.gen`); plan 2 Task 10's `placeFor(spawnSidecar, 0)`, `spawnInCgroupAt` and the `role-sidecar` child's `RemoveChild` in `startSidecarChild`; plan 2's `delegatedBackend` test helper; `stopDiscardedSidecar`, the `sidecarStarting`/`sidecarStartDone` interlock.
- Produces:
```go
type SidecarGenStarter interface{ StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error) }
- var errNestedSidecarGen error
+ var errNestedSidecarGen error // nested arm until Task 9 gives it the staged executable
func (b *ExecBackend) StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error)
- func (b *ExecBackend) startSidecarChildFrom(ctx context.Context, rt registry.Route, bin string) (Proc, error)
+ func (b *ExecBackend) startSidecarChildFrom(ctx context.Context, rt registry.Route, bin string) (Proc, error) // plan 2's body, placement kept; only the binary is a parameter
func (b *SplitBackend) StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error)
func (a *Service) startSidecar(ctx context.Context, rt registry.Route, g Gen) (Proc, error)
- func (a *Service) replaceSidecar(e *actEntry)
+ func (a *Service) replaceSidecar(e *actEntry) // joins an in-flight supervised start, then replaces
```
+ Three rules this task keeps (findings 2, 10 and 11 of the P1 review):
+ - **Placement.** `startSidecarChildFrom` is plan 2 Task 10's `startSidecarChild` body with one change: the binary. Plan 2's `placeFor(spawnSidecar, 0)`, `spawnInCgroupAt(rt, child, false, …)` and the goroutine's `RemoveChild(rt.Host, child)` stay. Going back to `spawnInCgroup` would put the tier and `memory.oom.group=1` back on the generation parent (spec §7.3) and, with controllers delegated, try to place a process in a parent that must hold none.
+ - **No live-link read for a nested sidecar.** The nested unit binds whatever root lays out, so a check of `LiveVersion` before the start is a race with a repoint, and after a fallback it refuses for ever. Until Task 9 the nested arm refuses outright (`errNestedSidecarGen`), the way Task 1's nested `StartStaged` does. Task 9 hands root the generation's captured staged executable, which root validates under the same rule as the run unit's.
+ - **Replacement is never skipped.** A supervised restart in flight captured the generation it read when it began, which can be the one just retired. `replaceSidecar` waits for it (`sidecarStartDone`) and then replaces whatever it installed. The supervisor's own completion also discards a sidecar started for a generation that is no longer current.
- [ ] **Step 1: Write the failing tests**
@@ -3385,7 +3944,6 @@ import (
"os"
"path/filepath"
"slices"
- "strings"
"testing"
"time"
@@ -3497,9 +4055,96 @@ func TestSwapSidecarReplacementHoldsTheSupervisorOff(t *testing.T) {
}
}
+// 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) }()
+ time.Sleep(50 * time.Millisecond)
+ 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 either by its own completion (its
+ // generation was retired) or 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")
+ }
+}
+
+// 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", Socket: filepath.Join(t.TempDir(), "s.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 binds the live build, so it starts only while that is the
-// serving generation's build, and is refused otherwise.
+// 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) {
t.Run("flat", func(t *testing.T) {
b := testExecBackend(t)
@@ -3534,12 +4179,10 @@ func TestExecBackendStartSidecarGen(t *testing.T) {
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")}
- if _, err := b.StartSidecarGen(context.Background(), rt, Gen{Build: "v2"}); !errors.Is(err, errNestedSidecarGen) {
- t.Fatalf("live v1, serving v2: %v, want errNestedSidecarGen", err)
- }
- _, err := b.StartSidecarGen(context.Background(), rt, Gen{Build: "v1"})
- if err == nil || errors.Is(err, errNestedSidecarGen) || !strings.Contains(err.Error(), "instance control channel not configured") {
- t.Fatalf("live and serving agree: %v, want the nested start (refused here only for its missing control channel)", err)
+ 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)
+ }
}
})
}
@@ -3549,12 +4192,12 @@ var _ SidecarGenStarter = (*SplitBackend)(nil)
- [ ] **Step 2: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestExecBackendStartSidecarGen' -count=1`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestExecBackendStartSidecarGen|TestStartSidecarGenKeeps' -count=1`
Expected: build failure: `undefined: SidecarGenStarter`, `b.StartSidecarGen undefined`, `undefined: errNestedSidecarGen`.
- [ ] **Step 3: Write the backend side**
-`backend_exec.go`: replace `startSidecarChild` (lines 1315–1342) with a forwarder plus the body, which now takes the binary:
+`backend_exec.go`: rename the body of `startSidecarChild` **as plan 2 Task 10 left it** to `startSidecarChildFrom`, add a `bin string` parameter, and keep `startSidecarChild` as a forwarder. The only line of the body that changes is the `exec.Command`: `b.liveLink(rt.Host)` becomes `bin`. Plan 2's placement stays exactly as it is: `placeFor(spawnSidecar, 0)`, `spawnInCgroupAt(rt, child, false, …)` and the goroutine's `RemoveChild(rt.Host, child)`, plus anything else `main` has in that body when you cut the branch (a strict `settle`, `cleanupHost`). The result, as plan 2 writes it:
```go
func (b *ExecBackend) startSidecarChild(ctx context.Context, rt registry.Route) (Proc, error) {
@@ -3563,20 +4206,27 @@ func (b *ExecBackend) startSidecarChild(ctx context.Context, rt registry.Route)
// 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).
+// 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)
}
- // Same host cgroup as the instance: a sidecar's load is that instance's
- // work, and attributing it elsewhere would be a lie in the other
- // direction.
- cmd, err := b.spawnInCgroup(rt, func() *exec.Cmd {
+ child, _, err := b.placeFor(spawnSidecar, 0)
+ if err != nil {
+ return nil, err
+ }
+ // Same host cgroup as the instance (its role-sidecar child under
+ // containment): a sidecar's load is that instance's work, and
+ // attributing it elsewhere would be a lie in the other direction.
+ cmd, err := b.spawnInCgroupAt(rt, child, false, func() *exec.Cmd {
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
+ // (journald under systemd), teed into the log sink when armed. A
// sidecar's output is the app's output.
b.teeStd(cmd, rt)
return cmd
@@ -3587,6 +4237,7 @@ func (b *ExecBackend) startSidecarChildFrom(_ context.Context, rt registry.Route
p := &childProc{cmd: cmd, done: make(chan struct{})}
go func() {
p.err = cmd.Wait()
+ b.Cgroups.RemoveChild(rt.Host, child) // no-op for "" (flag off)
b.Cgroups.CleanupHost(rt.Host)
close(p.done)
}()
@@ -3594,10 +4245,12 @@ func (b *ExecBackend) startSidecarChildFrom(_ context.Context, rt registry.Route
}
```
+Check that placement survived: `grep -n "placeFor(" internal/activator/backend_exec.go` still prints exactly three lines (`spawnInstance, n`, `spawnSidecar`, `spawnSeed`), and `grep -n "spawnInCgroup(" internal/activator/backend_exec.go` prints no sidecar call.
+
Append to `genbackend.go`, adding the `fmt` import:
```go
-var errNestedSidecarGen = errors.New("nested sidecar: the live build is not the serving generation's build")
+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
@@ -3607,18 +4260,17 @@ type SidecarGenStarter interface {
}
// StartSidecarGen execs g.StagedPath for a flat (or foreign) host. A
-// nested host's sidecar unit binds the host's live build (instancectl's
-// ensureExe), not a generation's, so it may start only while the two
-// agree. Otherwise it is refused. The supervisor retries with its backoff
-// rather than run a build its instance is not serving.
+// 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:
- if cur, err := b.LiveVersion(rt.Host); err != nil || cur != g.Build {
- return nil, errNestedSidecarGen
- }
- return b.startSidecarUnit(ctx, rt)
+ return nil, errNestedSidecarGen
case LayoutFenced, LayoutConflict:
return nil, fmt.Errorf("%s: refusing to start sidecar: %s", rt.Host, p.reason)
}
@@ -3661,9 +4313,24 @@ func (a *Service) startSidecar(ctx context.Context, rt registry.Route, g Gen) (P
// 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()
- if a.entries[e.host] != e || e.state != actAwake || !e.sidecarWanted || e.sidecarStarting {
+ 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 {
a.mu.Unlock()
return
}
@@ -3723,6 +4390,20 @@ func (a *Service) replaceSidecar(e *actEntry) {
sc, err := a.startSidecar(ctx, route, gen)
```
+In the same goroutine, directly after the existing `if e0.state != actAwake { … stopDiscardedSidecar(sc); return }` block, add:
+
+```go
+ 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). The swap's replaceSidecar is waiting on
+ // this goroutine and starts the current generation's
+ // sidecar next; nothing old is installed meanwhile.
+ stopDiscardedSidecar(sc)
+ return
+ }
+```
+
`swap.go`, in `swapOverlap`, step 8: replace the final `return res` with:
```go
@@ -3733,7 +4414,7 @@ func (a *Service) replaceSidecar(e *actEntry) {
- [ ] **Step 5: Run the tests to verify they pass**
-Run: `go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestExecBackendStartSidecarGen' -count=1 -v 2>&1 | tail -20`
+Run: `go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestExecBackendStartSidecarGen|TestStartSidecarGenKeeps' -count=1 -v 2>&1 | tail -20`
Expected: every subtest `--- PASS`, then `ok`.
- [ ] **Step 6: Run the whole package (the existing sidecar tests are the flag-off proof)**
@@ -4084,11 +4765,11 @@ A refusal answers 403. The client turns that into `ErrStagedExe`, and the swap i
**Files:**
- Create: `internal/instancectl/stagedexe.go`, `internal/instancectl/stagedexe_linux_test.go`, `internal/instancectl/stagedexe_test.go`, `internal/activator/swap_nested_test.go`
- Modify:
- - `internal/instancectl/server.go`: `Server` fields; `Run`; plan 2's `startGen` and its `/start` query parsing;
+ - `internal/instancectl/server.go`: `Server` fields; `Run`; plan 2's `startGen`, `handleStart`'s original-template path, and the `/start` query parsing;
- `internal/instancectl/client.go`: `StartGenExe`;
- `internal/activator/containment.go`: plan 2's `GenControl`, `startRole`;
- - `internal/activator/backend_exec.go`: plan 1's `startUnitAt`;
- - `internal/activator/genbackend.go`: the nested arm of `StartStaged`;
+ - `internal/activator/backend_exec.go`: plan 1's `startUnitAt`; `startSidecarUnit`;
+ - `internal/activator/genbackend.go`: the nested arms of `StartStaged` and `StartSidecarGen`;
- `internal/activator/swap.go`, `swaphold.go`: a refusal is an abandonment.
**Interfaces:**
@@ -4103,11 +4784,18 @@ A refusal answers 403. The client turns that into `ErrStagedExe`, and the swap i
func (s *Server) ensureGenExeAt(host string, n uint64, x StagedExe) error
func registryHostApp(path string) func(host string) (string, string, error)
func (c *Client) StartGenExe(ctx context.Context, host string, n uint64, memoryMax int64, x StagedExe) (string, error)
+ func (s *Server) ensureHostExeAt(host string, x StagedExe) error
+ func (c *Client) StartSidecarExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (string, error)
// activator
// GenControl gains: StartGenExe(ctx, host, n, memoryMax, x instancectl.StagedExe) (string, error)
+ // StartSidecarExe(ctx, host, memoryMax, x instancectl.StagedExe) (string, error)
// startRole gains a trailing exe *instancectl.StagedExe parameter (nil: plan 2's StartGen, the live link)
- func (b *ExecBackend) startUnitFrom(ctx context.Context, rt registry.Route, sock string, gen uint64, exe *instancectl.StagedExe) (Proc, string, error)
+ func (b *ExecBackend) spawnUnitFrom(ctx context.Context, rt registry.Route, sock string, gen uint64, exe *instancectl.StagedExe, tier cgroup.Limit) (Proc, string, error) // spawn only; startUnitAt = this + the health tail
+ // startRole gains a second trailing parameter, tier cgroup.Limit (zero: b.tier(rt))
+ func (b *ExecBackend) startSidecarUnitFrom(ctx context.Context, rt registry.Route, exe *instancectl.StagedExe) (Proc, error)
+ // ExecBackend.StartSidecarGen's nested arm (Task 7's errNestedSidecarGen) now starts the sidecar unit on g's staged executable
```
+ **The nested legacy sidecar** (spec §7.5, "every legacy co-spawned sidecar runs its instance's serving artifact") gets the same treatment as the run unit. Its unit, `carlos-instance-sidecar@<id>`, binds `<ExeRoot>/<id>/app`, which root's `ensureHostExe` points at the live link. A `/start` for the sidecar role may now carry the generation's captured `exe`, `version` and `digest`. Root runs the same `validateStagedExe` (the host's own app's `VersionPath`, opened without following a symlink, the digest equal), answers 403 on a refusal, and only then points that `app` link at the staged build. The sidecar therefore never depends on the live link, so neither a fallback (serving differs from installed) nor a repoint during the start changes what it runs. A run-role start on the original template still refuses `exe`: only generation starts and the sidecar take one.
- [ ] **Step 1: Check the import graph**
@@ -4124,14 +4812,19 @@ Create `internal/instancectl/stagedexe_linux_test.go` (Linux only: `OpenBeneath`
package instancectl
import (
+ "context"
"crypto/sha256"
"encoding/hex"
"errors"
+ "net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -4190,6 +4883,51 @@ func TestValidateStagedExe(t *testing.T) {
t.Errorf("no registry view: %v, want ErrStagedExe", err)
}
}
+
+// The nested legacy sidecar runs the serving generation's staged build
+// (spec §7.5), validated by root under the run unit's rule before its
+// executable mount is pointed at it. The live link names another build
+// throughout and makes no difference. A refused path answers 403 and
+// leaves the mount alone. Before this task a sidecar /start could not name
+// an executable at all, so the sidecar bound the live link.
+func TestSidecarStartMountsTheValidatedStagedExe(t *testing.T) {
+ opt, exeRoot := t.TempDir(), t.TempDir()
+ staged := release.VersionPath(opt, release.OpsAccount, "v2", "blog")
+ digest := writeStaged(t, staged, "build v2")
+ _, sock := serveOnSocket(t, &fakeCtl{}, func(s *Server) {
+ s.DropInDir, s.ExeRoot, s.OptDir = t.TempDir(), exeRoot, opt
+ s.HostApp = func(string) (string, string, error) { return release.OpsAccount, "blog", nil }
+ })
+ c := NewClient(sock)
+ app := filepath.Join(exeRoot, provision.InstanceID(genHost), "app")
+ if _, err := c.StartSidecarExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax,
+ StagedExe{Path: staged, Version: "v2", Digest: digest}); err != nil {
+ t.Fatalf("the host's own staged build was refused for its sidecar: %v", err)
+ }
+ if got, err := os.Readlink(app); err != nil || got != staged {
+ t.Fatalf("the sidecar's exe = %q (%v), want the staged %q", got, err, staged)
+ }
+ for name, x := range map[string]StagedExe{
+ "bytes that differ": {Path: staged, Version: "v2", Digest: strings.Repeat("0", 64)},
+ "a path outside": {Path: "/etc/passwd", Version: "v2", Digest: digest},
+ } {
+ if _, err := c.StartSidecarExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax, x); !errors.Is(err, ErrStagedExe) {
+ t.Errorf("%s: %v, want ErrStagedExe", name, err)
+ }
+ if got, _ := os.Readlink(app); got != staged {
+ t.Errorf("%s: a refused start moved the mount to %q", name, got)
+ }
+ }
+ // A run-role start on the original template names no executable.
+ q := hostRole(genHost, RoleRun)
+ q.Set("memory_max", strconv.FormatInt(cgroup.TierDefault.MemoryMax, 10))
+ q.Set("exe", staged)
+ q.Set("version", "v2")
+ q.Set("digest", digest)
+ if code, _ := c.do(context.Background(), DeadlineControl, http.MethodPost, "/start", q, nil); code != http.StatusBadRequest {
+ t.Errorf("a run-role start with exe answered %d, want 400", code)
+ }
+}
```
Create `internal/instancectl/stagedexe_test.go`:
@@ -4284,6 +5022,7 @@ import (
"context"
"errors"
"fmt"
+ "os"
"strings"
"sync"
"testing"
@@ -4295,13 +5034,25 @@ import (
// exeControl is a root control channel with the generation verbs. Its plain
// StartGen refuses, so a test sees which start was used.
type exeControl struct {
- mu sync.Mutex
- got []instancectl.StagedExe
+ mu sync.Mutex
+ got []instancectl.StagedExe
+ sidecarExes []instancectl.StagedExe
+ plainStarts []instancectl.Role // Start calls: the live-link exe layout
+ tiers []int64 // StartGenExe's memoryMax, per call
}
-func (c *exeControl) Start(context.Context, string, instancectl.Role, int64) (string, error) {
+func (c *exeControl) Start(_ context.Context, _ string, role instancectl.Role, _ int64) (string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.plainStarts = append(c.plainStarts, role)
return "inv", nil
}
+func (c *exeControl) StartSidecarExe(_ context.Context, _ string, _ int64, x instancectl.StagedExe) (string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.sidecarExes = append(c.sidecarExes, x)
+ return "inv-sc", nil
+}
func (c *exeControl) Seed(context.Context, string, int64) (instancectl.SeedResponse, error) {
return instancectl.SeedResponse{}, nil
}
@@ -4317,10 +5068,11 @@ func (c *exeControl) StartGen(context.Context, string, uint64, int64) (string, e
func (c *exeControl) StopGen(context.Context, string, uint64, bool) error { return nil }
func (c *exeControl) ResetGen(context.Context, string, uint64) error { return nil }
func (c *exeControl) Parent(context.Context, string, int64) error { return nil }
-func (c *exeControl) StartGenExe(_ context.Context, _ string, _ uint64, _ int64, x instancectl.StagedExe) (string, error) {
+func (c *exeControl) StartGenExe(_ context.Context, _ string, _ uint64, memoryMax int64, x instancectl.StagedExe) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.got = append(c.got, x)
+ c.tiers = append(c.tiers, memoryMax)
return "inv-7", nil
}
@@ -4333,15 +5085,46 @@ func TestStartRoleNamesTheStagedExe(t *testing.T) {
b.Control = ctl
rt := registry.Route{Host: "n.example.com"}
x := instancectl.StagedExe{Path: "/opt/carlos/versions/v2/blog", Version: "v2", Digest: strings.Repeat("a", 64)}
- inv, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, &x)
+ inv, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, &x, cgroup.Limit{})
if err != nil || inv != "inv-7" || len(ctl.got) != 1 || ctl.got[0] != x {
t.Fatalf("startRole = %q, %v; root was sent %+v", inv, err, ctl.got)
}
- if _, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, nil); err == nil || !strings.Contains(err.Error(), "live-link") {
+ if _, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, nil, cgroup.Limit{}); err == nil || !strings.Contains(err.Error(), "live-link") {
t.Fatalf("no staged exe: %v, want plan 2's start", err)
}
}
+// A nested legacy sidecar is started on the serving generation's captured
+// staged executable, which root validates (spec §7.5). The live link names
+// v1 while the generation serves v2, as after a fallback, and the sidecar
+// still starts, on v2. Task 7's interim nested arm refused it
+// (errNestedSidecarGen), and today's StartSidecar would bind the live v1.
+func TestStartSidecarGenNestedNamesTheStagedExe(t *testing.T) {
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ b.GenContainment = true
+ ctl := &exeControl{}
+ b.Control = ctl
+ stageLive(t, b, rt.Host, "v1")
+ if err := os.WriteFile(b.pathsFor(rt).sidecarEnv, []byte("X=1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ g := Gen{N: 4, Build: "v2", StagedPath: "/opt/carlos/versions/v2/blog", Digest: strings.Repeat("b", 64)}
+ p, err := b.StartSidecarGen(context.Background(), rt, g)
+ if err != nil {
+ t.Fatalf("StartSidecarGen: %v", err)
+ }
+ defer p.Stop(context.Background()) //nolint:errcheck
+ want := instancectl.StagedExe{Path: g.StagedPath, Version: "v2", Digest: g.Digest}
+ ctl.mu.Lock()
+ defer ctl.mu.Unlock()
+ if len(ctl.sidecarExes) != 1 || ctl.sidecarExes[0] != want {
+ t.Fatalf("root was sent %+v for the sidecar, want %+v", ctl.sidecarExes, want)
+ }
+ if len(ctl.plainStarts) != 0 {
+ t.Fatalf("a live-link start ran too: %v", ctl.plainStarts)
+ }
+}
+
// Root refusing the staged path abandons the swap. It is not a failure of
// the build, and the incumbent keeps serving.
func TestSwapAbandonsWhenRootRefusesTheStagedExe(t *testing.T) {
@@ -4359,8 +5142,8 @@ func TestSwapAbandonsWhenRootRefusesTheStagedExe(t *testing.T) {
- [ ] **Step 3: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/instancectl ./internal/activator -run 'TestValidateStagedExe|TestEnsureGenExeAt|TestGenStartRefusesAStagedExe|TestRegistryHostApp|TestStartRoleNamesTheStagedExe|TestSwapAbandonsWhenRootRefuses' -count=1`
-Expected: build failure: `undefined: StagedExe`, `s.validateStagedExe undefined`, `undefined: registryHostApp`, `too many arguments in call to b.startRole`.
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/instancectl ./internal/activator -run 'TestValidateStagedExe|TestSidecarStartMounts|TestEnsureGenExeAt|TestGenStartRefusesAStagedExe|TestRegistryHostApp|TestStartRoleNamesTheStagedExe|TestStartSidecarGenNested|TestStartStagedNested|TestSwapAbandonsWhenRootRefuses' -count=1`
+Expected: build failure: `undefined: StagedExe`, `s.validateStagedExe undefined`, `undefined: registryHostApp`, `c.StartSidecarExe undefined`, `too many arguments in call to b.startRole`.
- [ ] **Step 4: Write root's validation**
@@ -4493,18 +5276,59 @@ In `Run`, add after `ExeRoot: filepath.Join(controlsock.Dir, "exe"),`:
HostApp: registryHostApp("/data/registry.db"),
```
-Add `exe StagedExe` to plan 2's `request` struct. In the `/start` query parsing, directly after plan 2 reads `gen`, add:
+Add `exe StagedExe` to plan 2's `request` struct. In the `/start` query parsing, directly after plan 2 reads `gen` (the role is parsed before it), add:
```go
if x := q.Get("exe"); x != "" {
- if req.gen == 0 {
- return req, errors.New("exe is only for a generation start")
+ // A generation start, or the legacy sidecar (zero-downtime spec
+ // §7.5: it runs its instance's serving artifact). The original
+ // template's run unit keeps the live link.
+ if req.gen == 0 && req.role != RoleSidecar {
+ return req, errors.New("exe is only for a generation start or a sidecar")
}
req.exe = StagedExe{Path: x, Version: q.Get("version"), Digest: q.Get("digest")}
}
```
-Return the error the way plan 2's parser returns its other refusals (a 400). In plan 2's `startGen`, replace
+Return the error the way plan 2's parser returns its other refusals (a 400).
+
+Append to `stagedexe.go`:
+
+```go
+// ensureHostExeAt lays out the host's own executable mount (the one the
+// original templates bind: <ExeRoot>/<id>/app). It points at a validated
+// staged build when x names one, which only a legacy sidecar's start does,
+// and at the live link otherwise (ensureHostExe).
+func (s *Server) ensureHostExeAt(host string, x StagedExe) error {
+ if x.Path == "" {
+ return s.ensureHostExe(host)
+ }
+ return ensureExe(s.ExeRoot, provision.InstanceID(host), x.Path, host)
+}
+```
+
+(add the `internal/provision` import). In `handleStart`'s original-template path, replace plan 2's
+
+```go
+ if s.refuseStart(w, req.host, unit, func() error { return s.ensureHostExe(req.host) }) {
+```
+with
+```go
+ // A sidecar naming its generation's staged build is validated before
+ // anything is laid out, under the run unit's rule (validateStagedExe).
+ // 403 tells the agent it is a refusal, not a failure of the build.
+ if req.exe.Path != "" {
+ if err := s.validateStagedExe(req.host, req.exe); err != nil {
+ http.Error(w, "not starting "+unit+": "+err.Error(), http.StatusForbidden)
+ return
+ }
+ }
+ if s.refuseStart(w, req.host, unit, func() error { return s.ensureHostExeAt(req.host, req.exe) }) {
+```
+
+The bind is made when the unit starts, so a later start for the same host (a seed, or a flag-off run unit) that points `app` back at the live link does not change what an already-running sidecar runs.
+
+In plan 2's `startGen`, replace
```go
if s.refuseStart(w, req.host, unit, func() error { return s.ensureGenExe(req.host, req.gen) }) {
@@ -4545,6 +5369,27 @@ func (c *Client) StartGenExe(ctx context.Context, host string, n uint64, memoryM
}
return out.InvocationID, nil
}
+
+// StartSidecarExe is Start for the legacy sidecar role with its executable
+// named: the serving generation's staged build, which root validates and
+// mounts in place of the live link (zero-downtime spec §7.5). A 403 is
+// root's refusal, reported as ErrStagedExe.
+func (c *Client) StartSidecarExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (string, error) {
+ q := hostRole(host, RoleSidecar)
+ q.Set("memory_max", strconv.FormatInt(memoryMax, 10))
+ q.Set("exe", x.Path)
+ q.Set("version", x.Version)
+ q.Set("digest", x.Digest)
+ var out StartResponse
+ code, err := c.do(ctx, DeadlineControl, http.MethodPost, "/start", q, &out)
+ if code == http.StatusForbidden {
+ return "", fmt.Errorf("%w: %v", ErrStagedExe, err)
+ }
+ if err != nil {
+ return "", err
+ }
+ return out.InvocationID, nil
+}
```
- [ ] **Step 5: Thread the staged executable through the agent's nested start**
@@ -4553,32 +5398,101 @@ In plan 2's `GenControl` interface, add:
```go
StartGenExe(ctx context.Context, host string, n uint64, memoryMax int64, x instancectl.StagedExe) (string, error)
+ StartSidecarExe(ctx context.Context, host string, memoryMax int64, x instancectl.StagedExe) (string, error)
```
-Plan 2's test fake for `GenControl` (`fakeGenControl`) gains the same method, returning what its `StartGen` returns. `instancectl.Client` satisfies the interface through Step 4.
+Plan 2's test fake for `GenControl` (`fakeGenControl`) gains both methods: `StartGenExe` returns what its `StartGen` returns, and `StartSidecarExe` records `startsidecarexe <host> <max>` and returns `f.inv, f.startErr`. `instancectl.Client` satisfies the interface through Step 4.
-Give plan 2's `startRole` a trailing `exe *instancectl.StagedExe` parameter, and every existing call passes `nil`. Its generation branch becomes:
+**The nested legacy sidecar.** In `backend_exec.go`, rename the body of `startSidecarUnit` to `startSidecarUnitFrom(ctx context.Context, rt registry.Route, exe *instancectl.StagedExe) (Proc, error)` and keep `startSidecarUnit` as a forwarder: `return b.startSidecarUnitFrom(ctx, rt, nil)`. In the body, replace
+
+```go
+ inv, err := b.Control.Start(ctx, rt.Host, instancectl.RoleSidecar, b.tier(rt).MemoryMax)
+```
+with
+```go
+ var inv string
+ if exe != nil {
+ // The serving generation's captured staged build (zero-downtime
+ // spec §7.5), which root validates before it lays out the mount.
+ gc, gerr := b.genControl()
+ if gerr != nil {
+ logs.Close()
+ return nil, gerr
+ }
+ inv, err = gc.StartSidecarExe(ctx, rt.Host, b.tier(rt).MemoryMax, *exe)
+ } else {
+ inv, err = b.Control.Start(ctx, rt.Host, instancectl.RoleSidecar, b.tier(rt).MemoryMax)
+ }
+```
+
+The launch file's argv is unchanged (`/opt/carlos/live/<host>` inside the namespace, which the mount resolves to the staged build).
+
+In `genbackend.go`, Task 7's nested arm of `StartSidecarGen`, `return nil, errNestedSidecarGen`, becomes:
+
+```go
+ case LayoutNested:
+ // The unit binds what root lays out, so root is told the
+ // generation's staged build and validates it (Task 9). Nothing here
+ // reads the live link: a fallback or a repoint cannot change what
+ // this sidecar runs.
+ if g.StagedPath == "" {
+ return nil, errNestedSidecarGen
+ }
+ exe := instancectl.StagedExe{Path: g.StagedPath, Version: g.Build, Digest: g.Digest}
+ return b.startSidecarUnitFrom(ctx, rt, &exe)
+```
+
+and `errNestedSidecarGen`'s message becomes `"nested sidecar: the generation has no staged executable"`. Update Task 7's `TestExecBackendStartSidecarGen` nested subtest to match: with `Gen{N: 3, Build: build}` (no staged path) it still answers `errNestedSidecarGen`. A root refusal (`ErrStagedExe`) reaches the supervisor as a failed start, which retries on its backoff; the instance keeps serving.
+
+Give plan 2's `startRole` two trailing parameters, `exe *instancectl.StagedExe` and `tier cgroup.Limit`, and every existing call passes `nil, cgroup.Limit{}`. Its generation branch becomes:
```go
gc, err := b.genControl()
if err != nil {
return "", err
}
+ if tier == (cgroup.Limit{}) {
+ tier = b.tier(rt)
+ }
if exe != nil {
- return gc.StartGenExe(ctx, rt.Host, gen, b.tier(rt).MemoryMax, *exe)
+ // The staged artifact's own tier (Task 1), not the live link's.
+ return gc.StartGenExe(ctx, rt.Host, gen, tier.MemoryMax, *exe)
+ }
+ return gc.StartGen(ctx, rt.Host, gen, tier.MemoryMax)
+```
+
+Split `startUnitAt` (plan 1's, with plan 2's trailing `n uint64`) at its health wait, exactly as Task 1 split `startChildAt`. Everything from the top of the body down to, but not including, `if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil {` becomes `spawnUnitFrom(ctx context.Context, rt registry.Route, sock string, gen uint64, exe *instancectl.StagedExe, tier cgroup.Limit) (Proc, string, error)`, ending `return up, spawnVer, nil`. `startUnitAt` keeps the health tail, byte for byte:
+
+```go
+func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error) {
+ p, spawnVer, err := b.spawnUnitFrom(ctx, rt, sock, n, nil, cgroup.Limit{})
+ if err != nil {
+ return p, "", err
+ }
+ up := p.(*unitProc)
+ if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil {
+ up.stopRequested.Store(true)
+ stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout)
+ defer cancel()
+ if serr := up.Stop(stopCtx); serr != nil {
+ log.Printf("activator: %s: stop after failed health wait: %v", rt.Host, serr)
+ }
+ return nil, "", err
}
- return gc.StartGen(ctx, rt.Host, gen, b.tier(rt).MemoryMax)
+ b.normalizeSocketMode(rt, sock, up)
+ return up, spawnVer, nil
+}
```
-Rename the body of `startUnitAt` (plan 1's, with plan 2's trailing `n uint64`) to `startUnitFrom(ctx context.Context, rt registry.Route, sock string, gen uint64, exe *instancectl.StagedExe) (Proc, string, error)`. Keep `func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error)` as a forwarder: `return b.startUnitFrom(ctx, rt, sock, n, nil)`. Inside the body, plan 2's `gen, err := b.unitGen(n)` becomes `ug, err := b.unitGen(gen)`, and every later use of that result (`prepareAt`'s last argument, `writeLaunchFor`, `startRole`, `newUnitProcFor`, `unitName`) takes `ug`. Pass `exe` to the run role's `startRole` call: `b.startRole(ctx, rt, instancectl.RoleRun, ug, exe)`. The launch file's argv is unchanged: inside the namespace the trampoline still execs `/opt/carlos/live/<host>`, which the generation's executable mount now resolves to the staged build.
+(keep the tail's long comments from `main` as they are). Inside `spawnUnitFrom`, plan 2's `gen, err := b.unitGen(n)` becomes `ug, err := b.unitGen(gen)`, and every later use of that result (`prepareAt`'s last argument, `writeLaunchFor`, `startRole`, `newUnitProcFor`, `unitName`) takes `ug`. Pass `exe` and `tier` to the run role's `startRole` call: `b.startRole(ctx, rt, instancectl.RoleRun, ug, exe, tier)`. The launch file's argv is unchanged: inside the namespace the trampoline still execs `/opt/carlos/live/<host>`, which the generation's executable mount now resolves to the staged build.
-In `genbackend.go`, the nested arm of `StartStaged` becomes:
+In `genbackend.go`, the nested arm of `StartStaged` becomes (no health wait: the caller owns it, Task 1):
```go
case LayoutNested:
sock := nestedGenSocketIn(p.runDir, g.N)
exe := instancectl.StagedExe{Path: g.StagedPath, Version: g.Build, Digest: g.Digest}
- proc, _, err := b.startUnitFrom(ctx, rt, sock, g.N, &exe)
+ proc, _, err := b.spawnUnitFrom(ctx, rt, sock, g.N, &exe, g.Tier)
if err != nil {
return g, proc, err
}
@@ -4586,6 +5500,45 @@ In `genbackend.go`, the nested arm of `StartStaged` becomes:
return g, proc, nil
```
+Append to `swap_nested_test.go` the nested form of Task 1's real-backend test:
+
+```go
+// A nested staged generation is returned as soon as root has started its
+// unit, with no health wait inside (spec §8.2): the socket here is never
+// bound, and the real 30 s spawnHealthTimeout is left alone. root is sent
+// the generation's own tier.
+func TestStartStagedNestedReturnsTheUnitAtOnce(t *testing.T) {
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ b.GenContainment = true
+ ctl := &exeControl{}
+ b.Control = ctl
+ g := Gen{N: 7, Build: "v2", StagedPath: "/opt/carlos/versions/v2/blog", Digest: strings.Repeat("c", 64), Tier: cgroup.TierChildren}
+ done := make(chan error, 1)
+ var proc Proc
+ go func() {
+ var err error
+ _, proc, err = b.StartStaged(context.Background(), rt, g)
+ done <- err
+ }()
+ select {
+ case err := <-done:
+ if err != nil || proc == nil {
+ t.Fatalf("StartStaged = %v, %v: want the started unit", proc, err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("StartStaged waited for health: it must return the started unit at once")
+ }
+ defer proc.Stop(context.Background()) //nolint:errcheck
+ ctl.mu.Lock()
+ defer ctl.mu.Unlock()
+ if len(ctl.got) != 1 || ctl.tiers[0] != cgroup.TierChildren.MemoryMax {
+ t.Fatalf("root was sent %+v at %v, want one start at the generation's 1 GiB", ctl.got, ctl.tiers)
+ }
+}
+```
+
+(add `internal/cgroup` and `time` to the file's imports).
+
Then delete `errNestedStaged`.
In `swapOverlap` (`swap.go`), in the `if spawnErr != nil` block, before `return failSwap(...)`:
@@ -4607,7 +5560,7 @@ In `swapHold` (`swaphold.go`), add this case to the `switch` that ends the funct
- [ ] **Step 6: Run the tests to verify they pass**
-Run: `go vet ./internal/instancectl ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/instancectl ./internal/activator -run 'TestValidateStagedExe|TestEnsureGenExeAt|TestGenStartRefusesAStagedExe|TestRegistryHostApp|TestStartRoleNamesTheStagedExe|TestSwapAbandonsWhenRootRefuses' -count=1`
+Run: `go vet ./internal/instancectl ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/instancectl ./internal/activator -run 'TestValidateStagedExe|TestSidecarStartMounts|TestEnsureGenExeAt|TestGenStartRefusesAStagedExe|TestRegistryHostApp|TestStartRoleNamesTheStagedExe|TestStartSidecarGenNested|TestStartStagedNested|TestSwapAbandonsWhenRootRefuses' -count=1`
Expected: `ok` twice.
- [ ] **Step 7: Run both packages in full and cross-compile**
@@ -4659,7 +5612,7 @@ Claims-on wakes spawn through the claim runtime's provider (`ClaimStart.Build`),
func (a *Service) claimGenAt(rt registry.Route, n uint64) Gen
func (a *Service) entryParent(e *actEntry, s ClaimStart) (*cgroup.Prepared, error)
func (a *Service) closeEntryParent(e *actEntry)
- func (a *Service) setParent(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, overlap bool) error
+ func (a *Service) setParent(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, cur cgroup.Limit, succ *cgroup.Limit) error // captured tiers only
func (a *Service) spawnSuccessor(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, work hostwrite.Work, g Gen) (Gen, Proc, error)
func (a *Service) startClaimedSuccessor(ctx context.Context, e *actEntry, rt registry.Route, work hostwrite.Work, g Gen) (Gen, Proc, error)
// Service gains: claimSpawn func(ctx context.Context, e *actEntry, rt registry.Route, work hostwrite.Work, g Gen) (Gen, Proc, error) (test seam)
@@ -5162,25 +6115,27 @@ func (a *Service) closeEntryParent(e *actEntry) {
}
}
-// setParent raises the parent to the overlap limit or restores it to the
-// tier. Under claims that is the entry's strict parent, whose
-// expected-limit record SetParentLimit keeps in step. Otherwise it is the
-// backend's.
-func (a *Service) setParent(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, overlap bool) error {
+// setParent raises the parent to the incumbent's tier plus the successor's
+// (succ non-nil) or restores it to the current generation's tier (succ
+// nil). Every tier is a captured one (spec §7.3, Task 1): never the
+// parent's stored preparation tier summed with itself. Under claims the
+// parent is the entry's strict parent, whose expected-limit record
+// SetParentLimit keeps in step. Otherwise it is the backend's.
+func (a *Service) setParent(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, cur cgroup.Limit, succ *cgroup.Limit) error {
a.mu.Lock()
- p, tier := e.ops.parent, e.ops.parentTier
+ p := e.ops.parent
a.mu.Unlock()
if p != nil {
- l := tier
- if overlap {
- l = cgroup.OverlapLimit(tier, tier)
+ l := cur
+ if succ != nil {
+ l = cgroup.OverlapLimit(cur, *succ)
}
return p.SetParentLimit(l)
}
- if overlap {
- return gb.RaiseGenParent(ctx, rt)
+ if succ != nil {
+ return gb.RaiseGenParent(ctx, rt, cur, *succ)
}
- return gb.RestoreGenParent(ctx, rt)
+ return gb.RestoreGenParent(ctx, rt, cur)
}
// spawnSuccessor is §7.5 step 3 in both modes. Claims off, it is the
@@ -5224,7 +6179,11 @@ func (a *Service) startClaimedSuccessor(ctx context.Context, e *actEntry, rt reg
if err != nil {
return g, nil, err
}
- cmd, child, err := spawnClaimedGen(ctx, parent, g.N, s.Limit, work, s.Identity, s.Build, nil)
+ tier := s.Limit
+ if g.Tier != (cgroup.Limit{}) {
+ tier = g.Tier // the successor's own artifact's tier, captured at admission (Task 1)
+ }
+ cmd, child, err := spawnClaimedGen(ctx, parent, g.N, tier, work, s.Identity, s.Build, nil)
p := newClaimProcess(cmd, child)
if err != nil {
return g, p, err
@@ -5254,9 +6213,9 @@ In `swapOverlap`, after `work, err := a.selectWork(...)` and its error check, ad
Then make two replacements.
-`if err := gb.RaiseGenParent(ctx, rt); err != nil {` becomes:
+`if err := gb.RaiseGenParent(ctx, rt, incumbent.Tier, succTier); err != nil {` becomes:
```go
- if err := a.setParent(ctx, gb, e, rt, true); err != nil {
+ if err := a.setParent(ctx, gb, e, rt, incumbent.Tier, &succTier); err != nil {
```
`g, proc, spawnErr := gb.StartStaged(mctx, rt, m.gen)` becomes:
@@ -5280,7 +6239,7 @@ In `leaveSet` (`opset.go`), the restore block becomes:
if restore {
if gb, ok := a.backend.(SwapBackend); ok {
rctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
- if err := a.setParent(rctx, gb, e, rt, false); err != nil {
+ if err := a.setParent(rctx, gb, e, rt, tier, nil); err != nil {
log.Printf("activator: restore parent limit for %s: %v", rt.Host, err)
}
cancel()
@@ -5787,6 +6746,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/registry"
)
@@ -6097,8 +7057,14 @@ func (b *simBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen)
g.Control = g.Socket + ".control"
return g, b.w.spawn(rt.Host, g.Build, g.N), nil
}
-func (b *simBackend) RaiseGenParent(context.Context, registry.Route) error { return nil }
-func (b *simBackend) RestoreGenParent(context.Context, registry.Route) error { return nil }
+func (b *simBackend) GenTier(registry.Route, string) cgroup.Limit { return cgroup.TierDefault }
+func (b *simBackend) GenReady(registry.Route, Gen, Proc) {}
+func (b *simBackend) RaiseGenParent(context.Context, registry.Route, cgroup.Limit, cgroup.Limit) error {
+ return nil
+}
+func (b *simBackend) RestoreGenParent(context.Context, registry.Route, cgroup.Limit) error {
+ return nil
+}
func (b *simBackend) GenSwitched(rt registry.Route, g Gen, _ Proc) error {
b.w.logf("switch %s -> gen %d (%s)", rt.Host, g.N, g.Build)
return nil
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md
index 3d235f4b2..5ab966238 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md
@@ -59,7 +59,8 @@ From the index (exact names):
| `func SetGenerations(on bool)`; `Service.gens` (read once in `New`) | `activator` | plan 1 Task 1, Task 3 | arming generations, and with them plan 3's `Swap` (Task 14) |
| `actEntry.gen Gen`, `wokenInstance.gen Gen` | `activator` | plan 1 Task 3 | the current generation; Task 9's `Current` reads it |
| `ExecBackend.GenContainment bool`; `func (b *ExecBackend) KillGen(ctx context.Context, rt registry.Route, n uint64) error`; `func (b *SplitBackend) KillGen(ctx context.Context, rt registry.Route, n uint64) error` | `activator` | plan 2 Task 10 | arming containment (Task 14); `cgroup.kill` on a failed first wake's generation child, waited until empty (spec §8.2 step 1, Task 9) |
-| `type SwapBackend interface { StartStaged(ctx, rt, g Gen) (Gen, Proc, error); RaiseGenParent(ctx, rt) error; RestoreGenParent(ctx, rt) error; GenSwitched(rt, g Gen, p Proc) error }` | `activator` | plan 3 Task 1 | the staged spawn under `startPinned`; Task 9's test backend implements it |
+| `type SwapBackend interface { StartStaged(ctx, rt, g Gen) (Gen, Proc, error); GenTier(rt, build string) cgroup.Limit; GenReady(rt, g Gen, p Proc); RaiseGenParent(ctx, rt, incumbent, successor cgroup.Limit) error; RestoreGenParent(ctx, rt, tier cgroup.Limit) error; GenSwitched(rt, g Gen, p Proc) error }`. `StartStaged` returns the running process at once, with no health wait | `activator` | plan 3 Task 1 | the staged spawn under `startPinned`; Task 9's test backends implement it |
+| `type genKiller interface { KillGen(ctx, rt, n uint64) error }` | `activator` | plan 3 Task 2 | `killFailed` (Task 9); plan 5 declares none |
| `func (a *Service) swapChecksLocked(e *actEntry, at Attempt, newest func(string) uint64) error`; `errSwapNotAwake`, `errSwapBusy`, `errSwapStopped` | `activator` | plan 3 Tasks 3, 4 | the §7.4 checks before a socketless attempt (Task 11) |
| `actEntry.pin *Gen`; `func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) (Proc, string, Gen, error)`; the test seam `Service.genHealth func(context.Context, Gen) error` | `activator` | plan 3 Tasks 4, 6 | every planned wake execs a staged build through it; Task 9 adds a health-deadline parameter |
| `StartPolicy.Pin *Gen`, `StartPolicy.Fallback *Gen`, `StartPolicy.HealthBy time.Time`; `actEntry.pinFailed *Gen`, `actEntry.pinFailReason string` | `activator` | plan 3 Tasks 6, 10 | a claimed (claims-on) first wake's budget and fallback (Task 9) |
@@ -102,7 +103,7 @@ From the index (exact names):
| `internal/registry/registry.go` (modify) | four columns, `GoodBuild`, `SetGoodBuild`, `SetServingVersion` |
| `internal/appconfig/appconfig.go` (modify) | `Manifest.Swap`, `Manifest.SwapConcurrency`, validators |
| `internal/appconfig/claimsync.go`, `syncexec.go` (modify) | `Callbacks.Delivered` |
-| `internal/activator/wakeplan.go` (create) | `WakePlan`, `GenEvent`, `Current`, budget vars, `genKiller`, `spawnPlanned`, `spawnPlannedWorker`, `afterWake`, `watchFirstWake`, `claimedPlanResult`, `BounceFor`, `bounceText` |
+| `internal/activator/wakeplan.go` (create) | `WakePlan`, `GenEvent`, `Current`, budget vars, `spawnPlanned`, `spawnPlannedWorker`, `plannedWorkerReady`, `afterWake`, `watchFirstWake`, `claimedPlanResult`, `BounceFor`, `bounceText` |
| `internal/activator/workerswap.go` (create) | `ReplaceWorker`: a socketless attempt on the same entry (Task 11) |
| `internal/activator/genfile.go` (create) | `generationsLocked`, `persistGenerations`: the generations file writer |
| `internal/activator/activator.go`, `swap.go`, `claimwake.go` (modify) | Service hooks, entry fields, `begin` deadline, `doWake` planning and step 4, `startPinned`'s health deadline, the claimed wake's pin, fallback and health cap, sweep drift feed, `unexpectedExit`, `respawnWorkerLocked`'s retire timing, the generation-change calls |
@@ -169,6 +170,7 @@ var (
_ func(*ExecBackend, context.Context, registry.Route, uint64) error = (*ExecBackend).KillGen // plan 2
_ func(*SplitBackend, context.Context, registry.Route, uint64) error = (*SplitBackend).KillGen // plan 2
_ SwapBackend = (*SplitBackend)(nil) // plan 3
+ _ genKiller = (*SplitBackend)(nil) // plan 3 Task 2 declares genKiller; plan 5 declares none
_ func(*Service, *actEntry, Attempt, func(string) uint64) error = (*Service).swapChecksLocked // plan 3
_ func(*Service, context.Context, registry.Route, Gen) (Proc, string, Gen, error) = (*Service).startPinned // plan 3
_ func(context.Context, Proc, time.Duration) settleResult = observeSettle // plan 3
@@ -2775,9 +2777,18 @@ func (f *fakeAct) snapshot() ([]swapCall, []string, int) {
type fakeRoutes struct {
mu sync.Mutex
rows map[string]registry.Route
+ // getHook, when set, runs at the start of every Get, with no lock held:
+ // a test's window into the moment the scheduler reads a row.
+ getHook func(host string)
}
func (f *fakeRoutes) Get(host string) (*registry.Route, error) {
+ f.mu.Lock()
+ hook := f.getHook
+ f.mu.Unlock()
+ if hook != nil {
+ hook(host)
+ }
f.mu.Lock()
defer f.mu.Unlock()
r, ok := f.rows[host]
@@ -2994,6 +3005,7 @@ Create `internal/rollout/manager_test.go`:
package rollout
import (
+ "sync/atomic"
"testing"
"time"
@@ -3080,6 +3092,48 @@ func TestAFailedCanaryHaltsTheCohort(t *testing.T) {
}
}
+// Spec §8.1: the failure transition stops the cohort in the same step that
+// frees the failed attempt's slot. The hook runs the scheduler's admission
+// at the first moment done() lets go of the queue (its registry read). If
+// finish() freed the slot and the canary there but the halt waited for the
+// failure record, that admission starts a second attempt onto the failed
+// build, and an attempt already past spawn passes its newest re-check.
+func TestAFailureHaltsTheCohortInTheStepThatFreesItsSlot(t *testing.T) {
+ h := newHarness(t, "a", "b")
+ h.stage("v2")
+ var failed atomic.Bool
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Build != "v2" {
+ return activator.SwapDone
+ }
+ failed.Store(true)
+ return activator.SwapFailed
+ }
+ var leaked atomic.Int32
+ h.rows.mu.Lock()
+ h.rows.getHook = func(host string) {
+ if !failed.CompareAndSwap(true, false) {
+ return
+ }
+ s := h.m.schedulerOf(host)
+ s.mu.Lock()
+ ds := s.q.admit(currentView(s.q), h.clock())
+ s.mu.Unlock()
+ for _, d := range ds {
+ if d.ID.Build == "v2" {
+ leaked.Add(1)
+ }
+ }
+ }
+ h.rows.mu.Unlock()
+ h.install("v2", "a", "b")
+ h.pass()
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+ if n := leaked.Load(); n != 0 {
+ t.Fatalf("%d attempt(s) onto v2 admitted between the failure and the halt", n)
+ }
+}
+
// Review focus 4.
func TestStagingRefusalWritesNoFailureRecord(t *testing.T) {
h := newHarness(t, "a")
@@ -4091,6 +4145,14 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
m := s.m
s.mu.Lock()
transition := s.q.finish(d, res.Outcome)
+ if transition {
+ // The cohort-failure transition's queue half (spec §8.1 step 2), in
+ // the critical section that freed the slot and the canary: no
+ // admission and no newest re-check can run between the failure and
+ // the halt. cohortFailed below writes the record; its own halt of
+ // the same build is then a no-op for admission.
+ s.q.halt(d.ID.Build, res.Reason)
+ }
serving := ""
if h := s.q.hosts[d.ID.Host]; h != nil {
serving = h.serving
@@ -4165,15 +4227,18 @@ git commit -m "zdd-5 task 8: the manager and the per-app scheduler"
- Produces:
```go
type WakePlan struct { Boot release.StagedIdentity; Mode SwapMode; FirstWake bool; Fallback *release.StagedIdentity; Refuse string }
- type GenEventKind string // GenHealthy, GenSettled, GenFailed, GenFellBack
+ type GenEventKind string // GenHealthy, GenSettled, GenFailed, GenFellBack, GenCancelled
type GenEvent struct { Kind GenEventKind; Route registry.Route; Gen Gen; Failed release.StagedIdentity; Mode SwapMode; FirstWake bool; Reason string; At time.Time }
// Service gains: Planner func(ctx context.Context, rt registry.Route) WakePlan; OnGen func(GenEvent); OnDrift func(host string)
func (a *Service) Current(host string) (Gen, bool)
func (a *Service) BounceFor(host string, cause Cause) bool
func bounceText(worker bool, cause Cause) string // plan 6 rewords it
- type genKiller interface { KillGen(ctx context.Context, rt registry.Route, n uint64) error } // plan 2's signature
+ // genKiller is plan 3 Task 2's (it calls KillGen after every member's drain); this task uses it and declares none
func (a *Service) claimedPlanResult(e *actEntry, w *wokenInstance) *wokenInstance
- // startPinned gains a last parameter: healthBy time.Time (zero: no cap)
+ func plannedWorkerReady(ctx context.Context, p Proc) error // spec §6.5 readiness for a planned socketless wake; awaitWorkerSettle stays for flag-off wakes
+ // startPinned gains a last parameter: healthBy time.Time (zero: no cap). Plan 3's StartStaged returns the
+ // process at once, so this deadline is the only health bound, and a failed B comes back with its Proc (never nil)
+ // for spawnPlanned to record and kill (spec §8.2).
var firstWakeBudget, firstWakeKillReserve, firstWakeKillBound, firstWakeSettle time.Duration // 55 s, 25 s, 2 s, 10 s
```
@@ -4191,14 +4256,17 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
+ "maps"
"os"
"path/filepath"
+ "slices"
"strconv"
"strings"
"sync"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -4237,9 +4305,15 @@ func (b *planGenBackend) StartStaged(_ context.Context, _ registry.Route, g Gen)
return g, p, nil
}
-func (b *planGenBackend) RaiseGenParent(context.Context, registry.Route) error { return nil }
-func (b *planGenBackend) RestoreGenParent(context.Context, registry.Route) error { return nil }
-func (b *planGenBackend) GenSwitched(registry.Route, Gen, Proc) error { return nil }
+func (b *planGenBackend) GenTier(registry.Route, string) cgroup.Limit { return cgroup.TierDefault }
+func (b *planGenBackend) GenReady(registry.Route, Gen, Proc) {}
+func (b *planGenBackend) RaiseGenParent(context.Context, registry.Route, cgroup.Limit, cgroup.Limit) error {
+ return nil
+}
+func (b *planGenBackend) RestoreGenParent(context.Context, registry.Route, cgroup.Limit) error {
+ return nil
+}
+func (b *planGenBackend) GenSwitched(registry.Route, Gen, Proc) error { return nil }
func (b *planGenBackend) KillGen(_ context.Context, _ registry.Route, n uint64) error {
b.gmu.Lock()
@@ -4461,8 +4535,11 @@ func TestAnIntentionalStopInsideTheWindowRecordsNothing(t *testing.T) {
evs.wait(t, GenHealthy)
a.BounceFor("one.example.com", CauseConfig)
time.Sleep(800 * time.Millisecond)
- if k := evs.kinds(); len(k) != 1 {
- t.Fatalf("events %v: an intentional stop must end the observation and record nothing", k)
+ // Spec §6.4: nothing about the build is recorded. Spec §9.1: the
+ // observation is still closed, by GenCancelled, or the scheduler shows
+ // the host settling for ever and the deploy watch never completes.
+ if k := evs.kinds(); !slices.Equal(k, []GenEventKind{GenHealthy, GenCancelled}) {
+ t.Fatalf("events %v: want healthy, then cancelled, and nothing recorded against the build", k)
}
}
@@ -4542,6 +4619,175 @@ func TestAClaimedFirstWakeReportsLikeAnUnclaimedOne(t *testing.T) {
}
}
+// execStagedBackend runs planned wakes through the REAL exec backend's
+// StartStaged (real processes, plan 3's real flat and foreign spawn paths)
+// over fakeBackend's lease and restore. Its KillGen stops the generation's
+// process, because a test fixture is not cgroup v2.
+type execStagedBackend struct {
+ *fakeBackend
+ eb *ExecBackend
+ smu sync.Mutex
+ procs map[uint64]Proc
+}
+
+func (b *execStagedBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error) {
+ got, p, err := b.eb.StartStaged(ctx, rt, g)
+ if p != nil {
+ b.smu.Lock()
+ b.procs[g.N] = p
+ b.smu.Unlock()
+ }
+ return got, p, err
+}
+func (b *execStagedBackend) GenTier(rt registry.Route, build string) cgroup.Limit { return b.eb.GenTier(rt, build) }
+func (b *execStagedBackend) GenReady(rt registry.Route, g Gen, p Proc) { b.eb.GenReady(rt, g, p) }
+func (b *execStagedBackend) RaiseGenParent(ctx context.Context, rt registry.Route, inc, succ cgroup.Limit) error {
+ return b.eb.RaiseGenParent(ctx, rt, inc, succ)
+}
+func (b *execStagedBackend) RestoreGenParent(ctx context.Context, rt registry.Route, tier cgroup.Limit) error {
+ return b.eb.RestoreGenParent(ctx, rt, tier)
+}
+func (b *execStagedBackend) GenSwitched(rt registry.Route, g Gen, p Proc) error { return b.eb.GenSwitched(rt, g, p) }
+func (b *execStagedBackend) Layout(rt registry.Route) (Layout, string) { return b.eb.Layout(rt) }
+func (b *execStagedBackend) KillGen(ctx context.Context, _ registry.Route, n uint64) error {
+ b.smu.Lock()
+ p := b.procs[n]
+ b.smu.Unlock()
+ if p == nil {
+ return nil
+ }
+ if err := p.Stop(ctx); err != nil {
+ return err
+ }
+ if !p.Exited() {
+ return ErrStillRunning
+ }
+ return nil
+}
+
+// stagedScript writes an executable staged build and returns its identity.
+func stagedScript(t *testing.T, dir, ver, body string) release.StagedIdentity {
+ t.Helper()
+ path := filepath.Join(dir, ver)
+ if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256([]byte(body))
+ return release.StagedIdentity{Version: ver, StagedPath: path, Digest: hex.EncodeToString(sum[:])}
+}
+
+// Spec §8.2 on the real exec backend: B never answers, so its failure must
+// be seen inside the wake's own budget, recorded, and the good build booted
+// in its place. StartStaged hands back the running process at once (plan 3
+// Task 1), so startPinned's health deadline (55 s − 25 s, shrunk here) is
+// the only one, and the kill reaches a real process. A StartStaged that
+// waited out the backend's own 30 s spawnHealthTimeout and returned a nil
+// handle made spawnPlanned read "nothing executed": no failure recorded, no
+// fallback, and the waiter timed out. The foreign row runs the same way on
+// its canonical socket (plan 1's reading): refusing it failed every planned
+// wake of a foreign row.
+func TestAPlannedFirstWakeOnTheRealExecBackendFallsBackInTime(t *testing.T) {
+ for _, layout := range []string{"flat", "foreign"} {
+ t.Run(layout, func(t *testing.T) {
+ oldBound := wakeWaitBound
+ wakeWaitBound = time.Second
+ t.Cleanup(func() { wakeWaitBound = oldBound })
+ shrinkFirstWake(t, 4*time.Second, 2*time.Second, time.Millisecond) // B's health window: 2 s
+ eb := testExecBackend(t)
+ eb.RunDirOverride = t.TempDir()
+ host := "real.example.com"
+ stageLive(t, eb, host, "v0")
+ rt := registry.Route{Host: host, Kind: "instance", Hibernate: true, DBPath: filepath.Join(eb.DataDir, host+".db"),
+ Socket: filepath.Join(eb.RunDirOverride, host+".sock")}
+ want := LayoutFlat
+ if layout == "foreign" {
+ rt.Socket, want = filepath.Join(t.TempDir(), "hand-set.sock"), LayoutForeign
+ }
+ if got, _ := eb.Layout(rt); got != want {
+ t.Fatalf("fixture layout %v, want %v", got, want)
+ }
+ dir := t.TempDir()
+ next := stagedScript(t, dir, "v2", "#!/bin/sh\nexec sleep 30\n") // never binds
+ good := stagedScript(t, dir, "v1", "#!/bin/sh\nexec sleep 30\n")
+ b := &execStagedBackend{fakeBackend: &fakeBackend{}, eb: eb, procs: map[uint64]Proc{}}
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, b, reg)
+ a.gens = true
+ evs := &genLog{}
+ a.OnGen = evs.add
+ a.Planner = func(context.Context, registry.Route) WakePlan {
+ return WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good}
+ }
+ // B gets the real check: GET / on its real socket, which nothing
+ // binds, bounded only by the caller's deadline. The good build is
+ // let through, because a shell script cannot answer HTTP.
+ a.genHealth = func(ctx context.Context, g Gen) error {
+ if g.Build == "v1" {
+ return nil
+ }
+ return waitHealthyCtx(ctx, g.Socket, spawnHealthTimeout)
+ }
+ t.Cleanup(func() { a.Shutdown(context.Background()) })
+ start := time.Now()
+ done, err := a.beginRequest(context.Background(), rt)
+ if err != nil {
+ t.Fatalf("the waiter was not served by the good build: %v", err)
+ }
+ done()
+ if took := time.Since(start); took > 4*time.Second {
+ t.Fatalf("served after %v, past the first-wake budget", took)
+ }
+ ev := evs.wait(t, GenFellBack)
+ if ev.Failed.Version != "v2" || ev.Gen.Build != "v1" {
+ t.Fatalf("fallback event = %+v", ev)
+ }
+ b.smu.Lock()
+ procs := maps.Clone(b.procs)
+ b.smu.Unlock()
+ if len(procs) != 2 {
+ t.Fatalf("%d generations spawned, want B and the good build", len(procs))
+ }
+ for n, p := range procs {
+ if n != ev.Gen.N && !p.Exited() {
+ t.Fatalf("B's process (generation %d) outlived its kill", n)
+ }
+ }
+ })
+ }
+}
+
+// Spec §6.5: a socketless planned wake is ready once it is still running
+// 10 s after it started, and a clean exit inside that window is a
+// one-shot's success: the same rule ReplaceWorker applies. The flag-off
+// awaitWorkerSettle fails every early exit, exit 0 included, so a one-shot's
+// first wake was recorded as a failed build (and could halt its cohort).
+func TestAPlannedOneShotWorkerThatExitsCleanlyIsReady(t *testing.T) {
+ shrinkFirstWake(t, 55*time.Second, 25*time.Second, 200*time.Millisecond)
+ a, b, _, _, _ := stagedActivator(t)
+ a.gens = true
+ evs := &genLog{}
+ a.OnGen = evs.add
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a.Planner = func(context.Context, registry.Route) WakePlan {
+ return WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good}
+ }
+ b.workerDeadOnStart = true // the child exits at once, cleanly
+ if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v (a one-shot's clean exit is its success)", err)
+ }
+ evs.wait(t, GenSettled)
+ if k := evs.kinds(); slices.Contains(k, GenFailed) || slices.Contains(k, GenFellBack) {
+ t.Fatalf("events %v: a clean exit was recorded against the build", k)
+ }
+ if calls := b.calls(); len(calls) != 1 {
+ t.Fatalf("spawns %v: a clean exit fell back to the good build", calls)
+ }
+}
+
// The flag off: no Planner, today's wake, no events.
func TestWithoutAPlannerTheWakeIsTodays(t *testing.T) {
a, b, _, _ := testActivator(t, time.Hour)
@@ -4613,7 +4859,7 @@ func TestBounceForNamesTheCause(t *testing.T) {
- [ ] **Step 2: Run them to see them fail**
-Run: `mkdir -p /tmp/k && TMPDIR=/tmp/k go test ./internal/activator/ -run 'TestFirstWake|TestAHoldFirstWake|TestAnUnprovenKill|TestAnExitInside|TestAnIntentionalStop|TestAFirstWakeThat|TestARefusedPlan|TestAClaimedFirstWake|TestWithoutAPlanner|TestDriftFeeds|TestBounceFor' -count=1`
+Run: `mkdir -p /tmp/k && TMPDIR=/tmp/k go test ./internal/activator/ -run 'TestFirstWake|TestAHoldFirstWake|TestAnUnprovenKill|TestAnExitInside|TestAnIntentionalStop|TestAFirstWakeThat|TestARefusedPlan|TestAClaimedFirstWake|TestAPlannedFirstWakeOnTheRealExecBackend|TestAPlannedOneShotWorker|TestWithoutAPlanner|TestDriftFeeds|TestBounceFor' -count=1`
Expected: FAIL, `undefined: WakePlan`.
- [ ] **Step 3: Write the plan types and helpers**
@@ -4666,6 +4912,10 @@ const (
GenSettled GenEventKind = "settled" // a first wake survived its 10 s
GenFailed GenEventKind = "failed" // the planned build failed, nothing fell back
GenFellBack GenEventKind = "fellback" // the planned build failed, the good build serves
+ // GenCancelled: a first wake's observation ended without a verdict (an
+ // intentional stop, spec §6.4). It records nothing about the build; it
+ // only closes the observation GenHealthy opened.
+ GenCancelled GenEventKind = "cancelled"
)
// GenEvent is a planned wake's outcome, for the scheduler.
@@ -4688,12 +4938,9 @@ var (
firstWakeSettle = 10 * time.Second
)
-// genKiller is plan 2's generation kill (ExecBackend.KillGen and its
-// SplitBackend forward): cgroup.kill on generation n's child cgroup, then
-// wait until it is empty.
-type genKiller interface {
- KillGen(ctx context.Context, rt registry.Route, n uint64) error
-}
+// genKiller (plan 3 Task 2, opset.go) is plan 2's generation kill
+// (ExecBackend.KillGen and its SplitBackend forward): cgroup.kill on
+// generation n's child cgroup, then wait until it is empty.
// Current is the generation an awake instance serves, for the scheduler: a
// worker's is its captured spawn build. A first wake under its 10 s
@@ -4800,7 +5047,7 @@ func (a *Service) spawnPlannedWorker(ctx context.Context, e *actEntry, rt regist
start := func(id release.StagedIdentity) (Proc, release.StagedIdentity, error) {
p, got, err := a.startWorker(ctx, rt, env, &id)
if err == nil {
- err = a.awaitWorkerSettle(ctx, e, p)
+ err = plannedWorkerReady(ctx, p)
}
return p, got, err
}
@@ -4826,6 +5073,29 @@ func (a *Service) spawnPlannedWorker(ctx context.Context, e *actEntry, rt regist
return fproc, fid, nil
}
+// plannedWorkerReady is spec §6.5's readiness for a socketless planned
+// wake, the rule ReplaceWorker applies too: the child is ready once it is
+// still running firstWakeSettle (10 s) after it started, and a clean exit
+// inside that window is a one-shot's success. Any other exit fails. An early
+// ready report does not shorten the window. The flag-off wake keeps
+// awaitWorkerSettle (ready report, sidecarSettle, every early exit a
+// failure), unchanged.
+func plannedWorkerReady(ctx context.Context, p Proc) error {
+ switch observeSettle(ctx, p, firstWakeSettle) {
+ case settleOK:
+ return nil
+ case settleExited:
+ if !procCrashed(p) {
+ return nil
+ }
+ return errWorkerExitedEarly
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return context.Canceled
+}
+
func (a *Service) emitGen(ev GenEvent) {
if a.OnGen != nil {
a.OnGen(ev)
@@ -4861,9 +5131,17 @@ func (a *Service) afterWake(e *actEntry, w *wokenInstance, err error) {
// fails the build; for a socketless one-shot, a clean exit is its success
// (spec §6.5).
func (a *Service) watchFirstWake(e *actEntry, g Gen, plan WakePlan) {
+ cancelled := func(rt registry.Route) {
+ // Every observation GenHealthy opened is closed by an event; this
+ // one says "no verdict", so the scheduler stops showing the host
+ // as settling and records nothing (spec §6.4, §9.1).
+ a.emitGen(GenEvent{Kind: GenCancelled, Route: rt, Gen: g, Mode: plan.Mode, FirstWake: true, At: time.Now()})
+ }
a.mu.Lock()
if a.entries[e.host] != e || e.state != actAwake {
+ rt := e.route
a.mu.Unlock()
+ cancelled(rt)
return
}
ctx, cancel := context.WithCancel(context.Background())
@@ -4884,6 +5162,8 @@ func (a *Service) watchFirstWake(e *actEntry, g Gen, plan WakePlan) {
case exited:
a.emitGen(GenEvent{Kind: GenFailed, Route: rt, Failed: plan.Boot, Mode: plan.Mode, FirstWake: true,
Reason: "exited within " + firstWakeSettle.String() + " of becoming healthy", At: time.Now()})
+ default: // an intentional stop cancelled it
+ cancelled(rt)
}
}()
}
@@ -5583,6 +5863,8 @@ git commit -m "zdd-5 task 10: the wake planner and planned-wake outcomes"
func (a *Service) ReplaceWorker(ctx context.Context, at Attempt, fallback *release.StagedIdentity, newest func(host string) uint64) SwapResult
// actEntry gains: attemptBuilds []string (the builds a socketless attempt in flight may exec, for the generations file, Task 12)
// retireTook time.Duration (how long the last respawn took to retire its old child)
+ // attemptGate func() error; attemptRestore *release.StagedIdentity; attemptVeto error
+ // (the attempt's §7.4 checks, re-run by the respawn at the exec; what it execs instead; its refusal)
// rollout
func (s *scheduler) executeWorker(ctx context.Context, rt registry.Route, a activator.Attempt) activator.SwapResult
func (q *appQueue) admitOne(host string, now time.Time) (dispatch, bool)
@@ -5600,6 +5882,8 @@ package activator
import (
"context"
+ "strings"
+ "sync/atomic"
"testing"
"time"
@@ -5731,6 +6015,65 @@ func TestReplaceWorkerOneShotCleanExitIsReady(t *testing.T) {
}
}
+// Spec §6.3, §7.4, §8.1: the attempt's checks run again at the actual
+// spawn, after the old child has retired, and again before its result is
+// accepted. A newer release (or a sibling's failure, whose halt bumps the
+// revision) that lands during the retire means the attempt's build never
+// execs: the build it replaced comes back instead. One that lands during
+// the observation means the result is abandoned, never done, so the build
+// is not recorded good. Checked only before the retire, both slip through.
+func TestReplaceWorkerReChecksAtTheSpawnAndBeforeTheResult(t *testing.T) {
+ t.Run("superseded while the old child retires", func(t *testing.T) {
+ shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond)
+ a, b, _, _, _ := stagedActivator(t)
+ host := "bot.x.internal"
+ if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
+ t.Fatal(err)
+ }
+ gate := make(chan struct{})
+ b.lastWorker().setStopGate(gate)
+ var rev atomic.Uint64
+ rev.Store(1)
+ results := make(chan SwapResult, 1)
+ go func() {
+ results <- a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil,
+ func(string) uint64 { return rev.Load() })
+ }()
+ waitFor(t, "the retire to begin", func() bool { return a.entryRespawning(host) })
+ rev.Store(2) // a newer release lands while the old child drains
+ close(gate)
+ res := <-results
+ if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) {
+ t.Fatalf("ReplaceWorker = %+v, want abandoned: superseded", res)
+ }
+ for _, c := range b.calls() {
+ if c != nil && *c == fakeIdentity("v2") {
+ t.Fatalf("spawns %v: the superseded build was exec'd", b.calls())
+ }
+ }
+ waitFor(t, "a child back on the entry", func() bool { return len(b.calls()) == 2 && !a.entryRespawning(host) })
+ })
+ t.Run("superseded during the observation", func(t *testing.T) {
+ shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond)
+ a, b, _, _, _ := stagedActivator(t)
+ host := "bot.x.internal"
+ if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
+ t.Fatal(err)
+ }
+ var rev atomic.Uint64
+ rev.Store(1)
+ go func() {
+ waitFor(t, "the v2 child", func() bool { return len(b.calls()) == 2 })
+ rev.Store(2)
+ }()
+ res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil,
+ func(string) uint64 { return rev.Load() })
+ if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) {
+ t.Fatalf("ReplaceWorker = %+v, want abandoned: a superseded result is never done", res)
+ }
+ })
+}
+
// A superseded attempt touches nothing.
func TestReplaceWorkerSupersededSpawnsNothing(t *testing.T) {
a, b, _, _, _ := stagedActivator(t)
@@ -5763,8 +6106,47 @@ In `activator.go`, add to `actEntry` (beside Task 9's fields):
// worker (spec §9.4). Both written under a.mu.
attemptBuilds []string
retireTook time.Duration
+ // attemptGate is a socketless attempt's §7.4 checks (swapChecksLocked
+ // with the attempt's revision), run again by the respawn under a.mu at
+ // the moment it would exec, after the old child has retired (spec §6.3,
+ // §8.1). attemptRestore is what the respawn execs instead when the gate
+ // refuses: the build the attempt replaced (nil: the live link).
+ // attemptVeto records that refusal for ReplaceWorker. All written under
+ // a.mu, set and cleared by ReplaceWorker.
+ attemptGate func() error
+ attemptRestore *release.StagedIdentity
+ attemptVeto error
```
+In plan 4's `respawnWorkerLocked` goroutine, the "Still wanted?" block
+
+```go
+ a.mu.Lock()
+ wanted := a.entries[host] == e && e.state == actAwake && !a.shuttingDown
+ a.mu.Unlock()
+```
+
+becomes
+
+```go
+ a.mu.Lock()
+ wanted := a.entries[host] == e && e.state == actAwake && !a.shuttingDown
+ if wanted && onto != nil && e.attemptGate != nil {
+ // A socketless attempt's checks, again, at the exec (spec §6.3,
+ // §7.4): a newer release or a halted cohort (whose halt bumps
+ // the revision) that landed while the old child drained means
+ // the attempt's build must not run. The build it replaced comes
+ // back instead, and ReplaceWorker abandons the attempt.
+ if gerr := e.attemptGate(); gerr != nil {
+ e.attemptVeto = gerr
+ onto = e.attemptRestore
+ }
+ }
+ a.mu.Unlock()
+```
+
+(`onto` is the goroutine's own copy of the parameter, so reassigning it touches nothing else.)
+
In plan 4's `respawnWorkerLocked` goroutine, replace the whole retire block
```go
@@ -5803,6 +6185,7 @@ package activator
import (
"context"
+ "errors"
"fmt"
"github.com/carlosframework/platform/internal/release"
@@ -5844,6 +6227,16 @@ func (a *Service) ReplaceWorker(ctx context.Context, at Attempt, fallback *relea
if fallback != nil {
e.attemptBuilds = append(e.attemptBuilds, fallback.Version)
}
+ // The same checks, carried into the respawn: it runs them again at the
+ // exec, after the old child has retired (spec §6.3, §7.4, §8.1).
+ e.attemptGate = func() error { return a.swapChecksLocked(e, at, newest) }
+ e.attemptVeto = nil
+ if prev.StagedPath != "" {
+ restore := prev
+ e.attemptRestore = &restore
+ } else {
+ e.attemptRestore = nil
+ }
// Spec §9.4: the bounce event names the attempt's cause.
emitEvent(a.Logs, e.route, "info", bounceText(true, at.Cause))
a.respawnWorkerLocked(e, e.proc, "attempt ("+string(at.Cause)+")", &onto)
@@ -5851,7 +6244,7 @@ func (a *Service) ReplaceWorker(ctx context.Context, at Attempt, fallback *relea
a.mu.Unlock()
defer func() {
a.mu.Lock()
- e.attemptBuilds = nil
+ e.attemptBuilds, e.attemptGate, e.attemptRestore, e.attemptVeto = nil, nil, nil, nil
a.mu.Unlock()
}()
@@ -5859,10 +6252,19 @@ func (a *Service) ReplaceWorker(ctx context.Context, at Attempt, fallback *relea
return abandon(errSwapStopped)
}
a.mu.Lock()
+ // The gate is for this attempt's own exec only: a fallback or restore
+ // respawn below must not be turned back by it.
+ e.attemptGate = nil
if a.entries[at.Host] != e || e.state != actAwake {
a.mu.Unlock()
return abandon(errSwapStopped)
}
+ if veto := e.attemptVeto; veto != nil {
+ // The respawn's re-check refused the attempt's build and put the
+ // build it replaced back: nothing of this attempt ran.
+ a.mu.Unlock()
+ return abandon(fmt.Errorf("re-checked at the spawn: %w", veto))
+ }
if !sameArtifact(e.workerServing, onto) {
// Nothing of the attempt's build ran: the backend refused the staged
// copy (release.ErrStagedCopyChanged), the config gate stood the
@@ -5894,6 +6296,14 @@ func (a *Service) ReplaceWorker(ctx context.Context, at Attempt, fallback *relea
mine := a.entries[at.Host] == e && e.state == actAwake
switch {
case r == settleOK, r == settleExited && !procCrashed(proc):
+ // The checks once more before the result counts (spec §6.3, §8.1):
+ // a newer release or a halted cohort since the spawn means this
+ // build must not be recorded good. Its child stays until the newer
+ // attempt replaces it.
+ if err := a.swapChecksLocked(e, at, newest); err != nil && !(r == settleExited && errors.Is(err, errSwapNotAwake)) {
+ a.mu.Unlock()
+ return abandon(err)
+ }
a.mu.Unlock()
return SwapResult{Outcome: SwapDone, Gen: served(onto), Drain: drain}
case r == settleCancelled && !unexpected:
@@ -6177,7 +6587,11 @@ git commit -m "zdd-5 task 11: socketless attempts on the same entry, and the rec
```go
// Service gains: GenerationsFile string (empty: never written, the flag off)
func (a *Service) generationsLocked() release.Generations
- func (a *Service) persistGenerations()
+ func (a *Service) persistGenerations() error // nil when written, or when GenerationsFile is empty
+ var errGenerationsUnpublished error
+ ```
+ **Publication gates the spawn** (spec §§4.2, 8.5, the index's generations-file contract). Every call made before a spawn (`doWake`, `doClaimWake`, `swapOverlap`, the socketless respawn) refuses that spawn when the write fails: root would otherwise read a stale file, or an absent one as "no generations", and could sweep or restage over the artifact about to run. A refused spawn is not a failure of the build: a wake fails before the lease, a swap is abandoned, and a socketless respawn execs nothing. Calls made after a change (wake, `finishStop`, the sweep, `leaveSet`) only log, since the file they replace already pins at least as much.
+ ```go
func sweepStagedVersionsWith(reg routeLister, optDir, dataDir string, grace time.Duration, now time.Time) // internal/host
```
@@ -6192,8 +6606,10 @@ package activator
import (
"context"
+ "os"
"path/filepath"
"slices"
+ "strings"
"testing"
"time"
@@ -6205,7 +6621,9 @@ func TestTheGenerationsFileFollowsEveryChange(t *testing.T) {
good := stagedID(t, dir, "v1")
a, _, _ := planActivator(t, WakePlan{Boot: good, Mode: ModeOverlap})
a.GenerationsFile = release.GenerationsPath(dir)
- a.persistGenerations()
+ if err := a.persistGenerations(); err != nil {
+ t.Fatal(err)
+ }
g, err := release.ReadGenerations(a.GenerationsFile)
if err != nil || g == nil || len(g.Hosts) != 0 {
t.Fatalf("before any spawn: %+v, %v; want an empty file, written", g, err)
@@ -6249,6 +6667,55 @@ func TestTheGenerationsFileNamesThePlannedBuildBeforeItSpawns(t *testing.T) {
close(b.gate)
t.Fatal("the spawning build and its fallback were not pinned before the spawn")
}
+
+// unwritableGenerationsFile is a path whose parent is a regular file, so
+// every write fails.
+func unwritableGenerationsFile(t *testing.T) string {
+ t.Helper()
+ blocker := filepath.Join(t.TempDir(), "not-a-dir")
+ if err := os.WriteFile(blocker, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return filepath.Join(blocker, "zdd", "generations.json")
+}
+
+// Spec §4.2, §8.5: root pins only what the file names. A planned wake whose
+// build could not be published must not exec it: root may sweep or restage
+// over an artifact the file does not name. Logging and carrying on (the
+// old persistGenerations) spawns it anyway. The refusal is no failure of
+// the build, and takes no lease.
+func TestAnUnpublishedBuildIsNeverSpawned(t *testing.T) {
+ t.Run("planned wake", func(t *testing.T) {
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a, b, evs := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
+ a.GenerationsFile = unwritableGenerationsFile(t)
+ _, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err == nil || !strings.Contains(err.Error(), errGenerationsUnpublished.Error()) {
+ t.Fatalf("err = %v, want errGenerationsUnpublished", err)
+ }
+ if gens := b.started(); len(gens) != 0 {
+ t.Fatalf("spawned %+v with the generations file unwritten", gens)
+ }
+ if acquires, _, _ := b.counts(); acquires != 0 {
+ t.Fatal("an unpublished wake took the lease")
+ }
+ if k := evs.kinds(); len(k) != 0 {
+ t.Fatalf("events %v: a publication failure is no failure of the build", k)
+ }
+ })
+ t.Run("overlap successor", func(t *testing.T) {
+ f := newSwapFixture(t)
+ f.a.GenerationsFile = unwritableGenerationsFile(t)
+ res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
+ if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errGenerationsUnpublished.Error()) {
+ t.Fatalf("Swap = %+v, want abandoned: unpublished", res)
+ }
+ if gens, raises, restores := f.b.calls(); gens != 0 || raises != restores {
+ t.Fatalf("spawned %d, raises %d, restores %d", gens, raises, restores)
+ }
+ })
+}
```
Append to plan 4's `internal/release/pins_test.go`:
@@ -6319,8 +6786,8 @@ func TestAnUnreadableGenerationsFileSkipsTheSweep(t *testing.T) {
- [ ] **Step 2: Run them to see them fail**
-Run: `TMPDIR=/tmp/k go test ./internal/activator/ ./internal/release/ ./internal/host/ -run 'TestTheGenerationsFile|TestBoxPinsNamesTheGoodBuild|TestSweepKeeps|TestAnUnreadableGenerations' -count=1`
-Expected: FAIL, `a.GenerationsFile undefined`, `undefined: sweepStagedVersionsWith`, and the `BoxPins` assertion.
+Run: `TMPDIR=/tmp/k go test ./internal/activator/ ./internal/release/ ./internal/host/ -run 'TestTheGenerationsFile|TestAnUnpublishedBuild|TestBoxPinsNamesTheGoodBuild|TestSweepKeeps|TestAnUnreadableGenerations' -count=1`
+Expected: FAIL, `a.GenerationsFile undefined`, `undefined: errGenerationsUnpublished`, `undefined: sweepStagedVersionsWith`, and the `BoxPins` assertion.
- [ ] **Step 3: Write the generations writer**
@@ -6330,6 +6797,8 @@ Create `internal/activator/genfile.go`:
package activator
import (
+ "errors"
+ "fmt"
"log"
"slices"
"sync"
@@ -6380,14 +6849,21 @@ func (a *Service) generationsLocked() release.Generations {
return g
}
+// errGenerationsUnpublished refuses a spawn whose build could not be
+// published to root's pin set. It is never a failure of the build.
+var errGenerationsUnpublished = errors.New("the generations file could not be written, so root could not pin this build")
+
// persistGenerations rewrites the generations file (index amendments) with
// plan 4's atomic writer. Called without a.mu, before the first spawn
-// (Start), before every planned spawn, and after every change to an
-// operation set. A failed write is logged: root then reads the previous
-// file, and the next change or sweep writes again.
-func (a *Service) persistGenerations() {
+// (Start), before every spawn of a staged build, and after every change to
+// an operation set. A failed write is logged and returned, wrapping
+// errGenerationsUnpublished. Before a spawn the caller refuses the spawn on
+// it (spec §4.2, §8.5): root reads only what the file names, so a build it
+// does not name may be swept or restaged under the process. After a change
+// the caller only logs: the file it failed to replace pins at least as much.
+func (a *Service) persistGenerations() error {
if a.GenerationsFile == "" {
- return
+ return nil
}
genFileMu.Lock()
defer genFileMu.Unlock()
@@ -6396,7 +6872,9 @@ func (a *Service) persistGenerations() {
a.mu.Unlock()
if err := release.WriteGenerations(a.GenerationsFile, g); err != nil {
log.Printf("activator: write %s: %v", a.GenerationsFile, err)
+ return fmt.Errorf("%w: %v", errGenerationsUnpublished, err)
}
+ return nil
}
```
@@ -6409,15 +6887,50 @@ Add to `Service` in `activator.go`, beside `Planner`:
GenerationsFile string
```
-Call `a.persistGenerations()` (never while holding `a.mu`) at each of these points:
-- `Start`: as its first statement, so the file exists before the first spawn.
-- `doWake`: directly after the planning block Task 9 added (after `a.mu.Unlock()` that stores `e.plan`), and before step 1. Add it after the `e.pin` hold path too: put the call outside the `if a.Planner != nil && e.pin == nil {` block so it runs for both.
-- `doClaimWake`: directly after the planning block Task 9 added there, before `policy := startPolicy{…}` (claimed wakes never reach `doWake`'s planning block).
+Call `a.persistGenerations()` (never while holding `a.mu`) at each of these points. The four **before a spawn** refuse the spawn on an error; the rest only log it (the function already does).
+
+Before a spawn:
+- `doWake`: directly after the planning block Task 9 added (after `a.mu.Unlock()` that stores `e.plan`), and before step 1, outside the `if a.Planner != nil && e.pin == nil {` block so it covers a hold's pinned wake too:
+ ```go
+ if plan.planned() || e.pin != nil {
+ if err := a.persistGenerations(); err != nil {
+ // Before the lease: nothing is taken, nothing runs, and no
+ // build is blamed (spec §4.2, §8.5).
+ return w, fmt.Errorf("%s: refusing to wake: %w", host, err)
+ }
+ }
+ ```
+- `doClaimWake`: directly after the planning block Task 9 added there, before `policy := startPolicy{…}` (claimed wakes never reach `doWake`'s planning block): the same block, returning `nil, fmt.Errorf("%s: refusing to wake: %w", rt.Host, err)`.
+- plan 3's `swapOverlap`: directly before its `g, proc, spawnErr := a.spawnSuccessor(mctx, gb, e, rt, work, m.gen)` line (plan 3 Task 10; the successor is already in the operation set, so the file names it):
+ ```go
+ if err := a.persistGenerations(); err != nil {
+ a.dropMember(e, m) // restores the raised parent (leaveSet)
+ return abandon(err)
+ }
+ ```
+- plan 4's `respawnWorkerLocked` goroutine, directly after the "Still wanted?" block's `a.mu.Unlock()` (so a socketless attempt's `e.attemptBuilds` are on disk before its build execs):
+ ```go
+ if wanted && onto != nil {
+ if perr := a.persistGenerations(); perr != nil {
+ // A staged build root cannot pin does not run. The old child
+ // is gone, so the entry is down until the sweep's crash
+ // restart (the live link, a registry pin) or ReplaceWorker's
+ // restore.
+ log.Printf("activator: worker %s respawn (%s) refused: %v", host, why, perr)
+ wanted = false
+ }
+ }
+ ```
+
+After a change (log only):
+- `Start`: as its first statement, so the file exists before the first spawn (`_ = a.persistGenerations()`).
- `wake`: after the `a.afterWake(e, w, err)` line Task 9 added.
- `finishStop`: after `close(e.stopped)`.
- `sweepOnce`: as its last statement, after `a.mu.Unlock()`: one rewrite per sweep catches any change the points above miss.
-- plan 3's `swapOverlap`: directly before its `g, proc, spawnErr := a.spawnSuccessor(mctx, gb, e, rt, work, m.gen)` line (plan 3 Task 10; the successor is already in the operation set), and plan 3's `leaveSet`: before `close(m.done)`.
-- plan 4's `respawnWorkerLocked` goroutine, twice: directly after the "Still wanted?" block's `a.mu.Unlock()` (before the spawn, so a socketless attempt's `e.attemptBuilds` are on disk before its build execs), and after the `default:` arm installs the new child and unlocks (`a.mu.Unlock()` before its `log.Printf("activator: worker %s respawned …`).
+- plan 3's `leaveSet`: before `close(m.done)`.
+- plan 4's `respawnWorkerLocked` goroutine, after the `default:` arm installs the new child and unlocks (`a.mu.Unlock()` before its `log.Printf("activator: worker %s respawned …`).
+
+Write each log-only call as `_ = a.persistGenerations()`.
- [ ] **Step 4: Put the good build in the pin sets**
@@ -6566,6 +7079,36 @@ func TestAFailedFirstWakeShowsAsHeld(t *testing.T) {
}
}
+// Spec §6.4, §9.1: an intentional stop (hibernation, maintenance) during a
+// first wake's 10 s ends the observation with no verdict. GenCancelled closes
+// what GenHealthy opened, so the host stops showing as settling and the
+// deploy watch can complete; nothing is recorded against the build. With no
+// closing event, observing[host] stayed set for ever.
+func TestACancelledObservationStopsSettlingAndRecordsNothing(t *testing.T) {
+ h := newHarness(t, "a")
+ h.act.mu.Lock()
+ delete(h.act.gens, "a") // asleep: its first wake is the only thing that runs v2
+ h.act.mu.Unlock()
+ h.stage("v2")
+ h.install("v2", "a")
+ h.pass()
+ g := activator.Gen{N: 9, Build: "v2", Digest: "sha-v2"}
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: h.rows.get("a"), Gen: g, FirstWake: true})
+ if got := h.m.Status().Hosts["a"]; got.Attempt != status.AttemptSettling {
+ t.Fatalf("under observation: a = %+v, want settling", got)
+ }
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("a"), Gen: g, FirstWake: true})
+ if got := h.m.Status().Hosts["a"]; got.Attempt == status.AttemptSettling {
+ t.Fatalf("after the cancelled observation: a = %+v, still settling", got)
+ }
+ if _, ok := h.book.Lookup("bab", "blog", "v2"); ok {
+ t.Fatal("a cancelled observation was recorded as a failure of the build")
+ }
+ if r := h.rows.get("a"); r.GoodVersion != "v1" {
+ t.Fatalf("good build = %q: a cancelled observation is no settle", r.GoodVersion)
+ }
+}
+
// Plan 6's deploy watch counts a sleeper as "installed, awaiting first
// wake" once its box reports B installed. No attempt runs for a sleeper, so
// the pass that first sees B must republish.
@@ -6604,7 +7147,7 @@ Add `"sync/atomic"` and `"github.com/carlosframework/platform/internal/release"`
- [ ] **Step 2: Run them to see them fail**
-Run: `go test ./internal/status/ ./internal/rollout/ -run 'TestTheRolloutReport|TestStatusReports|TestAFailedFirstWakeShows|TestANewlyInstalledBuild|TestReasonWords' -count=1`
+Run: `go test ./internal/status/ ./internal/rollout/ -run 'TestTheRolloutReport|TestStatusReports|TestAFailedFirstWakeShows|TestACancelledObservation|TestANewlyInstalledBuild|TestReasonWords' -count=1`
Expected: FAIL, `undefined: RolloutReport`.
- [ ] **Step 3: Add the report to status**
@@ -6720,6 +7263,10 @@ In `OnGen`, add first:
case ev.Kind == activator.GenHealthy && ev.FirstWake:
m.observing[rt.Host] = true
case ev.Kind != activator.GenHealthy:
+ // GenSettled, GenFailed, GenFellBack, and GenCancelled (Task 9: an
+ // intentional stop ended the observation with no verdict) all close
+ // it. OnGen's own switch has no GenCancelled case, so a cancelled
+ // observation records nothing (spec §6.4).
delete(m.observing, rt.Host)
}
m.mu.Unlock()
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-surfaces.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-surfaces.md
index 0aa7cabff..3e2a938fc 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-surfaces.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-surfaces.md
@@ -2730,6 +2730,31 @@ func TestWatchRolloutFollowsTheBoxToDone(t *testing.T) {
}
}
+// A host that is final keeps being refreshed: here the sleeper is final
+// (installed, asleep) from the first sweep, while its box's scheduler is
+// still busy, and the box goes idle a sweep later. A loop that skips final
+// hosts keeps the first report's SchedulerIdle=false for ever, so boxDone
+// never holds and a finished deploy times out. The host prints once.
+func TestWatchRolloutRefreshesAFinalHostUntilTheBoxIsDone(t *testing.T) {
+ var out bytes.Buffer
+ var calls atomic.Int64
+ list := sweeps(&calls,
+ []apiInstanceRecord{rec("b.test", &apiRollout{Box: "x1", Installed: B, Good: "b1"})},
+ []apiInstanceRecord{rec("b.test", &apiRollout{Box: "x1", Installed: B, Good: "b1"})},
+ []apiInstanceRecord{rec("b.test", &apiRollout{Box: "x1", Installed: B, Good: "b1", SchedulerIdle: true})},
+ )
+ if err := testWatch(&out, list, noURL(t)).run(targetsFor("b.test")); err != nil {
+ t.Fatalf("watch: %v\n%s", err, out.String())
+ }
+ line := watchWordInstalled + " https://b.test (" + watchAsleepTail + ")"
+ if got := strings.Count(out.String(), line); got != 1 {
+ t.Fatalf("%q printed %d times, want once:\n%s", line, got, out.String())
+ }
+ if calls.Load() < 3 {
+ t.Fatalf("done after %d sweeps, before the box's scheduler went idle", calls.Load())
+ }
+}
+
// Review Focus 2.
func TestWatchRolloutNoOpDeployEndsAtOnce(t *testing.T) {
var calls atomic.Int64
@@ -3120,7 +3145,12 @@ func (rw rolloutWatch) run(targets []watchTarget) error {
}
}
for _, h := range hosts {
- if h.done() || h.mode == modeURL {
+ // Every rollout host is refreshed until the whole watch ends,
+ // final ones included: boxDone reads each host's latest
+ // SchedulerIdle, and a sleeper that went final while a sibling
+ // settled must not keep the stale "busy" it was final with.
+ // printed (below) is what keeps a final host from printing twice.
+ if h.mode == modeURL {
continue
}
ro := reports[h.t.Host]
diff --git a/docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md b/docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md
index 771653717..563b63a40 100644
--- a/docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md
+++ b/docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md
@@ -1,6 +1,6 @@
# Zero-downtime deploys: overlap swaps and paced rollout
-Status: rev 16, 2026-10-07. Rev 16 says a sleeper's config is acknowledged at delivery with today's hibernating marker (§8.1). Rev 15 keeps the canonical socket path as a symlink to the current generation, for outside consumers (#103), found while planning. Rev 14 adopts `carlos promote --retry` from `main` (9e628024) as the retry signal, in place of a separate signed retry object. That is a simplification found while planning. **Accepted by Astra in round 13** (`CODEX-FINDINGS-ZERO-DOWNTIME-R13.md`). Awaiting Paul's review. Discussions #231 (deploy pickup) and #230
+Status: rev 17, 2026-10-07. Rev 17 adds two clarifications from planning: a connection belongs to one generation (§7.2), and foreign-layout instances run their swaps as `hold` (§2.9). Rev 16 says a sleeper's config is acknowledged at delivery with today's hibernating marker (§8.1). Rev 15 keeps the canonical socket path as a symlink to the current generation, for outside consumers (#103), found while planning. Rev 14 adopts `carlos promote --retry` from `main` (9e628024) as the retry signal, in place of a separate signed retry object. That is a simplification found while planning. **Accepted by Astra in round 13** (`CODEX-FINDINGS-ZERO-DOWNTIME-R13.md`). Awaiting Paul's review. Discussions #231 (deploy pickup) and #230
(per-app restart pace, first-route health step) feed into this. Astra
rejected rev 1 with 17 findings, rev 2 with 12 new ones, rev 3 with 7, rev 4
with 3 and rev 5 with 4. The findings files are
@@ -62,6 +62,8 @@ These were settled with Paul on 2026-10-06/07 and are not reopened here.
unlink their socket path on exit. Socket activation was rejected because
only apps written for it would work.
9. **Scope.** Exec instances, in both the flat and nested layouts, overlap.
+ Instances in a foreign layout keep their canonical socket and never
+ overlap: an overlap attempt on one runs as `hold`.
Worker and sidecar processes keep stop-then-start. Static sites already
swap atomically. Named-unit routes are in §13.
@@ -341,6 +343,13 @@ generation stops being current, its idle connections are closed.
`dialUnixWithRetry` keeps its ENOENT/ECONNREFUSED retry, and each retry
asks `AppSocket` again.
+**A connection belongs to one generation.** The edge picks the current
+generation once per request and dials that generation's socket. Each
+generation's idle pool holds only connections to its own socket. The
+response is attributed to that generation, for the version header and the
+cache. If the generation stops being current before the dial completes,
+the request is retried against the new current generation.
+
**The edge cache is tagged with the serving route and its generation.**
The cache key stays the requested hostname. Custom domains, wildcards and
path-routed front doors all differ from the instance host