activator: unproven stops keep the lease, sidecar restarts leave the lock, wakes run on the committed row (plan 3, task 5)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+400
−34
internal/activator/activator.go+198 −21internal/activator/activator_test.go+179 −3internal/activator/maintenance_test.go+23 −10
CI passed — run details
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index fac275fa8..e76214b69 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -55,6 +55,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -179,6 +180,16 @@ type DueStore interface { |
| EarliestTimers() (map[string]int64, error) |
| } |
| +// RouteLookup is the registry read doWake performs before Acquire (design |
| +// §6.8): the edge hands a wake the route it had cached at request time, which |
| +// can be stale by the time the wake actually runs (a config edit landed in |
| +// between); doWake re-reads the committed row so it spawns against |
| +// whatever's really in the registry, not a snapshot. *registry.Registry |
| +// satisfies this directly with its own Get. |
| +type RouteLookup interface { |
| + Get(host string) (*registry.Route, error) |
| +} |
| + |
| // Lease is a held-but-not-yet-replicating lease (wake in progress; Eleven's |
| // actLease). |
| type Lease interface { |
| @@ -292,11 +303,29 @@ type actEntry struct { |
| // awake. All sidecar fields are written under a.mu in the awake state only |
| // (wake's attach, the sweeper's restart); a stop flips the state under the |
| // same lock first, so stop() owns them uncontended from then on. |
| - sidecar Proc // the co-spawned `sidecar run` child; nil = none running |
| - sidecarWanted bool // the host had a sidecar staged at wake time |
| - sidecarStarted time.Time // when the current sidecar spawned (backoff reset) |
| - sidecarBackoff time.Duration // last restart delay (doubles, capped) |
| - sidecarRetryAt time.Time // earliest next restart attempt |
| + sidecar Proc // the co-spawned `sidecar run` child; nil = none running |
| + sidecarWanted bool // the host had a sidecar staged at wake time |
| + sidecarStarted time.Time // when the current sidecar spawned (backoff reset) |
| + sidecarBackoff time.Duration // last restart delay (doubles, capped) |
| + sidecarRetryAt time.Time // earliest next restart attempt |
| + sidecarStarting bool // a restart's backend.StartSidecar call is in flight, off a.mu (design §6.7) |
| + |
| + // pendingStop marks a stop whose proc.Stop returned ErrStillRunning |
| + // (design §6.3): termination is unproven, so stop() returned without |
| + // releasing the lease or forgetting the entry, and the entry stays in |
| + // stopping. sweepOnce polls proc.Exited() for every pendingStop entry |
| + // and finishes the stop (finishStop) the moment it's proven. |
| + pendingStop bool |
| + |
| + // wakeGen counts this host's wakes, bumped each time an entry becomes |
| + // awake (in wake()). A sidecar restart goroutine captures it before |
| + // dropping a.mu (design §6.7) and re-checks it on the far side of the |
| + // async StartSidecar call: a generation mismatch means the entry moved |
| + // on (stopped and possibly rewoken) while the call was in flight, so |
| + // the goroutine's result belongs to a wake that's no longer current and |
| + // must be discarded (stopping whatever it started) rather than |
| + // installed. |
| + wakeGen uint64 |
| } |
| // Sidecar restart pacing — Restart=always semantics without a supervisor: the |
| @@ -386,6 +415,13 @@ type Service struct { |
| now func() time.Time // a seam for the unit tests' clock |
| liveVer func(string) (string, error) // live/<host> symlink target; a seam for tests |
| + // Routes, when non-nil, is read by doWake before Acquire (design §6.8) |
| + // to refresh the route against the registry's committed row rather than |
| + // spawning against the copy the edge cached at request time. Nil (every |
| + // test that doesn't wire it, and any caller that doesn't set it) keeps |
| + // today's behaviour: the wake runs on the route it was handed. |
| + Routes RouteLookup |
| + |
| // Logs, when non-nil, receives member-visible platform events (wake |
| // outcomes, restart bounces) for `carlos logs` — set before Start, like |
| // the backend's own Logs field. Nil (every test, a box without the |
| @@ -674,6 +710,7 @@ func (a *Service) wake(e *actEntry) { |
| delete(a.entries, e.host) // next request retries from scratch |
| } else { |
| e.state = actAwake |
| + e.wakeGen++ // this entry's sidecar-restart goroutines (design §6.7) key on this generation |
| e.rep, e.proc = w.rep, w.proc |
| e.sidecar, e.sidecarWanted = w.sidecar, w.sidecarWanted |
| e.sidecarUncertain = w.sidecarUncertain |
| @@ -776,6 +813,32 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance |
| return w, ErrFenced |
| } |
| + // 0. Refresh against the registry's committed row (design §6.8): the |
| + // edge cached rt at request time, and a config edit (provision, a path |
| + // change, a residence move) can land between that cache and this wake |
| + // actually running. Spawning against a stale DBPath/Socket would boot |
| + // the instance onto the wrong place on disk or bind the wrong socket, so |
| + // the wake re-reads the row it's actually about to spawn and carries |
| + // that forward — every step below reads rt, not the edge's copy. |
| + if a.Routes != nil { |
| + fresh, err := a.Routes.Get(rt.Host) |
| + if err != nil { |
| + return w, fmt.Errorf("%s: route lookup: %w", rt.Host, err) |
| + } |
| + if fresh == nil { |
| + return w, fmt.Errorf("%s: no route", rt.Host) |
| + } |
| + if fresh.DBPath != rt.DBPath || fresh.Socket != rt.Socket { |
| + log.Printf("activator: %s: route refreshed: db %q -> %q socket %q -> %q", rt.Host, rt.DBPath, fresh.DBPath, rt.Socket, fresh.Socket) |
| + } |
| + rt = *fresh |
| + a.mu.Lock() |
| + if e := a.entries[rt.Host]; e != nil { |
| + e.route = rt |
| + } |
| + a.mu.Unlock() |
| + } |
| + |
| // 1. The lease first: everything after this line is done as the prefix's |
| // single writer. Contention is not retried here — on one box it means a |
| // previous incarnation hasn't released; tell the client to come back after |
| @@ -967,6 +1030,26 @@ func (a *Service) stop(e *actEntry) { |
| // crash. |
| emitEvent(a.Logs, e.route, "info", "instance stopping") |
| + // A sidecar restart (sweepOnce) runs its backend.StartSidecar call off |
| + // a.mu (design §6.7) and may still be in flight when a stop begins. Wait, |
| + // bounded by sidecarStopTimeout, for it to land — either it installs a |
| + // fresh e.sidecar in time and the co-stop below stops that one directly, |
| + // or it doesn't and the restart goroutine's own generation check (it |
| + // re-reads a.entries[host] on the far side of the call) finds this entry |
| + // gone or stopping and stops whatever it started instead. Either way |
| + // nothing is left orphaned; this wait only decides which of the two |
| + // stops it. |
| + deadline := time.Now().Add(sidecarStopTimeout) |
| + for { |
| + a.mu.Lock() |
| + starting := e.sidecarStarting |
| + a.mu.Unlock() |
| + if !starting || time.Now().After(deadline) { |
| + break |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + |
| // Co-stop, sidecar FIRST — before the next-due probe and the instance's |
| // own SIGTERM. Two reasons: the sidecar must never outlive its instance |
| // (an orphan's reconnect loop would be a standing wake source — the |
| @@ -1013,16 +1096,41 @@ func (a *Service) stop(e *actEntry) { |
| // Separate budgets: a SIGTERM drain that runs long must not eat the final |
| // checkpoint's time — the S3 sync is the part that makes sleep durable. |
| procCtx, cancelProc := context.WithTimeout(context.Background(), actStopTimeout) |
| + defer cancelProc() |
| if e.proc != nil { |
| err = e.proc.Stop(procCtx) |
| + if errors.Is(err, ErrStillRunning) { |
| + // The lease is never released over an unproven termination |
| + // (design §6.3, codex R2-5): stop() bails out here rather than |
| + // falling through to the checkpoint and lease release below, |
| + // leaving the entry in stopping with pendingStop set. |
| + // sweepOnce polls proc.Exited() for it and calls finishStop — |
| + // the rest of what this function would have done — the moment |
| + // termination is actually proven. |
| + log.Printf("activator: stop %s: %v; keeping the lease until termination is proven", e.host, err) |
| + a.mu.Lock() |
| + e.pendingStop = true |
| + e.stopOutcome = outcome |
| + a.mu.Unlock() |
| + return |
| + } |
| outcome.mainStop, outcome.mainExited = err == nil, e.proc.Exited() |
| } else { |
| err = ErrStopUnproven |
| } |
| - cancelProc() |
| if err != nil { |
| log.Printf("activator: stop %s: %v", e.host, err) |
| } |
| + a.finishStop(e, outcome) |
| +} |
| + |
| +// finishStop is stop()'s tail: the final checkpoint, lease release, and |
| +// forgetting the entry. Split out (design §6.3, codex R2-5) so sweepOnce can |
| +// run it once a pendingStop entry's proc.Exited() finally proves the |
| +// termination that stop() itself couldn't — outcome carries forward whatever |
| +// stop() had already observed (the sidecar co-stop, the due-time probe) |
| +// before it hit ErrStillRunning and returned. |
| +func (a *Service) finishStop(e *actEntry, outcome *stopObservation) { |
| // Close = final sync + lease release. After a lost lease the release part |
| // fails harmlessly (someone else owns the lock); the sync still preserves |
| // a local checkpoint. |
| @@ -1033,8 +1141,8 @@ func (a *Service) stop(e *actEntry) { |
| repErr = e.rep.Close(ctx) |
| } |
| outcome.replicaClosed = repErr == nil |
| - if err := repErr; err != nil { |
| - log.Printf("activator: final checkpoint %s: %v", e.host, err) |
| + if repErr != nil { |
| + log.Printf("activator: final checkpoint %s: %v", e.host, repErr) |
| } |
| a.mu.Lock() |
| outcome.uncertain = !outcome.complete() |
| @@ -1103,6 +1211,24 @@ func (a *Service) sweepOnce() { |
| earliest = nil |
| } |
| a.mu.Lock() |
| + // Finish stops that were left pending because proc.Stop returned |
| + // ErrStillRunning (design §6.3, codex R2-5): termination wasn't proven |
| + // at the time, so stop() kept the lease and returned without forgetting |
| + // the entry. Checked before the idle pass below for the same reason it's |
| + // a separate loop at all — a pendingStop entry is in actStopping, not |
| + // actAwake, so the idle pass would never reach it. |
| + for _, e := range a.entries { |
| + if e.pendingStop && e.proc != nil && e.proc.Exited() { |
| + e.pendingStop = false |
| + outcome := e.stopOutcome |
| + if outcome == nil { |
| + outcome = &stopObservation{} |
| + } |
| + // The exit that unblocked this loop IS the proof stop() lacked. |
| + outcome.mainStop, outcome.mainExited = true, true |
| + go a.finishStop(e, outcome) |
| + } |
| + } |
| cycled := 0 // build-convergence cycles started this tick (rate-limited) |
| for _, e := range a.entries { |
| if a.fences[e.host] != 0 { |
| @@ -1193,20 +1319,71 @@ func (a *Service) sweepOnce() { |
| e.sidecarBackoff = min(e.sidecarBackoff*2, sidecarRestartMax) |
| } |
| e.sidecarRetryAt = now.Add(e.sidecarBackoff) |
| - sc, err := a.backend.StartSidecar(context.Background(), e.route) |
| - if err != nil { |
| - e.sidecarUncertain = true |
| - if sc == nil { |
| - log.Printf("activator: SIDECAR restart for %s failed: %v (next attempt in %s)", e.host, err, e.sidecarBackoff) |
| - } else { |
| - log.Printf("activator: SIDECAR restart for %s failed: %v — retaining returned process", e.host, err) |
| - } |
| - e.sidecar = sc |
| - continue |
| + // The restart call itself runs off a.mu (design §6.7, codex |
| + // R2-6): held under the lock, a stalled StartSidecar (a hung |
| + // exec, a wedged control socket) would block every other host's |
| + // wake and sweep behind it. sidecarStarting keeps a second |
| + // attempt from launching while one is already in flight; |
| + // wakeGen, re-checked on the far side, catches an entry that |
| + // stopped (and possibly rewoke under a new generation) while |
| + // the call was running, so a stale attempt's result is stopped |
| + // rather than installed onto a wake it doesn't belong to. |
| + if !e.sidecarStarting { |
| + e.sidecarStarting = true |
| + gen := e.wakeGen |
| + host := e.host |
| + route := e.route |
| + backoff := e.sidecarBackoff |
| + go func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl) |
| + sc, err := a.backend.StartSidecar(ctx, route) |
| + cancel() |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + e := a.entries[host] |
| + if e == nil || e.wakeGen != gen { |
| + // The entry is gone (stopped and forgotten) or this |
| + // host has moved on to a fresh wake under a new |
| + // generation — that entry's own sidecarStarting, if |
| + // any, belongs to a different attempt and is not |
| + // this goroutine's to touch. Just stop whatever |
| + // came back so it doesn't outlive the wake it |
| + // doesn't belong to. |
| + if sc != nil { |
| + go sc.Stop(context.Background()) //nolint:errcheck |
| + } |
| + return |
| + } |
| + // Still the same entry and generation: this attempt owns |
| + // its sidecarStarting flag regardless of what state the |
| + // entry is in now — a stop may have begun while the call |
| + // was in flight (stop() itself waits, bounded, for this |
| + // to clear; leaving it set here would make that wait run |
| + // the full sidecarStopTimeout for nothing). |
| + e.sidecarStarting = false |
| + if e.state != actAwake { |
| + // Stopped mid-call: don't install onto a wake that's |
| + // on its way out. |
| + if sc != nil { |
| + go sc.Stop(context.Background()) //nolint:errcheck |
| + } |
| + return |
| + } |
| + if err != nil { |
| + e.sidecarUncertain = true |
| + if sc == nil { |
| + log.Printf("activator: SIDECAR restart for %s failed: %v (next attempt in %s)", host, err, backoff) |
| + } else { |
| + log.Printf("activator: SIDECAR restart for %s failed: %v — retaining returned process", host, err) |
| + } |
| + e.sidecar = sc |
| + return |
| + } |
| + log.Printf("activator: sidecar for %s was down — restarted (backoff now %s)", host, backoff) |
| + e.sidecar = sc |
| + e.sidecarStarted = a.now() |
| + }() |
| } |
| - log.Printf("activator: sidecar for %s was down — restarted (backoff now %s)", e.host, e.sidecarBackoff) |
| - e.sidecar = sc |
| - e.sidecarStarted = now |
| } |
| } |
| // Due-time wakes: a request-less wake through the same singleflight state |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index 0a9075823..443056eb0 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -42,6 +42,13 @@ type fakeRep struct { |
| mu sync.Mutex |
| err error |
| closed bool |
| + // b, when set, is bumped on Close — production's Rep.Close "does the |
| + // final checkpoint/sync and releases the lease" (Rep's doc comment), so |
| + // a successful Close is the fake's equivalent of the lease coming free |
| + // once a wake has gotten past Replicate (before that point, the raw |
| + // Lease.Release calls below count it instead — the two are mutually |
| + // exclusive per wake, since Replicate spends the Lease). |
| + b *fakeBackend |
| } |
| func (r *fakeRep) Err() error { |
| @@ -56,8 +63,14 @@ func (r *fakeRep) setErr(err error) { |
| } |
| func (r *fakeRep) Close(context.Context) error { |
| r.mu.Lock() |
| - defer r.mu.Unlock() |
| r.closed = true |
| + b := r.b |
| + r.mu.Unlock() |
| + if b != nil { |
| + b.mu.Lock() |
| + b.releases++ |
| + b.mu.Unlock() |
| + } |
| return nil |
| } |
| func (r *fakeRep) isClosed() bool { |
| @@ -73,6 +86,13 @@ type fakeProc struct { |
| // stopGate, when non-nil, blocks Stop() until closed — holds an entry in |
| // the stopping state so a test can poke at it. |
| stopGate chan struct{} |
| + // stuck models a Proc whose termination cannot be proven within Stop's |
| + // bounded ctx (design §6.3, unitProc's ErrStillRunning): while stuck and |
| + // not yet dead, Stop reports ErrStillRunning instead of succeeding; exit() |
| + // clears it. stopAttempts counts every Stop() call, proven or not, so a |
| + // test can see the sweeper retrying a stop it hasn't been able to finish. |
| + stuck bool |
| + stopAttempts int |
| // label + rec feed the backend's order log ("instance" / "sidecar"), so |
| // the co-spawn/co-stop tests can assert interleavings. |
| label string |
| @@ -87,6 +107,11 @@ func (p *fakeProc) Stop(context.Context) error { |
| <-gate |
| } |
| p.mu.Lock() |
| + p.stopAttempts++ |
| + if p.stuck && !p.dead { |
| + p.mu.Unlock() |
| + return ErrStillRunning |
| + } |
| p.stopped = true |
| p.mu.Unlock() |
| if p.rec != nil { |
| @@ -104,6 +129,19 @@ func (p *fakeProc) isStopped() bool { |
| defer p.mu.Unlock() |
| return p.stopped |
| } |
| +func (p *fakeProc) stopCalls() int { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return p.stopAttempts |
| +} |
| + |
| +// exit marks the process as having died on its own (a crash, or — for a |
| +// stuck proc — the delayed termination Stop couldn't prove at the time). |
| +func (p *fakeProc) exit() { |
| + p.mu.Lock() |
| + p.dead = true |
| + p.mu.Unlock() |
| +} |
| type fakeLease struct { |
| rep *fakeRep |
| @@ -171,10 +209,21 @@ type fakeBackend struct { |
| sidecarErr error |
| sidecarStarts int |
| sidecars []*fakeProc |
| + // sidecarGate, when non-nil, blocks StartSidecar() until closed — models |
| + // a stalled restart call so a test can assert it doesn't hold a.mu. |
| + sidecarGate chan struct{} |
| // order interleaves backend calls and proc stops (start:instance, |
| // start:sidecar, nextDue, stop:sidecar, stop:instance) for the ordering |
| // assertions. |
| order []string |
| + |
| + // procStuck makes every Start() return a fakeProc whose Stop reports |
| + // ErrStillRunning until the test calls its exit() (design §6.3). |
| + procStuck bool |
| + // startedRoutes records the registry.Route Start() actually received, |
| + // per call — doWake's route-refresh assertion (design §6.8) needs to see |
| + // what the backend was actually handed, not what the caller passed in. |
| + startedRoutes []registry.Route |
| } |
| func (b *fakeBackend) record(ev string) { |
| @@ -193,7 +242,7 @@ func (b *fakeBackend) Acquire(context.Context, registry.Route) (Lease, error) { |
| b.mu.Lock() |
| b.acquires++ |
| err := b.acquireErr |
| - rep := &fakeRep{} |
| + rep := &fakeRep{b: b} |
| b.reps = append(b.reps, rep) |
| b.mu.Unlock() |
| if err != nil { |
| @@ -226,8 +275,9 @@ func (b *fakeBackend) Start(_ context.Context, rt registry.Route) (Proc, string, |
| gate := b.startGate |
| fn := b.execVerFn |
| startErr := b.startErr |
| - proc := &fakeProc{label: "instance", rec: b} |
| + proc := &fakeProc{label: "instance", rec: b, stuck: b.procStuck} |
| b.procs = append(b.procs, proc) |
| + b.startedRoutes = append(b.startedRoutes, rt) |
| b.mu.Unlock() |
| if startErr != nil { |
| return nil, "", startErr |
| @@ -259,6 +309,12 @@ func (b *fakeBackend) HasSidecar(registry.Route) bool { |
| } |
| func (b *fakeBackend) StartSidecar(context.Context, registry.Route) (Proc, error) { |
| + b.mu.Lock() |
| + gate := b.sidecarGate |
| + b.mu.Unlock() |
| + if gate != nil { |
| + <-gate |
| + } |
| b.mu.Lock() |
| defer b.mu.Unlock() |
| b.sidecarStarts++ |
| @@ -319,6 +375,12 @@ func (b *fakeBackend) lastProc() *fakeProc { |
| return b.procs[len(b.procs)-1] |
| } |
| +func (b *fakeBackend) lastStartedRoute() registry.Route { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.startedRoutes[len(b.startedRoutes)-1] |
| +} |
| + |
| func (b *fakeBackend) lastRep() *fakeRep { |
| b.mu.Lock() |
| defer b.mu.Unlock() |
| @@ -430,6 +492,18 @@ func waitFor(t *testing.T, what string, cond func() bool) { |
| t.Fatalf("timed out waiting for %s", what) |
| } |
| +// sidecarSettled reports whether host has no sidecar restart in flight — the |
| +// call runs off a.mu (design §6.7), so a test driving several restarts back |
| +// to back (each expecting the previous one's count and install to have |
| +// already landed) waits on this rather than racing sweepOnce's own, now much |
| +// shorter, return. |
| +func (a *Service) sidecarSettled(host string) bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + e := a.entries[host] |
| + return e == nil || !e.sidecarStarting |
| +} |
| + |
| func (a *Service) entryCount() int { |
| a.mu.Lock() |
| defer a.mu.Unlock() |
| @@ -2045,7 +2119,11 @@ func TestActivatorSidecarFailureDoesNotFailWake(t *testing.T) { |
| // The sweeper retries: attempt 2 immediately (first retry), then gated by |
| // the doubling backoff; once the fault clears, a retry lands the sidecar. |
| + // The restart call itself now runs off a.mu (design §6.7), so a fresh |
| + // attempt lands asynchronously — waitFor gives it a moment rather than |
| + // asserting on sweepOnce's own (now much shorter) return. |
| a.sweepOnce() |
| + waitFor(t, "sidecar restart attempt 2 to land", func() bool { return b.sidecarStartCount() == 2 && a.sidecarSettled(route.Host) }) |
| if got := b.sidecarStartCount(); got != 2 { |
| t.Fatalf("sidecar attempts after first sweep = %d, want 2", got) |
| } |
| @@ -2056,6 +2134,7 @@ func TestActivatorSidecarFailureDoesNotFailWake(t *testing.T) { |
| b.setSidecarErr(nil) |
| clk.advance(3 * time.Second) |
| a.sweepOnce() |
| + waitFor(t, "sidecar restart attempt 3 to land", func() bool { return b.sidecarStartCount() == 3 && a.sidecarSettled(route.Host) }) |
| if got := b.sidecarStartCount(); got != 3 { |
| t.Fatalf("sidecar attempts after fault cleared = %d, want 3", got) |
| } |
| @@ -2135,9 +2214,17 @@ func TestActivatorSidecarRestartBackoff(t *testing.T) { |
| sc.mu.Unlock() |
| } |
| + // The restart call itself runs off a.mu (design §6.7): each waitFor below |
| + // gives the previous attempt's goroutine a moment to land — both the |
| + // count and the entry's sidecarStarting flag — before the next kill/sweep, |
| + // so a slow scheduler can't make this sequence's next sweepOnce see the |
| + // prior attempt still "in flight" and skip its own. |
| + host := "bots.example.com" |
| + |
| // T0: first crash — restarted on the next sweep (attempt 2), gate 2s. |
| kill() |
| a.sweepOnce() |
| + waitFor(t, "restart 2 to land", func() bool { return b.sidecarStartCount() == 2 && a.sidecarSettled(host) }) |
| if got := b.sidecarStartCount(); got != 2 { |
| t.Fatalf("first restart: attempts = %d, want 2", got) |
| } |
| @@ -2150,6 +2237,7 @@ func TestActivatorSidecarRestartBackoff(t *testing.T) { |
| // T3: past the 2s gate — attempt 3, gate doubles to 4s. |
| clk.advance(3 * time.Second) |
| a.sweepOnce() |
| + waitFor(t, "restart 3 to land", func() bool { return b.sidecarStartCount() == 3 && a.sidecarSettled(host) }) |
| if got := b.sidecarStartCount(); got != 3 { |
| t.Fatalf("second restart: attempts = %d, want 3", got) |
| } |
| @@ -2163,6 +2251,7 @@ func TestActivatorSidecarRestartBackoff(t *testing.T) { |
| // T8: past it — attempt 4 (gate now 8s). |
| clk.advance(2 * time.Second) |
| a.sweepOnce() |
| + waitFor(t, "restart 4 to land", func() bool { return b.sidecarStartCount() == 4 && a.sidecarSettled(host) }) |
| if got := b.sidecarStartCount(); got != 4 { |
| t.Fatalf("third restart: attempts = %d, want 4", got) |
| } |
| @@ -2171,6 +2260,7 @@ func TestActivatorSidecarRestartBackoff(t *testing.T) { |
| clk.advance(2 * time.Minute) |
| kill() |
| a.sweepOnce() |
| + waitFor(t, "restart 5 to land", func() bool { return b.sidecarStartCount() == 5 && a.sidecarSettled(host) }) |
| if got := b.sidecarStartCount(); got != 5 { |
| t.Fatalf("post-healthy restart: attempts = %d, want 5", got) |
| } |
| @@ -2178,6 +2268,7 @@ func TestActivatorSidecarRestartBackoff(t *testing.T) { |
| kill() |
| clk.advance(3 * time.Second) |
| a.sweepOnce() |
| + waitFor(t, "restart 6 to land", func() bool { return b.sidecarStartCount() == 6 && a.sidecarSettled(host) }) |
| if got := b.sidecarStartCount(); got != 6 { |
| t.Fatalf("backoff not reset after a healthy run: attempts = %d, want 6", got) |
| } |
| @@ -4014,3 +4105,88 @@ func TestChildEnvKeepsOneEntryPerKeyLastWins(t *testing.T) { |
| t.Errorf("A = %q", seen["A"]) |
| } |
| } |
| + |
| +// A Proc whose Stop reports ErrStillRunning must not have its lease |
| +// released: stop() keeps the entry in stopping and the sweeper finishes it |
| +// once the proc reports exited (design §6.3, codex R2-5). |
| +func TestStopDoesNotReleaseTheLeaseOverAnUnprovenStop(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Minute) |
| + b.procStuck = true // Start returns a fakeProc whose Stop reports ErrStillRunning until exit() |
| + rt := testRoute("h.example.com") |
| + arrangeAwake(t, a, rt) // wake rt and release the request, as the idle-hibernate test does |
| + |
| + clk.advance(2 * time.Minute) |
| + a.sweepOnce() // idle: stop() runs, Stop reports ErrStillRunning |
| + p := b.lastProc() |
| + waitFor(t, "the stuck stop to be attempted", func() bool { return p.stopCalls() >= 1 }) |
| + if n := b.releaseCount(); n != 0 { |
| + t.Fatalf("lease released %d times while the stop is unproven", n) |
| + } |
| + if a.entryCount() != 1 { |
| + t.Fatal("entry forgotten before the stop was proven") |
| + } |
| + p.exit() |
| + a.sweepOnce() |
| + waitFor(t, "the lease to release once termination is proven", func() bool { return b.releaseCount() == 1 }) |
| +} |
| + |
| +// The sidecar restart must not hold the activator mutex across the start |
| +// call (design §6.7, codex R2-6): a stalled sidecar start does not block |
| +// another host's wake. |
| +func TestSidecarRestartDoesNotHoldTheLockAcrossTheStart(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Hour) |
| + b.sidecarStaged = true |
| + |
| + // wake a.example.com first, ungated — the gate below is for the |
| + // sweeper's RESTART call, not this initial co-spawn. |
| + arrangeAwake(t, a, testRoute("a.example.com")) |
| + b.lastSidecar().exit() // the sweeper now wants a restart |
| + |
| + gate := make(chan struct{}) |
| + b.sidecarGate = gate // StartSidecar blocks on this channel when non-nil |
| + clk.advance(time.Minute) |
| + go a.sweepOnce() // blocks inside StartSidecar until the gate closes |
| + time.Sleep(50 * time.Millisecond) |
| + // host b has no sidecar of its own — HasSidecar false keeps its wake's |
| + // own co-spawn (unrelated to what's under test) off the same gate. |
| + b.sidecarStaged = false |
| + done := make(chan struct{}) |
| + go func() { |
| + // wake b.example.com |
| + release, err := a.beginRequest(context.Background(), testRoute("b.example.com")) |
| + if err != nil { |
| + t.Errorf("beginRequest b.example.com: %v", err) |
| + return |
| + } |
| + release() |
| + close(done) |
| + }() |
| + select { |
| + case <-done: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("a second host's wake blocked behind a stalled sidecar start") |
| + } |
| + close(gate) |
| +} |
| + |
| +// doWake runs on the committed row, not the copy the edge supplied |
| +// (design §6.8, codex R8-1). |
| +func TestDoWakeRefreshesTheRouteFromTheRegistry(t *testing.T) { |
| + a, b, _, reg := testActivator(t, time.Hour) |
| + a.Routes = reg |
| + old := testRoute("h.example.com") |
| + fresh := old |
| + fresh.DBPath = "/data/instances/h.example.com/h.example.com.db" |
| + fresh.Socket = "/run/carlos/instances/h.example.com/app.sock" |
| + // insert fresh into reg the way the file's registry-backed tests do |
| + if err := reg.Upsert(fresh); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + arrangeAwake(t, a, old) // wake with old (the edge's stale copy) |
| + if got := b.lastStartedRoute().DBPath; got != fresh.DBPath { |
| + t.Fatalf("Start saw DBPath %q, want the registry's %q", got, fresh.DBPath) |
| + } |
| + if got := b.lastStartedRoute().Socket; got != fresh.Socket { |
| + t.Fatalf("Start saw Socket %q, want the registry's %q", got, fresh.Socket) |
| + } |
| +} |
diff --git a/internal/activator/maintenance_test.go b/internal/activator/maintenance_test.go| index e90d7628f..90f2196b6 100644 |
| --- a/internal/activator/maintenance_test.go |
| +++ b/internal/activator/maintenance_test.go |
| @@ -379,6 +379,17 @@ func TestMaintenanceFailedBackendStartRemainsUnproven(t *testing.T) { |
| t.Fatal("hidden cleanup failure became proof", err) |
| } |
| } |
| + |
| +// Before design §6.7 (codex R2-6) this restart ran synchronously under a.mu, |
| +// so a fence attempt that raced a stalled StartSidecar found the lock held |
| +// and came back ErrFenceNotInstalled without ever touching the entry. Task 5 |
| +// moved the call off a.mu specifically so a stalled restart can't block |
| +// anything else — which includes FenceAndStop: it now installs its fence |
| +// immediately. What still bounds a short-context attempt is stop()'s own |
| +// wait (sidecarStopTimeout) for the stalled restart to land before it can |
| +// act on e.sidecar — so the outcome changes (ErrStopUnproven, fence |
| +// installed) but a stalled sidecar still keeps a checked stop from |
| +// completing instantly, which is the property this test exists to cover. |
| func TestMaintenanceFenceSerializesSidecarSweepReplacement(t *testing.T) { |
| a, e, _, side, rep := maintenanceAwake(t) |
| gate, entered := make(chan struct{}), make(chan struct{}) |
| @@ -388,23 +399,22 @@ func TestMaintenanceFenceSerializesSidecarSweepReplacement(t *testing.T) { |
| a.backend = &maintenanceBackend{fakeBackend: base, side: next, sideGate: gate, sideEntered: entered, rep: rep} |
| side.exited.Store(true) |
| e.sidecarWanted = true |
| - sweep := make(chan struct{}) |
| - go func() { a.sweepOnce(); close(sweep) }() |
| + // sweepOnce itself now returns immediately — the restart it launches |
| + // runs in its own goroutine, which is what's actually gated below. |
| + a.sweepOnce() |
| <-entered |
| - if proof, err := a.FenceAndStop(maintenanceContext(t, 15*time.Millisecond), e.host, 1); err != ErrFenceNotInstalled || proof != nil { |
| + |
| + if proof, err := a.FenceAndStop(maintenanceContext(t, 15*time.Millisecond), e.host, 1); err != ErrStopUnproven || proof != nil { |
| t.Fatal("blocked replacement yielded proof", err) |
| } |
| - close(gate) |
| - <-sweep |
| - if !a.wakeAllowed(e.host) { |
| - t.Fatal("timed-out admission attempt installed a fence") |
| + if a.wakeAllowed(e.host) { |
| + t.Fatal("fenced admission attempt failed to install its fence") |
| } |
| + close(gate) |
| if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 1); err != nil || !proof.Matches(e.host, 1) { |
| t.Fatal("replacement escaped checked stop", err) |
| } |
| - if next.calls.Load() != 1 { |
| - t.Fatal("replacement sidecar was not stopped") |
| - } |
| + waitFor(t, "the discarded replacement to be stopped", func() bool { return next.calls.Load() == 1 }) |
| before := base.sidecarStartCount() |
| a.sweepOnce() |
| if base.sidecarStartCount() != before { |
| @@ -519,6 +529,9 @@ func TestMaintenanceFailedSidecarHandlesRemainOwned(t *testing.T) { |
| old.exited.Store(true) |
| a.entries[rt.Host] = &actEntry{host: rt.Host, route: rt, state: actAwake, proc: main, rep: rep, sidecar: old, sidecarWanted: true, lastActive: a.now()} |
| a.sweepOnce() |
| + // The restart call runs off a.mu (design §6.7) — give it a |
| + // moment to land before inspecting what it installed. |
| + waitFor(t, "sidecar restart to land", func() bool { return base.sidecarStartCount() == 1 && a.sidecarSettled(rt.Host) }) |
| } |
| a.mu.Lock() |
| entry := a.entries[rt.Host] |