| index efae9b48a..d00a6186d 100644 |
| --- a/internal/host/sidecarwake_test.go |
| +++ b/internal/host/sidecarwake_test.go |
| @@ -2,11 +2,11 @@ package host |
| |
| import ( |
| "context" |
| - "encoding/json" |
| "errors" |
| - "os" |
| "slices" |
| + "sync" |
| "testing" |
| + "time" |
| |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -15,7 +15,7 @@ import ( |
| // wakeSidecarRoute is sidecarRoute (restart_test.go) with Slug set — the |
| // field a sidecar route carries its short name on (registry.Route.Slug's |
| // doc comment; provision's reconciler stamps it from the sidecar record). |
| -// sidecarWakePass keys entirely off it, so a fixture that omits it is |
| +// sidecarWakeChanges keys entirely off it, so a fixture that omits it is |
| // useless here even though restart_test.go's sidecarRoute never needed it. |
| func wakeSidecarRoute(host, acct, app, slug string) registry.Route { |
| rt := sidecarRoute(host, acct, app) |
| @@ -23,115 +23,90 @@ func wakeSidecarRoute(host, acct, app, slug string) registry.Route { |
| return rt |
| } |
| |
| -// touchSidecarWake writes a sidecar-wake object directly, standing in for |
| -// the console door Task 12 has not built yet on this branch — exactly the |
| -// role racingRestartPutStore's raw JSON plays in restartgen_test.go. Every |
| -// call produces a BYTE-DIFFERENT body (an incrementing generation), because |
| -// DirStore's etag is content-derived and a byte-identical rewrite would not |
| -// move it. |
| -func touchSidecarWake(t *testing.T, ctx context.Context, s release.Store, acct, app, name string, gen int) { |
| +// touchSidecarWake bumps name's wake generation through the real writer |
| +// (release.TouchSidecarWake) — the same door the console's wake handler |
| +// (Task 12) will call, so these tests exercise the actual signal a box |
| +// sees rather than a hand-rolled stand-in for it. |
| +func touchSidecarWake(t *testing.T, ctx context.Context, s release.Store, acct, app, name string) { |
| t.Helper() |
| - body, err := json.Marshal(release.SidecarWake{ |
| - Schema: 1, Generation: gen, At: "2026-08-17T23:05:00Z", |
| - By: "member:bdm", Reason: "test wake", |
| - }) |
| - if err != nil { |
| - t.Fatalf("marshal sidecar wake: %v", err) |
| - } |
| - key := release.SidecarWakeKey(acct, app, name) |
| - _, etag, err := s.Get(ctx, key) |
| - if err != nil && !errors.Is(err, os.ErrNotExist) { |
| - t.Fatalf("read sidecar wake %s: %v", key, err) |
| - } |
| - if errors.Is(err, os.ErrNotExist) { |
| - etag = "" |
| - } |
| - if _, err := s.Put(ctx, key, body, etag); err != nil { |
| - t.Fatalf("put sidecar wake %s: %v", key, err) |
| + if err := release.TouchSidecarWake(ctx, s, acct, app, name, "member:bdm", "test wake"); err != nil { |
| + t.Fatalf("TouchSidecarWake %s/%s/%s: %v", acct, app, name, err) |
| } |
| } |
| |
| -func TestSidecarWakePassEtagMoveWakesExactlyThatSidecar(t *testing.T) { |
| +// ---- sidecarWakeChanges: the synchronous etag comparison ------------------ |
| + |
| +func TestSidecarWakeChangesEtagMoveReportsExactlyThatSidecar(t *testing.T) { |
| ctx, s := genKit(t) |
| routes := []registry.Route{ |
| wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"), |
| wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"), |
| } |
| cache := map[[3]string]string{} |
| - // Seed: first sight of both, silently. |
| - sidecarWakePass(ctx, routes, s, cache, nil) |
| + sidecarWakeChanges(ctx, routes, s, cache) // seed: first sight of both, silently |
| |
| - touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 1) |
| - var woken []string |
| - wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil } |
| - got, errs := sidecarWakePass(ctx, routes, s, cache, wake) |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| + got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| - if !slices.Equal(got, []string{"bot.eleven.sidecar.internal"}) { |
| - t.Fatalf("sidecarWakePass returned %v, want exactly the moved sidecar", got) |
| - } |
| - if !slices.Equal(woken, []string{"bot.eleven.sidecar.internal"}) { |
| - t.Fatalf("woken = %v, want exactly the named sidecar and nothing else", woken) |
| + if len(got) != 1 || got[0].Host != "bot.eleven.sidecar.internal" { |
| + t.Fatalf("sidecarWakeChanges = %v, want exactly the moved sidecar's route", got) |
| } |
| } |
| |
| -func TestSidecarWakePassUnchangedEtagWakesNothing(t *testing.T) { |
| +func TestSidecarWakeChangesUnchangedEtagReportsNothing(t *testing.T) { |
| ctx, s := genKit(t) |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| - touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 1) |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| cache := map[[3]string]string{} |
| - sidecarWakePass(ctx, routes, s, cache, nil) // seed |
| + sidecarWakeChanges(ctx, routes, s, cache) // seed |
| |
| - var woken []string |
| - wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil } |
| - got, errs := sidecarWakePass(ctx, routes, s, cache, wake) |
| + got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| - if len(got) != 0 || len(woken) != 0 { |
| - t.Fatalf("unchanged etag woke %v (returned %v), want none", woken, got) |
| + if len(got) != 0 { |
| + t.Fatalf("unchanged etag reported %v, want none", got) |
| } |
| } |
| |
| -// TestSidecarWakePassFirstSightDoesNotReplay is the no-replay-on-absence |
| +// TestSidecarWakeChangesFirstSightDoesNotReplay is the no-replay-on-absence |
| // rule: an unseeded cache must not treat "I have never looked at this |
| -// before" as movement, or every agent restart would wake every sidecar |
| -// this app has ever asked to wake — a thundering herd on boot. |
| -func TestSidecarWakePassFirstSightDoesNotReplay(t *testing.T) { |
| +// before" as movement, or every agent restart would replay every wake this |
| +// sidecar has ever received — a thundering herd on boot. |
| +func TestSidecarWakeChangesFirstSightDoesNotReplay(t *testing.T) { |
| ctx, s := genKit(t) |
| // Wake object already exists BEFORE this box ever looks — the shape a |
| // long-lived wake request or a box that just (re)started sees. |
| - touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 1) |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| cache := map[[3]string]string{} |
| |
| - var woken []string |
| - wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil } |
| - got, errs := sidecarWakePass(ctx, routes, s, cache, wake) |
| + got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| - if len(got) != 0 || len(woken) != 0 { |
| - t.Fatalf("first sight woke %v (returned %v) — a box that just started must not replay every past wake", woken, got) |
| + if len(got) != 0 { |
| + t.Fatalf("first sight reported %v — a box that just started must not replay every past wake", got) |
| } |
| - want := touchedEtag(t, ctx, s, "bdm", "eleven", "bot") |
| + want := sidecarWakeStoreEtag(t, ctx, s, "bdm", "eleven", "bot") |
| if got := cache[[3]string{"bdm", "eleven", "bot"}]; got != want { |
| t.Fatalf("cache etag = %q, want the store's %q", got, want) |
| } |
| |
| // A SECOND wake request after the seed IS movement. |
| - touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 2) |
| - got, errs = sidecarWakePass(ctx, routes, s, cache, wake) |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| + got, errs = sidecarWakeChanges(ctx, routes, s, cache) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| - if !slices.Equal(got, []string{"bot.eleven.sidecar.internal"}) || !slices.Equal(woken, []string{"bot.eleven.sidecar.internal"}) { |
| - t.Fatalf("second request returned %v, woken %v, want the sidecar woken exactly once", got, woken) |
| + if len(got) != 1 || got[0].Host != "bot.eleven.sidecar.internal" { |
| + t.Fatalf("second request reported %v, want exactly the sidecar", got) |
| } |
| } |
| |
| -func touchedEtag(t *testing.T, ctx context.Context, s release.Store, acct, app, name string) string { |
| +func sidecarWakeStoreEtag(t *testing.T, ctx context.Context, s release.Store, acct, app, name string) string { |
| t.Helper() |
| _, etag, err := s.Get(ctx, release.SidecarWakeKey(acct, app, name)) |
| if err != nil { |
| @@ -140,84 +115,318 @@ func touchedEtag(t *testing.T, ctx context.Context, s release.Store, acct, app, |
| return etag |
| } |
| |
| -// TestSidecarWakePassReadErrorDoesNotSuppressOthers: a transient read |
| +// TestSidecarWakeChangesReadErrorDoesNotSuppressOthers: a transient read |
| // failure against one sidecar's wake object must not blind the pass to a |
| // real move on a different one in the same tick. |
| -func TestSidecarWakePassReadErrorDoesNotSuppressOthers(t *testing.T) { |
| +func TestSidecarWakeChangesReadErrorDoesNotSuppressOthers(t *testing.T) { |
| ctx, s0 := genKit(t) |
| routes := []registry.Route{ |
| wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"), |
| wakeSidecarRoute("worker.titogo.sidecar.internal", "bdm", "titogo", "worker"), |
| } |
| cache := map[[3]string]string{} |
| - sidecarWakePass(ctx, routes, s0, cache, nil) // seed both, absent |
| + sidecarWakeChanges(ctx, routes, s0, cache) // seed both, absent |
| |
| - touchSidecarWake(t, ctx, s0, "bdm", "eleven", "bot", 1) |
| - touchSidecarWake(t, ctx, s0, "bdm", "titogo", "worker", 1) |
| + touchSidecarWake(t, ctx, s0, "bdm", "eleven", "bot") |
| + touchSidecarWake(t, ctx, s0, "bdm", "titogo", "worker") |
| failing := &failingKeyStore{Store: s0, failKey: release.SidecarWakeKey("bdm", "eleven", "bot"), err: errors.New("bucket blip")} |
| |
| - var woken []string |
| - wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil } |
| - got, errs := sidecarWakePass(ctx, routes, failing, cache, wake) |
| + got, errs := sidecarWakeChanges(ctx, routes, failing, cache) |
| if len(errs) != 1 { |
| t.Fatalf("errs = %v, want exactly one (the failing key)", errs) |
| } |
| - if !slices.Equal(got, []string{"worker.titogo.sidecar.internal"}) || !slices.Equal(woken, []string{"worker.titogo.sidecar.internal"}) { |
| - t.Fatalf("returned %v, woken %v, want only the unaffected sidecar moved", got, woken) |
| + if len(got) != 1 || got[0].Host != "worker.titogo.sidecar.internal" { |
| + t.Fatalf("reported %v, want only the unaffected sidecar moved", got) |
| } |
| } |
| |
| -// TestSidecarWakePassIgnoresUnservedSidecar: a wake object naming a sidecar |
| -// this box does not serve is never even read, let alone acted on or |
| -// reported as an error — the pass only ever asks about routes this box |
| -// actually has. |
| -func TestSidecarWakePassIgnoresUnservedSidecar(t *testing.T) { |
| +// TestSidecarWakeChangesIgnoresUnservedSidecar: a wake object naming a |
| +// sidecar this box does not serve is never even read, let alone reported — |
| +// the pass only ever asks about routes this box actually has. |
| +func TestSidecarWakeChangesIgnoresUnservedSidecar(t *testing.T) { |
| ctx, s := genKit(t) |
| - // A wake request for a sidecar this box has no route for at all. |
| - touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost", 1) |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // no route for "ghost" at all |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| cache := map[[3]string]string{} |
| - sidecarWakePass(ctx, routes, s, cache, nil) // seed |
| + sidecarWakeChanges(ctx, routes, s, cache) // seed |
| |
| - touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost", 2) // "ghost" moves again |
| - var woken []string |
| - wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil } |
| - got, errs := sidecarWakePass(ctx, routes, s, cache, wake) |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // "ghost" moves again |
| + got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| - if len(got) != 0 || len(woken) != 0 { |
| - t.Fatalf("an unserved sidecar's wake object produced %v (woken %v), want none", got, woken) |
| + if len(got) != 0 { |
| + t.Fatalf("an unserved sidecar's wake object was reported: %v, want none", got) |
| } |
| if _, ok := cache[[3]string{"bdm", "eleven", "ghost"}]; ok { |
| t.Fatal("a sidecar this box does not serve must never appear in its cache") |
| } |
| } |
| |
| -// TestSidecarWakePassIgnoresNonSidecarRouteWithMatchingSlug: Slug is not a |
| -// sidecar-only field (registry.Route.Slug's doc comment — it is the |
| +// TestSidecarWakeChangesIgnoresNonSidecarRouteWithMatchingSlug: Slug is not |
| +// a sidecar-only field (registry.Route.Slug's doc comment — it is the |
| // app-account short name usable on a root domain, stamped on ordinary |
| -// routes too), so an ordinary instance route that happens to carry the same |
| -// Slug as a real sidecar name must never be mistaken for that sidecar and |
| -// must never trigger a read of, or a wake from, its wake object. |
| -func TestSidecarWakePassIgnoresNonSidecarRouteWithMatchingSlug(t *testing.T) { |
| +// routes too), so an ordinary instance route that happens to carry the |
| +// same Slug as a real sidecar name must never be mistaken for that |
| +// sidecar. |
| +func TestSidecarWakeChangesIgnoresNonSidecarRouteWithMatchingSlug(t *testing.T) { |
| ctx, s := genKit(t) |
| - touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 1) |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| notSidecar := execRoute("app.example.com", "bdm", "eleven") |
| notSidecar.Slug = "bot" // same name a sidecar could carry, wrong Kind |
| routes := []registry.Route{notSidecar} |
| cache := map[[3]string]string{} |
| |
| - var woken []string |
| - wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil } |
| - got, errs := sidecarWakePass(ctx, routes, s, cache, wake) |
| + got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| - if len(got) != 0 || len(woken) != 0 { |
| - t.Fatalf("a non-sidecar route with a matching Slug produced %v (woken %v), want none", got, woken) |
| + if len(got) != 0 { |
| + t.Fatalf("a non-sidecar route with a matching Slug was reported: %v, want none", got) |
| } |
| if len(cache) != 0 { |
| t.Fatalf("cache = %v, want empty — a non-sidecar route must never seed a sidecar cache entry", cache) |
| } |
| } |
| + |
| +// ---- dispatchSidecarWakes: the bounded-concurrency, non-blocking half ---- |
| + |
| +// TestDispatchSidecarWakesReturnsBeforeASlowWakeCompletes pins fix round 1's |
| +// finding directly: WakeWorker can block up to 30s per sidecar (the settle |
| +// window), and dispatchSidecarWakes' only caller (sidecarWakeLeg) runs |
| +// synchronously on the box's single per-tick convergence goroutine — so |
| +// this function must never wait on a wake, or one slow sidecar delays every |
| +// other app's turn on that tick. Asserted against a deliberately slow fake |
| +// wake, the same elapsed-time shape Task 8's boot pass and Task 11's |
| +// TestCycleWorkersReturnsBeforeASlowWakeCompletes both use. |
| +func TestDispatchSidecarWakesReturnsBeforeASlowWakeCompletes(t *testing.T) { |
| + moved := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| + slow := make(chan struct{}) |
| + wake := func(rt registry.Route) error { <-slow; return nil } |
| + |
| + start := time.Now() |
| + dispatchSidecarWakes(moved, wake) |
| + elapsed := time.Since(start) |
| + close(slow) // release the parked wake goroutine so the test can exit cleanly |
| + |
| + if elapsed > time.Second { |
| + t.Fatalf("dispatchSidecarWakes took %s to return with a wake blocked indefinitely — wakes must be dispatched asynchronously, not awaited", elapsed) |
| + } |
| +} |
| + |
| +// TestDispatchSidecarWakesWakesEveryMovedSidecarEventually: dispatching |
| +// asynchronously must not mean dropping any of them — every moved sidecar |
| +// still gets woken, just not before this function returns. |
| +func TestDispatchSidecarWakesWakesEveryMovedSidecarEventually(t *testing.T) { |
| + moved := []registry.Route{ |
| + wakeSidecarRoute("bot.a.sidecar.internal", "bdm", "eleven", "a"), |
| + wakeSidecarRoute("bot.b.sidecar.internal", "bdm", "eleven", "b"), |
| + } |
| + var mu sync.Mutex |
| + var woken []string |
| + wake := func(rt registry.Route) error { |
| + mu.Lock() |
| + woken = append(woken, rt.Host) |
| + mu.Unlock() |
| + return nil |
| + } |
| + dispatchSidecarWakes(moved, wake) |
| + waitForCond(t, "both sidecars to be woken", func() bool { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + slices.Sort(woken) |
| + return slices.Equal(woken, []string{"bot.a.sidecar.internal", "bot.b.sidecar.internal"}) |
| + }) |
| +} |
| + |
| +// TestDispatchSidecarWakesBoundsConcurrency pins the carried requirement |
| +// that wakes must not fan out unbounded: N sidecars strictly more than |
| +// workerBootConcurrency drive more in-flight wakes than the bound and prove |
| +// it is actually reached (not stuck at 1, which a serial dispatch would |
| +// show) and never exceeded (which an unbounded one would show) — the same |
| +// shape TestWorkerBootPassWakesConcurrently pins for Task 8's boot pass. |
| +func TestDispatchSidecarWakesBoundsConcurrency(t *testing.T) { |
| + n := workerBootConcurrency + 3 |
| + moved := make([]registry.Route, n) |
| + for i := range moved { |
| + moved[i] = wakeSidecarRoute("s"+string(rune('a'+i))+".x.sidecar.internal", "bdm", "eleven", "s"+string(rune('a'+i))) |
| + } |
| + var mu sync.Mutex |
| + inFlight, maxInFlight := 0, 0 |
| + release := make(chan struct{}) |
| + wake := func(registry.Route) error { |
| + mu.Lock() |
| + inFlight++ |
| + if inFlight > maxInFlight { |
| + maxInFlight = inFlight |
| + } |
| + mu.Unlock() |
| + <-release |
| + mu.Lock() |
| + inFlight-- |
| + mu.Unlock() |
| + return nil |
| + } |
| + |
| + // dispatchSidecarWakes' own dispatch loop blocks on sem once the bound |
| + // saturates (it is a bounded fan-out, not a fire-and-forget launcher) — |
| + // exactly runWorkerBootPass's own shape, and its test |
| + // (TestWorkerBootPassWakesConcurrently) runs the call in a goroutine for |
| + // the same reason: the test needs to observe the saturated state and |
| + // then release it, which it cannot do if the call under test is itself |
| + // the thing blocking the test's only goroutine. |
| + done := make(chan struct{}) |
| + go func() { |
| + dispatchSidecarWakes(moved, wake) |
| + close(done) |
| + }() |
| + |
| + waitForCond(t, "wakes to saturate the concurrency bound", func() bool { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + return inFlight == workerBootConcurrency |
| + }) |
| + // A beat with the bound already saturated: an unbounded implementation |
| + // would keep climbing past it here rather than holding still. |
| + time.Sleep(50 * time.Millisecond) |
| + mu.Lock() |
| + got := maxInFlight |
| + mu.Unlock() |
| + if got != workerBootConcurrency { |
| + t.Fatalf("max concurrent wakes = %d, want exactly the bound %d", got, workerBootConcurrency) |
| + } |
| + |
| + close(release) |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("dispatch loop did not finish after its wakes were released") |
| + } |
| +} |
| + |
| +// TestDispatchSidecarWakesWithoutAnActivatorIsANoop: a box with no |
| +// activator armed gets nil for wake — a working no-op, not a panic. |
| +func TestDispatchSidecarWakesWithoutAnActivatorIsANoop(t *testing.T) { |
| + moved := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| + dispatchSidecarWakes(moved, nil) // must not panic |
| +} |
| + |
| +// ---- sidecarWakeLeg: the wired combination fastConvergeLoop calls -------- |
| + |
| +// TestSidecarWakeLegActsOnBumpedGeneration is the "wired into the |
| +// convergence path" pin: a real TouchSidecarWake write, run through the |
| +// seed-then-leg shape fastConvergeLoop actually uses (sidecarWakeSeed at |
| +// arming, sidecarWakeLeg on every serial move), must reach WakeWorker for |
| +// exactly the sidecar the console asked for. |
| +func TestSidecarWakeLegActsOnBumpedGeneration(t *testing.T) { |
| + ctx, s := genKit(t) |
| + routes := []registry.Route{ |
| + wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"), |
| + wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"), |
| + } |
| + reg := restartTestRegistry(t) |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert %s: %v", rt.Host, err) |
| + } |
| + } |
| + cache := map[[3]string]string{} |
| + sidecarWakeSeed(ctx, reg, s, cache) |
| + |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| + var mu sync.Mutex |
| + var woken []string |
| + wake := func(rt registry.Route) error { |
| + mu.Lock() |
| + woken = append(woken, rt.Host) |
| + mu.Unlock() |
| + return nil |
| + } |
| + moved, errs := sidecarWakeLeg(ctx, routes, s, cache, wake) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if len(moved) != 1 || moved[0].Host != "bot.eleven.sidecar.internal" { |
| + t.Fatalf("sidecarWakeLeg reported %v, want exactly the bumped sidecar", moved) |
| + } |
| + waitForCond(t, "the bumped sidecar to be woken", func() bool { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + return slices.Equal(woken, []string{"bot.eleven.sidecar.internal"}) |
| + }) |
| +} |
| + |
| +// TestSidecarWakeLegReturnsBeforeASlowWakeCompletes: the glued leg itself |
| +// must not block on WakeWorker either — the property dispatchSidecarWakes |
| +// provides must survive being called from inside sidecarWakeLeg. |
| +func TestSidecarWakeLegReturnsBeforeASlowWakeCompletes(t *testing.T) { |
| + ctx, s := genKit(t) |
| + routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| + cache := map[[3]string]string{} |
| + sidecarWakeChanges(ctx, routes, s, cache) // seed |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| + |
| + slow := make(chan struct{}) |
| + wake := func(rt registry.Route) error { <-slow; return nil } |
| + |
| + start := time.Now() |
| + moved, _ := sidecarWakeLeg(ctx, routes, s, cache, wake) |
| + elapsed := time.Since(start) |
| + close(slow) |
| + |
| + if len(moved) != 1 { |
| + t.Fatalf("moved = %v, want exactly one (detection is synchronous)", moved) |
| + } |
| + if elapsed > time.Second { |
| + t.Fatalf("sidecarWakeLeg took %s to return with a wake blocked indefinitely — the wake must be dispatched asynchronously", elapsed) |
| + } |
| +} |
| + |
| +// ---- wired into fastTick: rides the same serial move as the restart leg -- |
| + |
| +// TestFastTickCallsWakeLegOnSerialChange pins the wiring itself: fastTick |
| +// must call fastDeps.wakeLeg exactly when it calls restartLeg — on a serial |
| +// move, after fullPass — or `carlos sidecars wake` writes a generation |
| +// object no box ever reads. Same shape as TestFastTickActsOnSerialChangeNotBoot, |
| +// with a call-counting wakeLeg spy standing in for the production closure. |
| +func TestFastTickCallsWakeLegOnSerialChange(t *testing.T) { |
| + env := newFastFixture(t) |
| + var calls int |
| + restart := func(string) error { return nil } |
| + d := &fastDeps{ |
| + reg: env.reg, store: env.store, pub: env.pub, optDir: env.optDir, |
| + restart: restart, |
| + passOpts: fastPassOpts(env.dataDir, func(string, string, error) {}, restart), |
| + fullPass: func(context.Context) {}, |
| + wakeLeg: func(context.Context) { calls++ }, |
| + } |
| + now := time.Unix(1754640000, 0) |
| + fastTick(context.Background(), d, now) // no serial object at all: seeds at zero, runs nothing |
| + if calls != 0 { |
| + t.Fatalf("wakeLeg ran on the boot seed, want 0 calls, got %d", calls) |
| + } |
| + if err := release.BumpSerial(context.Background(), env.store, now); err != nil { |
| + t.Fatalf("BumpSerial: %v", err) |
| + } |
| + fastTick(context.Background(), d, now.Add(2*time.Second)) |
| + if calls != 1 { |
| + t.Fatalf("wakeLeg calls = %d, want exactly 1 on a serial move", calls) |
| + } |
| + fastTick(context.Background(), d, now.Add(10*time.Second)) // past clamp, unchanged serial |
| + if calls != 1 { |
| + t.Fatalf("wakeLeg calls = %d after an unchanged serial, want still 1", calls) |
| + } |
| +} |
| + |
| +// TestFastTickToleratesANilWakeLeg: a box built before this feature (or a |
| +// test fixture that never sets one) must not panic — wakeLeg is optional |
| +// exactly like restartLeg. |
| +func TestFastTickToleratesANilWakeLeg(t *testing.T) { |
| + env := newFastFixture(t) |
| + d := env.deps(nil) // deps() never sets wakeLeg |
| + now := time.Unix(1754640000, 0) |
| + if err := release.BumpSerial(context.Background(), env.store, now); err != nil { |
| + t.Fatalf("BumpSerial: %v", err) |
| + } |
| + fastTick(context.Background(), d, now) // must not panic |
| +} |