host: pin the adopt dispatch to the version-aware bounce
Astra round 2 found no defect and two test gaps. This closes one: with adoptBounceFn swapped back to the plain Bounce every other test passed. The real-activator tests now ask "was it stopped?" of the spawn count after a request. Owns stays true while an entry drains, so it could not tell a spared child from one being stopped, and the first version of this test passed against the plain Bounce. Declined: a test for the one-line assignment of the leg in fastConvergeLoop. That loop is thin and untested by the file's own convention, like every other leg it assigns; convergeLegFor, which it calls, is covered with a real activator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+65
−4
internal/host/converge_adopted_test.go+65 −4
CI passed — run details
diff --git a/internal/host/converge_adopted_test.go b/internal/host/converge_adopted_test.go| index f20a51967..6934b1c88 100644 |
| --- a/internal/host/converge_adopted_test.go |
| +++ b/internal/host/converge_adopted_test.go |
| @@ -318,11 +318,15 @@ func TestConvergeLegCyclesARootAdoptedInstanceOnARealActivator(t *testing.T) { |
| t.Fatal("convergeLegFor returned no leg for a box with an activator") |
| } |
| + // "Was it stopped?" is asked of the spawn count, after a request: Owns |
| + // stays true while an entry drains, so it cannot tell a spared child from |
| + // one being stopped. A request against a stopped instance spawns again. |
| adopt("v1") |
| request() // wakes the instance onto v1 |
| leg() |
| - if !svc.Owns(host) { |
| - t.Fatal("the leg stopped an instance whose build is the adopted one") |
| + request() |
| + if got, want := backend.started(), []string{"v1"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("spawned builds = %v, want %v: the leg stopped an instance whose build is the adopted one", got, want) |
| } |
| adopt("v2") // root: staged, repointed, stamped — and nothing cycled |
| @@ -343,8 +347,9 @@ func TestConvergeLegCyclesARootAdoptedInstanceOnARealActivator(t *testing.T) { |
| t.Fatalf("spawned builds = %v, want %v", got, want) |
| } |
| leg() |
| - if !svc.Owns(host) { |
| - t.Fatal("the leg cycled the instance again after it had converged") |
| + request() |
| + if got, want := backend.started(), []string{"v1", "v2"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("spawned builds = %v, want %v: the leg cycled the instance again after it had converged", got, want) |
| } |
| } |
| @@ -353,3 +358,59 @@ func TestConvergeLegForABoxWithNoActivator(t *testing.T) { |
| t.Fatal("convergeLegFor built a leg with no activator to ask") |
| } |
| } |
| + |
| +// The adopt passes' restart dispatch must be built on adoptBounceFn, not |
| +// bounceFn. With the converge leg in place an instance can already be on the |
| +// new build when a slower pass reaches its own restart; the plain Bounce would |
| +// stop that replacement for nothing. A stale child still gets its cycle. |
| +func TestAdoptRestartDispatchSparesAConvergedChild(t *testing.T) { |
| + env := newFastFixture(t) |
| + host := "warm.test" |
| + rt := registry.Route{ |
| + Host: host, Kind: "instance", App: "fastapp", Channel: "edge", |
| + Status: "ready", Hibernate: true, |
| + } |
| + if err := env.reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + env.stageLive(t, host, "v1") |
| + backend := &versionedBackend{optDir: env.optDir} |
| + svc := activator.New(activator.Config{Bucket: "test-bucket", Idle: time.Hour}, backend, env.reg) |
| + t.Cleanup(func() { |
| + sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + svc.Shutdown(sctx) |
| + }) |
| + wake := func() { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) |
| + if !svc.ServeHTTP(rec, httptest.NewRequest("GET", "http://"+host+"/", nil), rt, proxy) || rec.Code != http.StatusNoContent { |
| + t.Fatalf("wake request = %d", rec.Code) |
| + } |
| + } |
| + restart := makeRestartRoute(env.reg, func(string) {}, func(u string) error { |
| + t.Fatalf("named-unit arm fired for %q on a route that names no unit", u) |
| + return nil |
| + }, adoptBounceFn(svc)) |
| + |
| + // Asked of the spawn count, after a request — see the note in |
| + // TestConvergeLegCyclesARootAdoptedInstanceOnARealActivator. |
| + wake() // exec'd from v1, and live/<host> names v1 |
| + if err := restart(host); err != nil { |
| + t.Fatalf("restart: %v", err) |
| + } |
| + wake() |
| + if got, want := backend.started(), []string{"v1"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("spawned builds = %v, want %v: the adopt dispatch stopped a child already running the build live/<host> names", got, want) |
| + } |
| + |
| + env.stageLive(t, host, "v2") // the symlink moves under the running child |
| + if err := restart(host); err != nil { |
| + t.Fatalf("restart: %v", err) |
| + } |
| + wake() |
| + if got, want := backend.started(), []string{"v1", "v2"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("spawned builds = %v, want %v: the adopt dispatch left a stale child running", got, want) |
| + } |
| +} |