activator: stale sidecar restarts are discarded by entry identity; pending stops re-call Stop and finish under Shutdown (plan 3, task 5 fix round 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+256
−88
internal/activator/activator.go+159 −84internal/activator/activator_test.go+97 −4
CI passed — run details
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index e76214b69..b88df25d1 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -309,23 +309,19 @@ type actEntry struct { |
| 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) |
| + // sidecarStartDone is closed by the in-flight restart goroutine when it |
| + // finishes (installed or discarded), non-nil exactly while |
| + // sidecarStarting is true. stop() waits on it, bounded by |
| + // sidecarStopTimeout, instead of polling sidecarStarting. |
| + sidecarStartDone chan struct{} |
| // 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. |
| + // stopping. sweepOnce (and, during Shutdown, finishPendingStops) polls |
| + // proc.Exited() for every pendingStop entry and finishes the stop |
| + // (finishPendingStop) 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 |
| @@ -338,11 +334,34 @@ const ( |
| sidecarHealthyRun = time.Minute |
| ) |
| -// sidecarStopTimeout bounds the sidecar's stop at sleep time. `sidecar run` |
| -// has no drain handler, so the SIGTERM is effectively immediate; the SIGKILL |
| -// fallback behind this timeout is insurance. |
| +// sidecarStopTimeout serves two related bounds. First, its name: the |
| +// sidecar's own stop at sleep time — `sidecar run` has no drain handler, so |
| +// the SIGTERM is effectively immediate, and the SIGKILL fallback behind this |
| +// timeout is insurance. Second (design §6.7): how long stop() waits on |
| +// sidecarStartDone for an in-flight sidecar restart to land before giving up |
| +// and proceeding on whatever e.sidecar currently is. The restart goroutine's |
| +// own entry-identity check makes that second wait purely a promptness |
| +// trade-off, never a correctness one — nothing is orphaned or misplaced |
| +// whether it fires in time or times out. |
| const sidecarStopTimeout = 5 * time.Second |
| +// stopDiscardedSidecar stops a sidecar process a restart attempt returned |
| +// but could not install — the wake it belonged to moved on (stopped, or |
| +// replaced by a fresh wake of the same host) while the call was in flight. |
| +// Bounded by sidecarStopTimeout so a wedged Stop can't leak the goroutine |
| +// forever; fire-and-forget, since nothing is waiting on this beyond the |
| +// process actually dying. |
| +func stopDiscardedSidecar(sc Proc) { |
| + if sc == nil { |
| + return |
| + } |
| + go func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), sidecarStopTimeout) |
| + defer cancel() |
| + sc.Stop(ctx) //nolint:errcheck |
| + }() |
| +} |
| + |
| // Build convergence: how long an awake child may serve a symlink-stale binary |
| // before the sweep gracefully cycles it, and how many to cycle per tick. |
| // - ConvergeGrace comfortably exceeds one sweep interval (≤30s) and a |
| @@ -710,7 +729,6 @@ 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 |
| @@ -1034,20 +1052,21 @@ func (a *Service) stop(e *actEntry) { |
| // 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 |
| + // or it doesn't and the restart goroutine's own entry-identity 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 |
| + // stops it. sidecarStartDone is closed by the restart goroutine when it |
| + // finishes — however it finishes — so this waits on that signal directly |
| + // rather than polling sidecarStarting on a timer. |
| + a.mu.Lock() |
| + startDone := e.sidecarStartDone |
| + a.mu.Unlock() |
| + if startDone != nil { |
| + select { |
| + case <-startDone: |
| + case <-time.After(sidecarStopTimeout): |
| } |
| - time.Sleep(20 * time.Millisecond) |
| } |
| // Co-stop, sidecar FIRST — before the next-due probe and the instance's |
| @@ -1124,12 +1143,56 @@ func (a *Service) stop(e *actEntry) { |
| 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. |
| +// finishPendingStop finishes a stop that was left pending because proc.Stop |
| +// returned ErrStillRunning (design §6.3): now that proc.Exited() has proven |
| +// termination, spec §6.3 calls for retrying proc.Stop once more before |
| +// declaring the main process closed — "sweepOnce retries proc.Stop on its |
| +// cadence until done closes" — so outcome.mainStop reflects an actual |
| +// observation rather than an assumption baked in at the ErrStillRunning |
| +// return. A unitProc that has already exited takes its done branch and |
| +// returns nil immediately; a childProc's Stop is a no-op on an |
| +// already-exited process either way, so retrying costs nothing once the |
| +// process is well and truly gone. mainExited is unconditionally true — the |
| +// exit that made this entry eligible for this pass IS that proof — whatever |
| +// this retried Stop call itself reports. |
| +func (a *Service) finishPendingStop(e *actEntry, outcome *stopObservation) { |
| + ctx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| + err := e.proc.Stop(ctx) |
| + cancel() |
| + outcome.mainStop, outcome.mainExited = err == nil, true |
| + if err != nil { |
| + log.Printf("activator: stop %s: %v", e.host, err) |
| + } |
| + a.finishStop(e, outcome) |
| +} |
| + |
| +// finishPendingStops scans for entries left pending because proc.Stop |
| +// returned ErrStillRunning (design §6.3, codex R2-5) and finishes every one |
| +// whose proc has since exited, via finishPendingStop. Called from |
| +// sweepOnce's own tick and, once more, from Shutdown — the sweeper is |
| +// stopped by the time Shutdown runs, so without this second call a process |
| +// that exits during shutdown would never get its final checkpoint. The |
| +// caller holds a.mu. |
| +func (a *Service) finishPendingStops() { |
| + 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{} |
| + } |
| + go a.finishPendingStop(e, outcome) |
| + } |
| + } |
| +} |
| + |
| +// finishStop is stop()'s (and finishPendingStop's) tail: the final |
| +// checkpoint, lease release, and forgetting the entry. Split out (design |
| +// §6.3, codex R2-5) so a pendingStop entry's eventual finish shares it with |
| +// stop()'s own ordinary path — outcome carries forward whatever stop() had |
| +// already observed (the sidecar co-stop, the due-time probe) before it hit |
| +// ErrStillRunning and returned, plus whatever finishPendingStop's own |
| +// retried proc.Stop just added. |
| 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 |
| @@ -1215,20 +1278,9 @@ func (a *Service) sweepOnce() { |
| // 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 |
| + // a separate call 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) |
| - } |
| - } |
| + a.finishPendingStops() |
| cycled := 0 // build-convergence cycles started this tick (rate-limited) |
| for _, e := range a.entries { |
| if a.fences[e.host] != 0 { |
| @@ -1323,65 +1375,73 @@ func (a *Service) sweepOnce() { |
| // 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. |
| + // attempt from launching while one is already in flight. |
| + // |
| + // The far side re-checks by ENTRY IDENTITY (e0, the *actEntry |
| + // itself), not a generation counter (codex review round 1, |
| + // Critical): a per-entry counter that starts at 0 and is bumped |
| + // once when an entry becomes awake reads 1 for every entry's |
| + // first wake, so it can never tell "this entry" apart from "a |
| + // completely different entry — for the same host, after a stop |
| + // and rewake — that also just had its first wake". A pointer |
| + // comparison against a.entries[host] has no such collision: a |
| + // fresh wake always allocates a brand new *actEntry (begin() and |
| + // the due-wake path both do `&actEntry{...}`), so identity alone |
| + // says whether this attempt's wake is still the current one. |
| if !e.sidecarStarting { |
| e.sidecarStarting = true |
| - gen := e.wakeGen |
| + done := make(chan struct{}) |
| + e.sidecarStartDone = done |
| + e0 := e |
| host := e.host |
| route := e.route |
| backoff := e.sidecarBackoff |
| go func() { |
| + defer close(done) |
| 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 |
| - } |
| + if a.entries[host] != e0 { |
| + // This host's map slot no longer holds e0 at all — |
| + // forgotten (stopped and deleted) or replaced by an |
| + // entirely different entry from a fresh wake. e0 is |
| + // unreachable from here on, so there's nothing left |
| + // on it to update; just stop whatever came back so |
| + // it doesn't outlive the wake it doesn't belong to, |
| + // or clobber a fresh wake's own sidecar. |
| + stopDiscardedSidecar(sc) |
| 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 |
| - } |
| + // Still e0: this attempt owns its sidecarStarting flag |
| + // and clears it (and the done channel already closing |
| + // via the defer above) unconditionally, regardless of |
| + // what state e0 is in now — a stop may have begun |
| + // (flipping e0 to actStopping) while the call was in |
| + // flight, and stop()'s own wait on sidecarStartDone |
| + // must not run the full sidecarStopTimeout for nothing. |
| + e0.sidecarStarting = false |
| + e0.sidecarStartDone = nil |
| + if e0.state != actAwake { |
| + // Stopped mid-call, not yet forgotten: don't install |
| + // onto a wake that's on its way out. |
| + stopDiscardedSidecar(sc) |
| return |
| } |
| if err != nil { |
| - e.sidecarUncertain = true |
| + e0.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 |
| + e0.sidecar = sc |
| return |
| } |
| log.Printf("activator: sidecar for %s was down — restarted (backoff now %s)", host, backoff) |
| - e.sidecar = sc |
| - e.sidecarStarted = a.now() |
| + e0.sidecar = sc |
| + e0.sidecarStarted = a.now() |
| }() |
| } |
| } |
| @@ -1497,6 +1557,12 @@ func (a *Service) Shutdown(ctx context.Context) { |
| <-a.sweepDone |
| } |
| a.mu.Lock() |
| + // The sweeper is stopped as of the line above, so nothing else will ever |
| + // poll a pendingStop entry (design §6.3) again on its own — run the same |
| + // pass once here, and again on the ticker below while waiting, so a |
| + // process that finally exits during shutdown still gets its final |
| + // checkpoint instead of being abandoned mid-drain. |
| + a.finishPendingStops() |
| var waits []chan struct{} |
| for _, e := range a.entries { |
| switch e.state { |
| @@ -1513,11 +1579,20 @@ func (a *Service) Shutdown(ctx context.Context) { |
| } |
| } |
| a.mu.Unlock() |
| + poll := time.NewTicker(100 * time.Millisecond) |
| + defer poll.Stop() |
| for _, w := range waits { |
| - select { |
| - case <-w: |
| - case <-ctx.Done(): |
| - return |
| + for done := false; !done; { |
| + select { |
| + case <-w: |
| + done = true |
| + case <-poll.C: |
| + a.mu.Lock() |
| + a.finishPendingStops() |
| + a.mu.Unlock() |
| + case <-ctx.Done(): |
| + return |
| + } |
| } |
| } |
| } |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index 443056eb0..a7045124d 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -212,6 +212,12 @@ type fakeBackend struct { |
| // 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{} |
| + // sidecarStartEntered, when non-nil, is closed the first time |
| + // StartSidecar is entered (before it waits on sidecarGate) — lets a test |
| + // wait for a restart's call to actually begin rather than sleeping a |
| + // guessed duration. |
| + sidecarStartEntered chan struct{} |
| + sidecarStartEnteredOnce sync.Once |
| // order interleaves backend calls and proc stops (start:instance, |
| // start:sidecar, nextDue, stop:sidecar, stop:instance) for the ordering |
| // assertions. |
| @@ -311,7 +317,11 @@ func (b *fakeBackend) HasSidecar(registry.Route) bool { |
| func (b *fakeBackend) StartSidecar(context.Context, registry.Route) (Proc, error) { |
| b.mu.Lock() |
| gate := b.sidecarGate |
| + entered := b.sidecarStartEntered |
| b.mu.Unlock() |
| + if entered != nil { |
| + b.sidecarStartEnteredOnce.Do(func() { close(entered) }) |
| + } |
| if gate != nil { |
| <-gate |
| } |
| @@ -4126,8 +4136,16 @@ func TestStopDoesNotReleaseTheLeaseOverAnUnprovenStop(t *testing.T) { |
| 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 }) |
| + // Drive the sweep from inside the wait itself: finishing a pendingStop |
| + // entry now retries proc.Stop (design §6.3) on the sweeper's own |
| + // cadence, so a single sweepOnce() right after exit() is a race against |
| + // exactly when the sweeper would next notice — polling sweepOnce() |
| + // alongside the release count removes that race instead of hoping one |
| + // call lands after exit() is visible. |
| + waitFor(t, "the lease to release once termination is proven", func() bool { |
| + a.sweepOnce() |
| + return b.releaseCount() == 1 |
| + }) |
| } |
| // The sidecar restart must not hold the activator mutex across the start |
| @@ -4143,13 +4161,19 @@ func TestSidecarRestartDoesNotHoldTheLockAcrossTheStart(t *testing.T) { |
| b.lastSidecar().exit() // the sweeper now wants a restart |
| gate := make(chan struct{}) |
| + entered := make(chan struct{}) |
| + b.mu.Lock() |
| b.sidecarGate = gate // StartSidecar blocks on this channel when non-nil |
| + b.sidecarStartEntered = entered |
| + b.mu.Unlock() |
| clk.advance(time.Minute) |
| - go a.sweepOnce() // blocks inside StartSidecar until the gate closes |
| - time.Sleep(50 * time.Millisecond) |
| + a.sweepOnce() // launches the restart; it blocks inside StartSidecar on gate |
| + <-entered // wait for the restart's call to actually begin, not a guessed sleep |
| // 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.mu.Lock() |
| b.sidecarStaged = false |
| + b.mu.Unlock() |
| done := make(chan struct{}) |
| go func() { |
| // wake b.example.com |
| @@ -4190,3 +4214,72 @@ func TestDoWakeRefreshesTheRouteFromTheRegistry(t *testing.T) { |
| t.Fatalf("Start saw Socket %q, want the registry's %q", got, fresh.Socket) |
| } |
| } |
| + |
| +// A restart whose StartSidecar call is still stalled must never install |
| +// onto a DIFFERENT wake of the same host once it finally returns (codex |
| +// review round 1, Critical). The bug it regression-tests: the discard check |
| +// used to compare a per-entry generation counter that is always 1 on an |
| +// entry's first wake (entries are never reused, so nothing ever bumps it a |
| +// second time), which meant it could never tell "this entry" apart from "a |
| +// completely different entry — for the same host, after a stop and rewake — |
| +// that also just had its own first wake". A stale restart could install its |
| +// result straight over a fresh wake's own sidecar. The fix (this test's |
| +// assertion) compares entry identity: the *actEntry pointer captured when |
| +// the restart launched, not a counter on it. |
| +// |
| +// This test legitimately costs several seconds of wall time: stop() itself |
| +// waits on the in-flight restart, bounded by sidecarStopTimeout, before it |
| +// can finish taking the old entry down (that wait is a separate, already |
| +// covered property — see TestSidecarRestartDoesNotHoldTheLockAcrossTheStart |
| +// and stop()'s own doc comment) — the file has no seam to shrink |
| +// sidecarStopTimeout for tests, so this accepts the wall time rather than |
| +// adding one for a single regression test. |
| +func TestStaleSidecarRestartNeverInstallsOntoAFreshWake(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + b.sidecarStaged = true |
| + host := "stale.example.com" |
| + |
| + arrangeAwake(t, a, testRoute(host)) |
| + b.lastSidecar().exit() // the sweeper now wants a restart |
| + |
| + gate := make(chan struct{}) |
| + entered := make(chan struct{}) |
| + b.mu.Lock() |
| + b.sidecarGate = gate |
| + b.sidecarStartEntered = entered |
| + b.mu.Unlock() |
| + a.sweepOnce() // launches the restart; it blocks inside StartSidecar on gate |
| + <-entered |
| + |
| + if !a.Bounce(host) { |
| + t.Fatal("Bounce did not find the awake entry") |
| + } |
| + deadline := time.Now().Add(8 * time.Second) |
| + for a.entryCount() != 0 && time.Now().Before(deadline) { |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + if a.entryCount() != 0 { |
| + t.Fatal("Bounce'd entry never finished stopping (the stalled restart wedged the stop past its bound)") |
| + } |
| + |
| + // A fresh wake of the same host gets its own sidecar — this is what the |
| + // stale restart, still stalled, must not be allowed to overwrite. |
| + b.mu.Lock() |
| + b.sidecarGate = nil // the fresh wake's own co-spawn must not block on it too |
| + b.mu.Unlock() |
| + arrangeAwake(t, a, testRoute(host)) |
| + freshSidecar := b.lastSidecar() |
| + wantStarts := b.sidecarStartCount() + 1 // + the stale call, about to land |
| + |
| + close(gate) // let the stale StartSidecar call finally return |
| + waitFor(t, "the stale restart to resolve", func() bool { return b.sidecarStartCount() == wantStarts }) |
| + stale := b.lastSidecar() |
| + waitFor(t, "the stale sidecar to be stopped", func() bool { return stale.stopCalls() >= 1 }) |
| + |
| + a.mu.Lock() |
| + installed := a.entries[host].sidecar |
| + a.mu.Unlock() |
| + if installed != freshSidecar { |
| + t.Fatal("the stale restart installed over the fresh wake's own sidecar") |
| + } |
| +} |