| new file mode 100644 |
| index 000000000..ab342ae2d |
| --- /dev/null |
| +++ b/internal/activator/swap_hold_test.go |
| @@ -0,0 +1,571 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os/exec" |
| + "slices" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// A hold is stop-then-start through the barrier (spec §7.8). A request that |
| +// arrives during the stop is held, not refused, and is served by the new |
| +// generation, which execs the attempt's staged build while the live link |
| +// names something else. This is the witness for mutation M3. |
| +func TestSwapWitnessHoldExecsTheStagedBuild(t *testing.T) { |
| + f := newSwapFixture(t) |
| + f.b.execVerFn = func() string { return "v3" } |
| + gate := make(chan struct{}) |
| + f.b.lastProc().setStopGate(gate) |
| + results := make(chan SwapResult, 1) |
| + go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) }() |
| + waitFor(t, "the hold's stop", func() bool { |
| + f.a.mu.Lock() |
| + defer f.a.mu.Unlock() |
| + e := f.a.entries[f.host] |
| + return e != nil && e.state == actStopping |
| + }) |
| + served := make(chan error, 1) |
| + go func() { |
| + release, err := f.a.beginRequest(context.Background(), testRoute(f.host)) |
| + if err == nil { |
| + release() |
| + } |
| + served <- err |
| + }() |
| + time.Sleep(50 * time.Millisecond) |
| + close(gate) |
| + var res SwapResult |
| + select { |
| + case res = <-results: |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("the hold never finished") |
| + } |
| + if res.Outcome != SwapDone || res.Gen.Build != "v2" || res.Gen.StagedPath != f.staged { |
| + t.Fatalf("Swap = %+v", res) |
| + } |
| + if res.Drain < 40*time.Millisecond { |
| + t.Fatalf("Drain = %v, want the old generation's gated stop (spec §9.4)", res.Drain) |
| + } |
| + if err := <-served; err != nil { |
| + t.Fatalf("the held request was refused: %v", err) |
| + } |
| + // #231: one live-link Start (the original wake). The hold's wake went |
| + // through StartGen alone. |
| + if got := f.b.startCount(); got != 1 { |
| + t.Fatalf("Start (live link) calls = %d, want 1", got) |
| + } |
| + if gens, _, _ := f.b.calls(); gens != 1 { |
| + t.Fatalf("StartGen calls = %d, want 1", gens) |
| + } |
| + if got := f.appSocket(t); got != res.Gen.Socket { |
| + t.Fatalf("AppSocket = %q, want the new generation's %q", got, res.Gen.Socket) |
| + } |
| +} |
| + |
| +// 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) { |
| + f := newSwapFixture(t) |
| + f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 500") } |
| + res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) |
| + if res.Outcome != SwapDown { |
| + t.Fatalf("Swap = %+v, want down", res) |
| + } |
| + if f.a.entryCount() != 0 { |
| + t.Fatal("a failed hold left an entry behind") |
| + } |
| + if _, p := f.b.lastGen(); p == nil || !p.isStopped() { |
| + t.Fatal("the failed hold generation was left running") |
| + } |
| + if got := f.b.startCount(); got != 1 { |
| + t.Fatalf("Start (live link) calls = %d: a failed hold fell back", got) |
| + } |
| +} |
| + |
| +// acquireHookBackend runs hook inside Acquire: the window between a hold's |
| +// re-check at its stop and its pinned wake's exec. |
| +type acquireHookBackend struct { |
| + *fakeSwapBackend |
| + hook func() |
| +} |
| + |
| +func (b *acquireHookBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, error) { |
| + b.hook() |
| + return b.fakeSwapBackend.Acquire(ctx, rt) |
| +} |
| + |
| +// Spec §6.3, §7.4, §8.1: a hold's attempt checks run again at the pinned |
| +// wake's exec and once more before its result counts. Checked only when |
| +// the stop finished, a newer attempt (or a halted cohort, which bumps the |
| +// revision) landing during the wake still exec'd the superseded build and |
| +// answered SwapDone, which records it good. |
| +func TestSwapHoldReChecksAtTheExecAndBeforeTheResult(t *testing.T) { |
| + t.Run("superseded before the exec", func(t *testing.T) { |
| + f := newSwapFixture(t) |
| + var once sync.Once |
| + f.a.backend = &acquireHookBackend{fakeSwapBackend: f.b, hook: func() { once.Do(func() { f.rev.Add(1) }) }} |
| + res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) { |
| + t.Fatalf("Swap = %+v, want abandoned: superseded", res) |
| + } |
| + if gens, _, _ := f.b.calls(); gens != 0 { |
| + t.Fatalf("the superseded hold build was exec'd (%d)", gens) |
| + } |
| + }) |
| + t.Run("superseded before the result", func(t *testing.T) { |
| + f := newSwapFixture(t) |
| + f.a.genHealth = func(context.Context, Gen) error { f.rev.Add(1); return nil } |
| + res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) { |
| + t.Fatalf("Swap = %+v, want abandoned: a superseded result is never done", res) |
| + } |
| + }) |
| +} |
| + |
| +// killingSwapBackend is fakeSwapBackend plus plan 2's KillGen. fail decides, |
| +// per generation, whether the child is still occupied. |
| +type killingSwapBackend struct { |
| + *fakeSwapBackend |
| + kmu sync.Mutex |
| + fail func(n uint64) bool |
| +} |
| + |
| +func (k *killingSwapBackend) KillGen(_ context.Context, _ registry.Route, n uint64) error { |
| + k.kmu.Lock() |
| + defer k.kmu.Unlock() |
| + if k.fail != nil && k.fail(n) { |
| + return errors.New("the child still holds a process") |
| + } |
| + return nil |
| +} |
| + |
| +func (k *killingSwapBackend) setFail(f func(uint64) bool) { |
| + k.kmu.Lock() |
| + k.fail = f |
| + k.kmu.Unlock() |
| +} |
| + |
| +// A failed wake gives its lease back only once its generation's child is |
| +// proven empty (spec §7.4). Until then the host takes no new wake, and the |
| +// sweep retries the proof. cleanupFailedWake that accepts the leader's exit |
| +// releases the lease over a surviving descendant and lets a new wake start |
| +// beside it. |
| +func TestSwapHoldFailedWakeKeepsTheLeaseUntilItsChildIsEmpty(t *testing.T) { |
| + f := newSwapFixture(t) |
| + kb := &killingSwapBackend{fakeSwapBackend: f.b} |
| + kb.setFail(func(n uint64) bool { return n != f.incumbent.N }) // the hold's new generation only |
| + f.a.backend = kb |
| + f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 500") } |
| + before := f.b.releaseCount() |
| + if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapDown { |
| + t.Fatalf("Swap = %+v, want down", res) |
| + } |
| + if got := f.b.releaseCount(); got != before+1 { |
| + t.Fatalf("lease releases %d -> %d: want the old generation's only, the failed wake's kept", before, got) |
| + } |
| + if _, err := f.a.beginRequest(context.Background(), testRoute(f.host)); err == nil || !strings.Contains(err.Error(), errWakeUnproven.Error()) { |
| + t.Fatalf("a new wake = %v, want refused while the failed generation is unproven", err) |
| + } |
| + kb.setFail(nil) |
| + f.a.sweepOnce() |
| + waitFor(t, "the failed wake's lease given back", func() bool { return f.b.releaseCount() == before+2 }) |
| +} |
| + |
| +// The same when the failed wake's leader itself never stops: an unproven |
| +// stop (ErrStillRunning) is no termination, so the lease is kept until the |
| +// sweep's retried stop and proof hold. Retaining only when the leader had |
| +// exited released the lease over a live process. |
| +func TestSwapHoldFailedWakeWhoseLeaderNeverStopsKeepsTheLease(t *testing.T) { |
| + f := newSwapFixture(t) |
| + f.b.gmu.Lock() |
| + f.b.genStuck = true |
| + f.b.gmu.Unlock() |
| + f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 500") } |
| + before := f.b.releaseCount() |
| + if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapDown { |
| + t.Fatalf("Swap = %+v, want down", res) |
| + } |
| + if got := f.b.releaseCount(); got != before+1 { |
| + t.Fatalf("lease releases %d -> %d: the failed wake's lease was given back over a live leader", before, got) |
| + } |
| + _, p := f.b.lastGen() |
| + p.exit() |
| + f.a.sweepOnce() |
| + waitFor(t, "the failed wake's lease given back", func() bool { return f.b.releaseCount() == before+2 }) |
| +} |
| + |
| +// The re-check before the spawn happens when the stop finishes. A fence, a |
| +// newer attempt or the agent's shutdown abandons the hold, and nothing is |
| +// spawned. |
| +func TestSwapHoldRefusedAtTheStop(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + act func(t *testing.T, f *swapFixture) |
| + want error |
| + }{ |
| + {"fenced", func(_ *testing.T, f *swapFixture) { |
| + f.a.mu.Lock() |
| + if f.a.fences == nil { |
| + f.a.fences = map[string]uint64{} |
| + } |
| + f.a.fences[f.host] = 5 |
| + f.a.mu.Unlock() |
| + }, errSwapFenced}, |
| + {"superseded", func(_ *testing.T, f *swapFixture) { f.rev.Add(1) }, errSwapSuperseded}, |
| + {"shutdown", func(t *testing.T, f *swapFixture) { |
| + go f.a.Shutdown(context.Background()) |
| + // The shutdown is announced before the old generation's stop is |
| + // let go, so the outcome does not hang on goroutine scheduling. |
| + waitFor(t, "the shutdown announced", func() bool { |
| + f.a.mu.Lock() |
| + defer f.a.mu.Unlock() |
| + return f.a.shuttingDown |
| + }) |
| + }, errSwapShutdown}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newSwapFixture(t) |
| + gate := make(chan struct{}) |
| + f.b.lastProc().setStopGate(gate) |
| + results := make(chan SwapResult, 1) |
| + go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) }() |
| + waitFor(t, "the hold's stop", func() bool { |
| + f.a.mu.Lock() |
| + defer f.a.mu.Unlock() |
| + e := f.a.entries[f.host] |
| + return e != nil && e.state == actStopping |
| + }) |
| + tc.act(t, f) |
| + close(gate) |
| + res := <-results |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, tc.want.Error()) { |
| + t.Fatalf("Swap = %+v, want abandoned: %v", res, tc.want) |
| + } |
| + if gens, _, _ := f.b.calls(); gens != 0 { |
| + t.Fatal("a refused hold spawned a generation") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Claims on: a hold needs a claim runtime that can pin a wake to a staged |
| +// build. Without one it is refused BEFORE anything stops. |
| +func TestSwapHoldClaimsOnNeedsPins(t *testing.T) { |
| + f := newSwapFixture(t) |
| + f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: &swapWork{}}} |
| + res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errHoldNeedsPins.Error()) { |
| + t.Fatalf("Swap = %+v", res) |
| + } |
| + if f.b.lastProc().isStopped() { |
| + t.Fatal("a refused hold stopped the incumbent") |
| + } |
| +} |
| + |
| +// A row that cannot run a staged generation is refused BEFORE anything |
| +// stops (spec §7.8 is stop-then-start: a start that cannot happen must not |
| +// cost the instance its running process). Refused after the stop, the |
| +// pinned wake failed and the hold answered SwapDown for a build that never |
| +// ran. Swaps off refuses a hold as it refuses an overlap. |
| +func TestSwapHoldRefusesARowThatCannotRunAStagedBuild(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + arrange func(f *swapFixture) |
| + want error |
| + }{ |
| + {"swap off", func(f *swapFixture) { f.a.gens = false }, errSwapOff}, |
| + {"unit-backed", func(f *swapFixture) { |
| + f.a.mu.Lock() |
| + f.a.entries[f.host].route.Backing = registry.BackingUnit |
| + f.a.mu.Unlock() |
| + }, errSwapKind}, |
| + {"nested", func(f *swapFixture) { |
| + f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutNested, "" } |
| + }, errSwapLayout}, |
| + {"fenced layout", func(f *swapFixture) { |
| + f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutFenced, "a migration journal" } |
| + }, errSwapLayout}, |
| + {"worker", func(f *swapFixture) { |
| + f.a.mu.Lock() |
| + f.a.entries[f.host].worker = true |
| + f.a.mu.Unlock() |
| + }, errSwapKind}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newSwapFixture(t) |
| + tc.arrange(f) |
| + res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, tc.want.Error()) { |
| + t.Fatalf("Swap = %+v, want abandoned: %v", res, tc.want) |
| + } |
| + if f.b.lastProc().isStopped() { |
| + t.Fatal("a refused hold stopped the incumbent") |
| + } |
| + if gens, _, _ := f.b.calls(); gens != 0 || f.b.startCount() != 1 { |
| + t.Fatalf("a refused hold spawned: StartGen %d, Start %d", gens, f.b.startCount()) |
| + } |
| + f.a.mu.Lock() |
| + e := f.a.entries[f.host] |
| + held := e != nil && e.holdNext != nil |
| + f.a.mu.Unlock() |
| + if held { |
| + t.Fatal("a refused hold left its spec on the entry") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The caller is released when the old generation's stop outlasts |
| +// wakeWaitBound, the bound §7.8 holds requests for, or when the caller's own |
| +// context ends. The hold is withdrawn under the entry lock, so the stop's |
| +// eventual finish installs no pinned wake and the next request wakes the |
| +// ordinary way. Waiting on the stop alone held the caller for as long as an |
| +// unproven stop stayed pending. |
| +func TestSwapHoldReleasesItsCallerWhileTheStopIsPending(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + run func(f *swapFixture) SwapResult |
| + want error |
| + }{ |
| + {"the stop outlasts wakeWaitBound", func(f *swapFixture) SwapResult { |
| + return f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) |
| + }, errHoldStopSlow}, |
| + {"the caller's context ends", func(f *swapFixture) SwapResult { |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + go func() { |
| + // Cancel once the hold's stop has begun (bounded: the test's |
| + // own assertion reports a hold that never got there). |
| + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { |
| + f.a.mu.Lock() |
| + e := f.a.entries[f.host] |
| + stopping := e != nil && e.state == actStopping |
| + f.a.mu.Unlock() |
| + if stopping { |
| + break |
| + } |
| + } |
| + cancel() |
| + }() |
| + return f.a.Swap(ctx, f.attempt(ModeHold), f.newest) |
| + }, errSwapStopped}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + old := wakeWaitBound |
| + t.Cleanup(func() { wakeWaitBound = old }) |
| + f := newSwapFixture(t) |
| + if errors.Is(tc.want, errHoldStopSlow) { |
| + wakeWaitBound = 100 * time.Millisecond |
| + } |
| + gate := make(chan struct{}) |
| + incumbent := f.b.lastProc() |
| + incumbent.setStopGate(gate) |
| + res := tc.run(f) |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, tc.want.Error()) { |
| + close(gate) |
| + t.Fatalf("Swap = %+v, want abandoned: %v", res, tc.want) |
| + } |
| + f.a.mu.Lock() |
| + prev := f.a.entries[f.host] |
| + if prev == nil || prev.state != actStopping || prev.holdNext != nil { |
| + f.a.mu.Unlock() |
| + close(gate) |
| + t.Fatal("want the old entry still stopping, with its hold withdrawn") |
| + } |
| + stopped := prev.stopped |
| + f.a.mu.Unlock() |
| + close(gate) |
| + <-stopped |
| + // finishStop would install the pinned wake in the critical section |
| + // that closes stopped, so its absence now is final. |
| + f.a.mu.Lock() |
| + next := f.a.entries[f.host] |
| + f.a.mu.Unlock() |
| + if next != nil { |
| + t.Fatalf("a withdrawn hold installed an entry (state %v)", next.state) |
| + } |
| + if gens, _, _ := f.b.calls(); gens != 0 { |
| + t.Fatalf("a withdrawn hold exec'd its build (%d)", gens) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A claims-on pinned wake runs the hold's attempt gate before anything is |
| +// admitted: a superseded hold reaches neither the prerequisites nor the |
| +// admission. |
| +func TestDoClaimWakeRunsThePinGateFirst(t *testing.T) { |
| + obs := hostclaim.Observed{Host: "x.example", ETag: "one", Claim: &hostclaim.Claim{ |
| + Holder: &hostclaim.Holder{Location: "instances/x.example/inc", Session: 1, Generation: "one", WriterBox: "box"}, |
| + Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}, |
| + }} |
| + admitted := false |
| + a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Pins: true, |
| + Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil }, |
| + StopForeign: func(context.Context, string) error { return nil }, |
| + FailedAdmission: func(context.Context, hostclaim.Observed, error) error { return nil }, |
| + Prerequisites: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) error { |
| + admitted = true |
| + return nil |
| + }, |
| + Admit: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (hostclaim.Observed, error) { |
| + admitted = true |
| + return obs, nil |
| + }, |
| + Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) { |
| + admitted = true |
| + return ClaimStart{}, nil |
| + }}}} |
| + e := &actEntry{host: obs.Host, route: registry.Route{Host: obs.Host, Kind: "instance"}, pin: &Gen{Build: "v2"}, |
| + pinCheck: func() error { return errSwapSuperseded }} |
| + if _, err := a.doClaimWake(context.Background(), e); !errors.Is(err, errSwapSuperseded) || admitted { |
| + t.Fatalf("doClaimWake = %v (admitted %v), want the pin gate's refusal before admission", err, admitted) |
| + } |
| +} |
| + |
| +// pinTestClaimStart is a ClaimStart that passes valid(): main's claims-on |
| +// launch needs a RoleLauncher and CurrentAttempt (claimwindow_test.go). On |
| +// this base RoleLaunch.Start always refuses, so no test here reaches it. |
| +func pinTestClaimStart(obs hostclaim.Observed, work hostwrite.Work, version string, restore func(context.Context) error) ClaimStart { |
| + return ClaimStart{RoleLauncher: &RoleLaunch{}, CurrentAttempt: func() hostclaim.WakeRetryAttempt { return hostclaim.WakeRetryAttempt{} }, |
| + Host: obs.Host, Work: work, Identity: hostwrite.Identity{Config: "c", Release: "r"}, Version: version, |
| + Restore: restore, |
| + Materialize: func(context.Context) error { return nil }, Build: func() *exec.Cmd { return nil }, |
| + Ready: func(context.Context, *exec.Cmd) error { return nil }, Reserve: func(context.Context, int) error { return nil }, |
| + Failed: func(context.Context, int) error { return nil }, Healthy: func(context.Context, int, *exec.Cmd) error { return nil }, |
| + Replicate: func(context.Context, string) (Rep, error) { return nil, nil }, |
| + Publish: func(context.Context, hostclaim.Observed) (hostclaim.Observed, error) { return obs, nil }, |
| + Retain: func(Proc, Rep, error) {}} |
| +} |
| + |
| +// A claim runtime that prepared a different build from the pinned one is |
| +// refused before Restore touches the database. |
| +func TestStartAdmittedRefusesAPinMismatch(t *testing.T) { |
| + obs := hostclaim.Observed{Host: "x.example", ETag: "one", Claim: &hostclaim.Claim{ |
| + Holder: &hostclaim.Holder{Location: "instances/x.example/inc", Session: 1, Generation: "one"}, |
| + Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}, |
| + }} |
| + work, _ := (hostwrite.Config{}).Select(context.Background(), obs.Host, "spawn") |
| + stop := errors.New("stop at restore") |
| + for _, tc := range []struct { |
| + version string |
| + restored bool |
| + }{{"v1", false}, {"v2", true}} { |
| + restored := false |
| + a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", |
| + Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil }, |
| + Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) { |
| + return pinTestClaimStart(obs, work, tc.version, func(context.Context) error { restored = true; return stop }), nil |
| + }}}} |
| + policy := startPolicy{MaxStarts: 1, Window: time.Second, Pin: &Gen{Build: "v2"}} |
| + err := a.startAdmitted(context.Background(), &actEntry{host: obs.Host}, obs, policy) |
| + if restored != tc.restored { |
| + t.Fatalf("prepared %s: restored = %v, want %v (err %v)", tc.version, restored, tc.restored, err) |
| + } |
| + if !tc.restored && !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("prepared %s: %v, want hostwrite.ErrUnavailable", tc.version, err) |
| + } |
| + if tc.restored && !errors.Is(err, stop) { |
| + t.Fatalf("prepared %s: %v, want the restore's own error", tc.version, err) |
| + } |
| + } |
| +} |
| + |
| +// 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 || res.Mode != ModeHold { |
| + t.Fatalf("Swap = %+v, want done through the hold, reported as a hold (Mode)", 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") |
| + } |
| +} |