carlos / platform Public

activator: RunCmd, the cmd hold and the stop ordering in finishStop (run spec §5.3, codex 1, 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f5dacb43a39c5e4d9c26ba7c6df18fc377856de8 parent 18d98eb
3 files changed, +614 −1
  • internal/activator/activator.go +32 −1
  • internal/activator/cmd.go +249 −0
  • internal/activator/cmd_test.go +333 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index e86ac422e..c1bcc3731 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -364,6 +364,16 @@ type actEntry struct {
// a.mu; finishStop hands it to a run it interrupts (run spec §5.3).
stopWhy string
+ // cmd is the run attached to this instance (run spec §5.3), written
+ // under a.mu. While it is set the idle sweep never stops the entry;
+ // every other stop stops and settles its unit in finishStop, before
+ // replication closes.
+ cmd *cmdHold
+ // cmdLeft holds runs released before their unit was proven settled
+ // (cmdRun.Release): their Stop failed or never came. finishStop settles
+ // them as it does cmd, before replication closes. Written under a.mu.
+ cmdLeft []*cmdHold
+
sidecarUncertain bool
// Activity tracking: an instance is idle only when nothing is in flight
@@ -2367,6 +2377,27 @@ func (a *Service) retryPendingStop(e *actEntry) {
// ErrStillRunning and returned, plus whatever finishPendingStop's own
// retried proc.Stop just added.
func (a *Service) finishStop(e *actEntry, outcome *stopObservation) {
+ // A run attached to this instance is stopped and settled before
+ // replication closes (run spec §5.3, codex 1): everything it committed
+ // is in the final checkpoint, and nothing writes the database after.
+ // A run that will not settle leaves the stop unproven.
+ a.mu.Lock()
+ holds := append([]*cmdHold(nil), e.cmdLeft...)
+ if e.cmd != nil {
+ holds = append(holds, e.cmd)
+ }
+ why := e.stopWhy
+ a.mu.Unlock()
+ if len(holds) > 0 {
+ cctx, ccancel := context.WithTimeout(context.Background(), cmdStopBound)
+ for _, h := range holds {
+ if err := h.stop(cctx, why); err != nil {
+ log.Printf("activator: %s: run %s did not settle before the final checkpoint: %v", e.host, h.runID, err)
+ outcome.uncertain = true
+ }
+ }
+ ccancel()
+ }
// 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.
@@ -2544,7 +2575,7 @@ func (a *Service) sweepOnce() {
if e.workerIdle > 0 {
idleWindow = e.workerIdle
}
- if hibernates && e.inflight == 0 && now.Sub(e.lastActive) >= idleWindow {
+ if hibernates && e.inflight == 0 && e.cmd == nil && now.Sub(e.lastActive) >= idleWindow {
// Don't sleep into an imminent tick. A wake/sleep pair is a full
// checkpoint-and-restore round trip; spending one to be absent
// for less time than we would have idled anyway is a straight
diff --git a/internal/activator/cmd.go b/internal/activator/cmd.go
index 6c08edd09..87f7ba670 100644
--- a/internal/activator/cmd.go
+++ b/internal/activator/cmd.go
@@ -1,5 +1,15 @@
package activator
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
// Why an instance stop interrupted a run (run spec §5.3). The text is what
// the member reads after "the run was interrupted:" (RUN-C1 to RUN-C8);
// beginStopLocked records one on every stop.
@@ -13,3 +23,242 @@ const (
StopRetired = "the instance was taken off this box"
StopShutdown = "the box's agent stopped"
)
+
+// ErrCmdClaim: claim control is armed on this box. Runs need the isolated
+// root protocol's reservations (#250), so they are refused (run spec §3.1).
+var ErrCmdClaim = errors.New("activator: claim control is armed; runs wait for the reservation protocol (#250)")
+
+// ErrCmdBusy: a run is already attached to the instance.
+var ErrCmdBusy = errors.New("activator: a run is already attached to this instance")
+
+// CmdWakeError: the wake failed or did not finish within cmdWakeBound.
+// Reason is member-safe (memberSafeWakeReason).
+type CmdWakeError struct {
+ Reason string
+ Err error
+}
+
+func (e *CmdWakeError) Error() string { return e.Reason }
+func (e *CmdWakeError) Unwrap() error { return e.Err }
+
+// CmdStoppedError: the instance began stopping before the unit started. The
+// run is interrupted with Reason, never queued for later (run spec §5.3).
+type CmdStoppedError struct{ Reason string }
+
+func (e *CmdStoppedError) Error() string {
+ return "activator: the instance stopped before the run started: " + e.Reason
+}
+
+// cmdWakeBound is how long RunCmd waits for a wake (run spec §5.3).
+var cmdWakeBound = 60 * time.Second
+
+// cmdStopBound bounds finishStop's wait for a run: the start in flight
+// (DeadlineControl), the stop and the cgroup (actStopTimeout) and both
+// streams (cmdDrainBound), with room.
+const cmdStopBound = 2 * time.Minute
+
+// CmdHandle is a started run, as the box's run pass sees it.
+type CmdHandle interface {
+ Invocation() string
+ Poll(ctx context.Context) (instancectl.CmdStatus, error)
+ // Interrupted is the instance stop's reason when one stopped the unit,
+ // "" otherwise.
+ Interrupted() string
+ // Stop stops the unit and waits for it to settle and its streams to
+ // end, leaving the instance running: a cancel, a timeout, or the end
+ // of a command that exited. Safe to call more than once.
+ Stop(ctx context.Context) error
+ // Release clears the run from the entry once its result is written.
+ Release()
+}
+
+// cmdHold is the entry's cmd field.
+type cmdHold struct {
+ runID string
+ started chan struct{} // closed once StartCmd returned, either way
+ unit CmdUnit // written before started is closed; nil on a failed start
+ mu sync.Mutex
+ asked bool // a stop has been asked for; under mu
+ reason string
+ stopOnce sync.Once
+ settled chan struct{}
+ stopErr error
+}
+
+func newCmdHold(runID string) *cmdHold {
+ return &cmdHold{runID: runID, started: make(chan struct{}), settled: make(chan struct{})}
+}
+
+// stop waits for the start to return, records reason if it is the first
+// stop asked for (the run's own Stop passes "", so an instance stop after
+// it is not what ended the run), and stops and settles the unit once.
+func (h *cmdHold) stop(ctx context.Context, reason string) error {
+ select {
+ case <-h.started:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ h.mu.Lock()
+ if !h.asked {
+ h.asked = true
+ h.reason = reason
+ }
+ h.mu.Unlock()
+ if h.unit == nil {
+ return nil
+ }
+ h.stopOnce.Do(func() {
+ go func() {
+ h.stopErr = h.unit.StopAndSettle(context.Background())
+ close(h.settled)
+ }()
+ })
+ select {
+ case <-h.settled:
+ return h.stopErr
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+// provenSettled reports whether the hold leaves nothing running: the start
+// returned no unit, or the unit settled without error.
+func (h *cmdHold) provenSettled() bool {
+ select {
+ case <-h.started:
+ default:
+ return false
+ }
+ if h.unit == nil {
+ return true
+ }
+ select {
+ case <-h.settled:
+ return h.stopErr == nil
+ default:
+ return false
+ }
+}
+
+type cmdRun struct {
+ a *Service
+ e *actEntry
+ h *cmdHold
+ once sync.Once
+}
+
+func (r *cmdRun) Invocation() string { return r.h.unit.Invocation() }
+func (r *cmdRun) Poll(ctx context.Context) (instancectl.CmdStatus, error) {
+ return r.h.unit.Poll(ctx)
+}
+func (r *cmdRun) Interrupted() string {
+ r.h.mu.Lock()
+ defer r.h.mu.Unlock()
+ return r.h.reason
+}
+func (r *cmdRun) Stop(ctx context.Context) error { return r.h.stop(ctx, "") }
+
+// Release clears the hold and stamps activity, so the instance idles a
+// full window after a run rather than hibernating at the next sweep. A
+// unit not proven settled (its Stop failed or never came) stays the
+// instance's: the next stop settles it before replication closes, or is
+// uncertain (run spec §5.3, codex 1).
+func (r *cmdRun) Release() {
+ r.once.Do(func() {
+ r.a.mu.Lock()
+ if r.e.cmd == r.h {
+ r.e.cmd = nil
+ r.e.lastActive = r.a.now()
+ if !r.h.provenSettled() {
+ r.e.cmdLeft = append(r.e.cmdLeft, r.h)
+ }
+ }
+ r.a.mu.Unlock()
+ })
+}
+
+// RunCmd runs one command against rt's instance (run spec §5.2 and §5.3):
+// refused with claim control armed or on a layout other than nested; the
+// instance woken through the ordinary wake path, waiting up to 60 s; the
+// run attached to the awake entry; spec.Prepare run under the hold; and
+// the unit started only while the entry is still awake. A stop that begins
+// after the attach is either seen here (CmdStoppedError, no unit) or
+// waits in finishStop for the start to return and then stops the unit
+// before replication closes.
+func (a *Service) RunCmd(ctx context.Context, rt registry.Route, spec CmdSpec) (CmdHandle, error) {
+ if a.cfg.HostWrites.Enabled {
+ return nil, ErrCmdClaim
+ }
+ cb, ok := a.backend.(CmdBackend)
+ if !ok {
+ return nil, ErrCmdNotNested
+ }
+ if c, ok := a.backend.(Classifier); !ok {
+ return nil, ErrCmdNotNested
+ } else if l, _ := c.Layout(rt); l != LayoutNested {
+ return nil, ErrCmdNotNested
+ }
+ wctx, cancel := context.WithTimeout(ctx, cmdWakeBound)
+ defer cancel()
+ var release func()
+ for {
+ r, err := a.begin(wctx, rt, true)
+ if err == nil {
+ release = r
+ break
+ }
+ if errors.Is(err, errWakeTimeout) && wctx.Err() == nil {
+ continue // begin's own bound is 30 s; a run waits 60
+ }
+ if wctx.Err() != nil && ctx.Err() == nil {
+ err = errWakeTimeout
+ }
+ return nil, &CmdWakeError{Reason: memberSafeWakeReason(err), Err: err}
+ }
+ h := newCmdHold(spec.RunID)
+ a.mu.Lock()
+ e := a.entries[rt.Host]
+ switch {
+ case e == nil || e.state != actAwake:
+ why := StopIdle
+ if e != nil && e.stopWhy != "" {
+ why = e.stopWhy
+ }
+ a.mu.Unlock()
+ release()
+ return nil, &CmdStoppedError{Reason: why}
+ case e.cmd != nil:
+ a.mu.Unlock()
+ release()
+ return nil, ErrCmdBusy
+ }
+ e.cmd = h
+ a.mu.Unlock()
+ release() // the hold, not the in-flight count, keeps the idle timer off now
+ run := &cmdRun{a: a, e: e, h: h}
+ abandon := func(err error) (CmdHandle, error) {
+ close(h.started)
+ run.Release()
+ return nil, err
+ }
+ if spec.Prepare != nil {
+ if err := spec.Prepare(ctx); err != nil {
+ return abandon(err)
+ }
+ }
+ a.mu.Lock()
+ if e.state != actAwake {
+ why := e.stopWhy
+ a.mu.Unlock()
+ return abandon(&CmdStoppedError{Reason: why})
+ }
+ a.mu.Unlock()
+ u, err := cb.StartCmd(ctx, rt, spec)
+ h.unit = u
+ close(h.started)
+ if err != nil {
+ run.Release()
+ return nil, err
+ }
+ return run, nil
+}
diff --git a/internal/activator/cmd_test.go b/internal/activator/cmd_test.go
index 04048ab17..a8eca9ef2 100644
--- a/internal/activator/cmd_test.go
+++ b/internal/activator/cmd_test.go
@@ -1,8 +1,16 @@
package activator
import (
+ "context"
+ "errors"
+ "path/filepath"
+ "sync"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/registry"
)
func (a *Service) stopWhyOf(host string) string {
@@ -26,3 +34,328 @@ func TestStopsRecordTheirReason(t *testing.T) {
t.Fatalf("Bounce: stopWhy %q, want %q", got, StopRestart)
}
}
+
+// fakeCmdUnit records the stop and whether replication was already closed
+// when it came.
+type fakeCmdUnit struct {
+ mu sync.Mutex
+ stops int
+ repWasClosed bool
+ settleErr error
+ rep func() *fakeRep
+ status instancectl.CmdStatus
+}
+
+func (u *fakeCmdUnit) Invocation() string { return "inv1" }
+func (u *fakeCmdUnit) Poll(context.Context) (instancectl.CmdStatus, error) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ return u.status, nil
+}
+func (u *fakeCmdUnit) StopAndSettle(context.Context) error {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ u.stops++
+ if u.rep != nil {
+ u.repWasClosed = u.rep().isClosed()
+ }
+ return u.settleErr
+}
+func (u *fakeCmdUnit) stopped() (int, bool) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ return u.stops, u.repWasClosed
+}
+
+// cmdFakeBackend is fakeBackend with the run capability and a nested layout.
+type cmdFakeBackend struct {
+ *fakeBackend
+ cmdMu sync.Mutex
+ unit *fakeCmdUnit
+ startErr error
+ cmdStarts int
+ gate chan struct{} // when set, StartCmd blocks until it closes
+ entered chan struct{} // when set, closed as StartCmd is entered
+}
+
+func (b *cmdFakeBackend) StartCmd(ctx context.Context, rt registry.Route, spec CmdSpec) (CmdUnit, error) {
+ if b.entered != nil {
+ close(b.entered)
+ }
+ if b.gate != nil {
+ <-b.gate
+ }
+ b.cmdMu.Lock()
+ defer b.cmdMu.Unlock()
+ b.cmdStarts++
+ if b.startErr != nil {
+ return nil, b.startErr
+ }
+ return b.unit, nil
+}
+
+func testCmdActivator(t *testing.T, cfg Config) (*Service, *cmdFakeBackend, *fakeClock) {
+ t.Helper()
+ fb := &fakeBackend{layoutFn: func(registry.Route) (Layout, string) { return LayoutNested, "" }}
+ cb := &cmdFakeBackend{fakeBackend: fb}
+ cb.unit = &fakeCmdUnit{rep: fb.lastRep}
+ clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Bucket == "" {
+ cfg.Bucket = "test-bucket"
+ }
+ if cfg.Idle == 0 {
+ cfg.Idle = time.Second
+ }
+ a := New(cfg, cb, reg)
+ a.now = clk.now
+ return a, cb, clk
+}
+
+func TestRunCmdRefusesWithClaimControlArmed(t *testing.T) {
+ a, cb, _ := testCmdActivator(t, Config{HostWrites: hostwrite.Config{Enabled: true}})
+ if _, err := a.RunCmd(context.Background(), testRoute("c.example.com"), CmdSpec{RunID: "run_a"}); !errors.Is(err, ErrCmdClaim) {
+ t.Fatalf("err = %v, want ErrCmdClaim", err)
+ }
+ if acquires, _, _ := cb.counts(); acquires != 0 {
+ t.Fatal("a claim-armed refusal woke the instance")
+ }
+}
+
+func TestRunCmdRefusesAFlatInstance(t *testing.T) {
+ a, cb, _ := testCmdActivator(t, Config{})
+ cb.layoutFn = func(registry.Route) (Layout, string) { return LayoutFlat, "" }
+ if _, err := a.RunCmd(context.Background(), testRoute("c.example.com"), CmdSpec{RunID: "run_a"}); !errors.Is(err, ErrCmdNotNested) {
+ t.Fatalf("err = %v, want ErrCmdNotNested", err)
+ }
+}
+
+func TestRunCmdWakesAHibernatedInstanceFirst(t *testing.T) {
+ a, cb, _ := testCmdActivator(t, Config{})
+ h, err := a.RunCmd(context.Background(), testRoute("c.example.com"), CmdSpec{RunID: "run_a"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, wakes := cb.counts(); h.Invocation() != "inv1" || wakes != 1 || cb.cmdStarts != 1 {
+ t.Fatalf("wake starts %d, cmd starts %d", wakes, cb.cmdStarts)
+ }
+ h.Release()
+}
+
+func TestRunCmdWakeFailureIsAWakeRefusal(t *testing.T) {
+ a, cb, _ := testCmdActivator(t, Config{})
+ cb.acquireErr = errors.New("restore failed")
+ _, err := a.RunCmd(context.Background(), testRoute("c.example.com"), CmdSpec{RunID: "run_a"})
+ var we *CmdWakeError
+ if !errors.As(err, &we) || we.Reason == "" {
+ t.Fatalf("err = %v, want a CmdWakeError with a member-safe reason", err)
+ }
+ if cb.cmdStarts != 0 {
+ t.Fatal("a failed wake started the command")
+ }
+}
+
+func TestRunCmdRefusesASecondRun(t *testing.T) {
+ a, _, _ := testCmdActivator(t, Config{})
+ rt := testRoute("c.example.com")
+ h, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_a"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Release()
+ if _, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_b"}); !errors.Is(err, ErrCmdBusy) {
+ t.Fatalf("err = %v, want ErrCmdBusy", err)
+ }
+}
+
+func TestRunCmdHoldsTheIdleTimer(t *testing.T) {
+ a, _, clk := testCmdActivator(t, Config{})
+ rt := testRoute("c.example.com")
+ h, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_a"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ clk.advance(time.Hour)
+ a.sweepOnce()
+ if a.entryCount() != 1 || a.stopWhyOf(rt.Host) != "" {
+ t.Fatal("the idle timer stopped an instance with a run open")
+ }
+ h.Release()
+ clk.advance(time.Hour)
+ a.sweepOnce()
+ waitFor(t, "hibernation after the run", func() bool { return a.entryCount() == 0 })
+}
+
+// Every stop path stops and settles the run before replication closes, and
+// the run learns why (run spec §5.3, codex 1).
+func TestEveryStopPathSettlesTheRunBeforeReplicationCloses(t *testing.T) {
+ cases := []struct {
+ name string
+ why string
+ stop func(a *Service, cb *cmdFakeBackend, host string)
+ }{
+ {"bounce", StopRestart, func(a *Service, _ *cmdFakeBackend, h string) { a.Bounce(h) }},
+ {"process exit", StopExited, func(a *Service, cb *cmdFakeBackend, _ string) { cb.lastProc().exit(); a.sweepOnce() }},
+ {"lease lost", StopLeaseLost, func(a *Service, cb *cmdFakeBackend, _ string) { cb.lastRep().setErr(errors.New("lost")); a.sweepOnce() }},
+ {"convergence", StopRelease, func(a *Service, _ *cmdFakeBackend, h string) {
+ a.liveVer = func(string) (string, error) { return "v2", nil }
+ a.ConvergeAdopted(h, "v2")
+ }},
+ {"move fence", StopFenced, func(a *Service, _ *cmdFakeBackend, h string) {
+ // FenceAndStop refuses a context without a deadline.
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ go func() {
+ defer cancel()
+ a.FenceAndStop(ctx, h, 1) //nolint:errcheck
+ }()
+ }},
+ {"retire", StopRetired, func(a *Service, _ *cmdFakeBackend, _ string) { a.RetireExcept(map[string]bool{}) }},
+ {"agent shutdown", StopShutdown, func(a *Service, _ *cmdFakeBackend, _ string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ a.Shutdown(ctx)
+ }},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ a, cb, _ := testCmdActivator(t, Config{})
+ cb.execVerFn = func() string { return "v1" }
+ rt := testRoute("c.example.com")
+ h, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_a"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ c.stop(a, cb, rt.Host)
+ waitFor(t, "the instance stopped", func() bool { return a.entryCount() == 0 })
+ stops, repWasClosed := cb.unit.stopped()
+ if stops != 1 {
+ t.Fatalf("the cmd unit was stopped %d times, want 1", stops)
+ }
+ if repWasClosed {
+ t.Fatal("replication closed before the run settled")
+ }
+ if !cb.lastRep().isClosed() {
+ t.Fatal("replication never closed")
+ }
+ if got := h.Interrupted(); got != c.why {
+ t.Fatalf("Interrupted() = %q, want %q", got, c.why)
+ }
+ })
+ }
+}
+
+func TestRunCmdNeverStartsUnderAStoppingEntry(t *testing.T) {
+ a, cb, _ := testCmdActivator(t, Config{})
+ rt := testRoute("c.example.com")
+ _, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_a", Prepare: func(context.Context) error {
+ a.Bounce(rt.Host) // the instance starts stopping while the files download
+ return nil
+ }})
+ var se *CmdStoppedError
+ if !errors.As(err, &se) || se.Reason != StopRestart {
+ t.Fatalf("err = %v, want CmdStoppedError{%q}", err, StopRestart)
+ }
+ if cb.cmdStarts != 0 {
+ t.Fatal("the cmd unit started under a stopping entry")
+ }
+}
+
+func TestAStopDuringTheStartWaitsForIt(t *testing.T) {
+ a, cb, _ := testCmdActivator(t, Config{})
+ cb.gate = make(chan struct{})
+ cb.entered = make(chan struct{})
+ rt := testRoute("c.example.com")
+ done := make(chan error, 1)
+ go func() {
+ _, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_a"})
+ done <- err
+ }()
+ waitFor(t, "the run attached", func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ e := a.entries[rt.Host]
+ return e != nil && e.cmd != nil
+ })
+ // The attach comes before RunCmd's last awake check; the stop must land
+ // after that check, with StartCmd in flight.
+ <-cb.entered
+ a.Bounce(rt.Host)
+ time.Sleep(50 * time.Millisecond)
+ if cb.lastRep().isClosed() {
+ t.Fatal("replication closed while the cmd unit's start was still in flight")
+ }
+ close(cb.gate)
+ if err := <-done; err != nil {
+ t.Fatalf("RunCmd: %v", err)
+ }
+ waitFor(t, "the instance stopped", func() bool { return a.entryCount() == 0 })
+ if stops, repWasClosed := cb.unit.stopped(); stops != 1 || repWasClosed {
+ t.Fatalf("stops %d, replication closed first %v", stops, repWasClosed)
+ }
+}
+
+func TestAnUnsettledRunMakesTheStopUncertain(t *testing.T) {
+ for _, c := range []struct {
+ settleErr error
+ uncertain bool
+ }{{nil, false}, {errors.New("processes remained"), true}} {
+ a, cb, _ := testCmdActivator(t, Config{})
+ cb.unit.settleErr = c.settleErr
+ rt := testRoute("c.example.com")
+ if _, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_a"}); err != nil {
+ t.Fatal(err)
+ }
+ a.Bounce(rt.Host)
+ waitFor(t, "the instance stopped", func() bool { return a.entryCount() == 0 })
+ if got := a.StopUncertain(rt.Host); got != c.uncertain {
+ t.Fatalf("settle error %v: StopUncertain = %v, want %v", c.settleErr, got, c.uncertain)
+ }
+ }
+}
+
+// A run released without a proven settle (its Stop failed, or was never
+// called) still belongs to the instance's next stop: that stop settles it
+// before replication closes, or is uncertain.
+func TestAReleasedRunThatNeverSettledStillHoldsTheStop(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ stopFirst bool
+ settleErr error
+ uncertain bool
+ }{
+ {"stopped and settled", true, nil, false},
+ {"never stopped", false, nil, false},
+ {"stop failed", true, errors.New("processes remained"), true},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ a, cb, clk := testCmdActivator(t, Config{})
+ cb.unit.settleErr = c.settleErr
+ rt := testRoute("c.example.com")
+ h, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_a"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.stopFirst {
+ if err := h.Stop(context.Background()); (err != nil) != (c.settleErr != nil) {
+ t.Fatalf("Stop: %v", err)
+ }
+ }
+ h.Release()
+ clk.advance(time.Hour)
+ a.sweepOnce() // the release ended the idle hold
+ waitFor(t, "the instance stopped", func() bool { return a.entryCount() == 0 })
+ if stops, repWasClosed := cb.unit.stopped(); stops != 1 || repWasClosed {
+ t.Fatalf("stops %d, replication closed first %v", stops, repWasClosed)
+ }
+ if got := a.StopUncertain(rt.Host); got != c.uncertain {
+ t.Fatalf("StopUncertain = %v, want %v", got, c.uncertain)
+ }
+ if got := h.Interrupted(); got != "" && c.stopFirst {
+ t.Fatalf("Interrupted() = %q after the run's own stop", got)
+ }
+ })
+ }
+}