carlos / platform Public

Wire the sidecar wake pass into fast convergence, add its CAS writer

Paul Campbell pushed by paul@keymail.dev cb1d128caec62e68c3d117b8b9175d62553db123 parent 3436588
5 files changed, +645 −134
  • internal/host/agent.go +29 −1
  • internal/host/sidecarwake.go +112 −34
  • internal/host/sidecarwake_test.go +308 −99
  • internal/release/sidecarwake.go +81 −0
  • internal/release/sidecarwake_test.go +115 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 7b14e2087..befe7821e 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -1092,6 +1092,7 @@ type fastDeps struct {
passOpts []release.PassOption // fastPassOpts — every sweep pass carries all of them
fullPass func(ctx context.Context) // reconcile → adopt → config sync, or a test hook
restartLeg func(ctx context.Context) // the `carlos restart` check (issue #113), or a test hook
+ wakeLeg func(ctx context.Context) // the sidecar wake check (design: sidecar members), or a test hook
lastSerial int64
seeded bool
lastFull time.Time
@@ -1234,7 +1235,11 @@ func (d *fastDeps) backoffFor(host string) time.Duration { return d.step[host] }
// the old ones. It is a separate call rather than a fourth leg inside
// fullPass because it is not release convergence — it restarts what is
// already correct — and because it must still run on a box with no
-// CARLOS_SECRETS_KEY, which fullPass's config leg silently skips.
+// CARLOS_SECRETS_KEY, which fullPass's config leg silently skips. The
+// sidecar wake leg (design: sidecar members) rides the same move
+// immediately after, for the identical reason: a bump can carry a
+// provisioning change and a wake request together, and it must run on
+// a box with no CARLOS_SECRETS_KEY too.
// 2. Fast sweep: fastMembers, and only when non-empty one PassFilter over
// exactly those hosts — an empty sweep costs zero store reads. After
// the sweep, members still unstaged back off; staged members clear
@@ -1265,6 +1270,9 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
if d.restartLeg != nil {
d.restartLeg(ctx)
}
+ if d.wakeLeg != nil {
+ d.wakeLeg(ctx)
+ }
d.lastSerial = doc.Serial
d.lastFull = now
}
@@ -1341,6 +1349,13 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service) {
restartSeed(ctx, reg, store, dd, restartCache)
restartBounce := bounceFn(svc)
restartWake := wakeFn(svc)
+ // The sidecar wake leg's own memory, seeded the same way and for the
+ // same reason: one silent read per served sidecar at arming so the
+ // FIRST wake request after a boot is acted on rather than swallowed as
+ // the baseline (sidecarWakeSeed's own doc comment).
+ sidecarWakeCache := map[[3]string]string{}
+ sidecarWakeSeed(ctx, reg, store, sidecarWakeCache)
+ sidecarWake := wakeFn(svc)
d := &fastDeps{
reg: reg, store: store, pub: pub, optDir: optDir,
restart: restart, passOpts: opts,
@@ -1375,6 +1390,19 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service) {
}
restartRequestLeg(ctx, routes, store, dd, restartBounce, restartWake, restartCache)
},
+ // The sidecar wake leg: apps/<acct>/<app>/sidecar-wake/<name>.json
+ // (release.SidecarWakeKey) compared and dispatched here, riding the
+ // same serial move as the restart leg immediately above it — see
+ // fastTick's comment for why both must run after fullPass and on a
+ // box with no CARLOS_SECRETS_KEY.
+ wakeLeg: func(ctx context.Context) {
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("agent: sidecar wake check: list routes: %v", err)
+ return
+ }
+ sidecarWakeLeg(ctx, routes, store, sidecarWakeCache, sidecarWake)
+ },
}
log.Printf("agent: fast converge loop armed (2s + jitter: serial poll + canary/* + unstaged routes)")
for {
diff --git a/internal/host/sidecarwake.go b/internal/host/sidecarwake.go
index cbff12df9..f300abad0 100644
--- a/internal/host/sidecarwake.go
+++ b/internal/host/sidecarwake.go
@@ -8,6 +8,17 @@
// instance wakes on its next inbound request, but a hibernating sidecar has
// no "next request" to wake on, so waking one from outside the box needs an
// explicit signal a pull-based box can notice on its own.
+//
+// Split into the same two shapes restart.go uses for exactly the same
+// reason: sidecarWakeChanges is the cheap, synchronous etag comparison
+// (restartGenPass's counterpart), while dispatchSidecarWakes is the
+// WakeWorker fan-out — which can block up to wakeWaitBound (30s,
+// activator.go) per sidecar waiting out the settle window — dispatched off
+// the caller's return path, bounded by workerBootConcurrency (agent.go),
+// the SAME idiom Task 8's boot pass and cycleWorkers both use. sidecarWakeLeg
+// is the two glued together and wired into fastConvergeLoop beside the
+// restart leg (agent.go's fastDeps.wakeLeg), so it rides the same 2s
+// serial-driven convergence `carlos restart` already gets.
package host
import (
@@ -25,8 +36,11 @@ import (
// app, name) triple its wake object lives at — name comes from Slug, the
// field a sidecar route carries its short name on (registry.Route's own
// doc comment; provision's reconciler stamps it from the sidecar record).
-// A sidecar route with no Slug cannot be resolved to a wake key and is
-// skipped: it has nothing this pass can act on.
+//
+// Gated on rt.IsSidecar() explicitly, not just a non-empty Slug: Slug is
+// NOT sidecar-exclusive (it is also an ordinary app's root-domain short
+// name), so an instance route that happens to carry the same Slug as a real
+// sidecar must never be mistaken for that sidecar.
func sidecarsServed(routes []registry.Route) map[[3]string]registry.Route {
out := make(map[[3]string]registry.Route)
for _, rt := range routes {
@@ -38,28 +52,30 @@ func sidecarsServed(routes []registry.Route) map[[3]string]registry.Route {
return out
}
-// sidecarWakePass is sidecar-wake's counterpart to restartGenPass
-// (restart.go): read the wake-object etag of every sidecar this box serves
-// and, for each one that MOVED, call wake with that sidecar's route.
+// sidecarWakeChanges is sidecar-wake's counterpart to restartGenPass: read
+// the wake-object etag of every sidecar this box serves and report which
+// ones MOVED, as their routes. It performs no wake itself — see
+// dispatchSidecarWakes for the half that can block.
//
-// - First sight of a sidecar records silently and wakes nothing. A box
-// (or an agent process) that has just started must not wake every
-// sidecar it serves because it has never seen these etags before — that
-// is the seed-don't-replay rule the serial watcher already applies
-// (agent.go's fastTick), and it is the whole safety property of this
-// feature. Without it, an agent restart would replay every wake this
-// app's sidecars have ever received — a thundering herd on boot, and
-// for a hibernating fleet a real cost, not a cosmetic one.
+// - First sight of a sidecar records silently and is never reported as
+// moved. A box (or an agent process) that has just started must not
+// wake every sidecar it serves because it has never seen these etags
+// before — that is the seed-don't-replay rule the serial watcher
+// already applies (agent.go's fastTick), and it is the whole safety
+// property of this feature. Without it, an agent restart would replay
+// every wake this app's sidecars have ever received — a thundering
+// herd on boot, and for a hibernating fleet a real cost, not a
+// cosmetic one. sidecarWakeSeed runs one such silent pass at arming so
+// the FIRST request after a boot is acted on rather than swallowed.
// - An unchanged etag does nothing.
// - Absent that stays absent does nothing, forever: a sidecar nobody has
// ever woken this way has no object and never will until someone asks.
-// - Any movement wakes: the etag changed, or absent -> present (the
+// - Any movement counts: the etag changed, or absent -> present (the
// sidecar's first ever wake request).
// - A wake object naming a sidecar this box does not serve is never even
-// read: the pass only ever builds keys from sidecarsServed, which comes
-// from THIS box's own route table. Such an object, however it moves in
-// the bucket, produces neither a wake nor an error here — it is simply
-// never asked about.
+// read: this function only ever builds keys from sidecarsServed, which
+// comes from THIS box's own route table. Such an object, however it
+// moves in the bucket, is simply never asked about.
// - A read error for one sidecar leaves its cache entry untouched and the
// rest of the pass keeps going; the error comes back for the caller to
// aggregate into its own log line, same discipline as restartGenPass.
@@ -69,10 +85,8 @@ func sidecarsServed(routes []registry.Route) map[[3]string]registry.Route {
// anyway.
//
// The cache is mutated in place and is expected to live for the process
-// lifetime, exactly like restartGenPass's — there is nowhere else to
-// remember what "changed" means between two requests that may be days
-// apart. wake may be nil (no activator armed on this box): a working no-op.
-func sidecarWakePass(ctx context.Context, routes []registry.Route, store release.Store, cache map[[3]string]string, wake func(rt registry.Route) error) (woken []string, readErrs []error) {
+// lifetime, exactly like restartGenPass's.
+func sidecarWakeChanges(ctx context.Context, routes []registry.Route, store release.Store, cache map[[3]string]string) (moved []registry.Route, readErrs []error) {
served := sidecarsServed(routes)
live := make(map[[3]string]bool, len(served))
for k, rt := range served {
@@ -87,23 +101,87 @@ func sidecarWakePass(ctx context.Context, routes []registry.Route, store release
}
prev, seen := cache[k]
cache[k] = etag
- if !seen || prev == etag {
- continue
+ if seen && prev != etag {
+ moved = append(moved, rt)
}
- if wake == nil {
- continue
- }
- if err := wake(rt); err != nil {
- log.Printf("agent: sidecar wake requested: wake %s: %v", rt.Host, err)
- continue
- }
- woken = append(woken, rt.Host)
- log.Printf("agent: sidecar wake requested: woke %s", rt.Host)
}
for k := range cache {
if !live[k] {
delete(cache, k)
}
}
- return woken, readErrs
+ return moved, readErrs
+}
+
+// dispatchSidecarWakes wakes every route in moved, concurrently, bounded by
+// workerBootConcurrency — the SAME idiom Task 8's runWorkerBootPass and
+// cycleWorkers' own wake half use, deliberately not a third shape.
+// WakeWorker can block up to wakeWaitBound (30s) per sidecar waiting out
+// the settle window, and this function's only caller (sidecarWakeLeg) runs
+// synchronously on the box's single per-tick convergence goroutine
+// (fastTick) — waiting here would delay every other app's serial-driven
+// convergence behind it. Safe because WakeWorker is idempotent and a wake
+// request's contract is "wake the process", not "return once it's up" — a
+// sidecar that comes up a moment after this tick has still been woken. A
+// wake that errors is logged and does not stop, delay, or fail the others.
+//
+// wake may be nil (no activator armed on this box): a working no-op.
+func dispatchSidecarWakes(moved []registry.Route, wake func(rt registry.Route) error) {
+ if wake == nil || len(moved) == 0 {
+ return
+ }
+ sem := make(chan struct{}, workerBootConcurrency)
+ for _, rt := range moved {
+ rt := rt
+ sem <- struct{}{}
+ go func() {
+ defer func() { <-sem }()
+ if err := wake(rt); err != nil {
+ log.Printf("agent: sidecar wake requested: wake %s: %v", rt.Host, err)
+ return
+ }
+ log.Printf("agent: sidecar wake requested: woke %s", rt.Host)
+ }()
+ }
+}
+
+// sidecarWakeLeg is sidecarWakeChanges and dispatchSidecarWakes glued
+// together — the leg fastConvergeLoop wires into fastDeps.wakeLeg beside
+// the restart leg, so a wake request rides the same 2s serial-driven
+// convergence `carlos restart` already gets. Reports whether anything
+// moved (for logging/tests); the actual wake calls happen off this
+// function's return path (dispatchSidecarWakes), so it never blocks on a
+// slow or unresponsive sidecar.
+func sidecarWakeLeg(ctx context.Context, routes []registry.Route, store release.Store, cache map[[3]string]string, wake func(rt registry.Route) error) (moved []registry.Route, readErrs []error) {
+ moved, readErrs = sidecarWakeChanges(ctx, routes, store, cache)
+ if len(readErrs) > 0 {
+ log.Printf("agent: sidecar wake check: %d sidecars unreadable: %v", len(readErrs), readErrs[0])
+ }
+ if len(moved) == 0 {
+ return moved, readErrs
+ }
+ dispatchSidecarWakes(moved, wake)
+ log.Printf("agent: sidecar wake requested for %d sidecar(s) on this box", len(moved))
+ return moved, readErrs
+}
+
+// sidecarWakeSeed is sidecarWakeChanges' counterpart to restartSeed: at
+// arming, read each served sidecar's wake-object etag once, silently, so
+// the cache sidecarWakeLeg compares against exists before the first serial
+// move. Without it the first wake request after a boot is the sweep that
+// establishes the baseline, and is therefore the one request that does
+// nothing.
+//
+// A read error leaves that sidecar out of the cache (its next sighting is a
+// first sight — safe, never a spurious wake).
+func sidecarWakeSeed(ctx context.Context, reg *registry.Registry, store release.Store, cache map[[3]string]string) {
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("agent: sidecar wake baseline: list routes: %v (the first request may need a second try)", err)
+ return
+ }
+ _, readErrs := sidecarWakeChanges(ctx, routes, store, cache)
+ if len(readErrs) > 0 {
+ log.Printf("agent: sidecar wake baseline: %d sidecars unreadable: %v", len(readErrs), readErrs[0])
+ }
}
diff --git a/internal/host/sidecarwake_test.go b/internal/host/sidecarwake_test.go
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
+}
diff --git a/internal/release/sidecarwake.go b/internal/release/sidecarwake.go
index 5a5cabdd6..b1d672816 100644
--- a/internal/release/sidecarwake.go
+++ b/internal/release/sidecarwake.go
@@ -18,6 +18,16 @@
// exactly as strict as it reads.
package release
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "time"
+)
+
// SidecarWake is the object a wake request touches. Change detection is by
// ETAG, never by Generation: a monotonic counter that resets — a bucket
// restored from backup, an object rewritten by a script that does not know
@@ -43,3 +53,74 @@ type SidecarWake struct {
func SidecarWakeKey(acct, app, name string) string {
return AppPrefix(acct, app) + "sidecar-wake/" + name + ".json"
}
+
+// sidecarWakeNameRe pins a sidecar's name to one DNS label — the same
+// pattern provision.ValidSidecarName (labelRe) checks, mirrored locally
+// rather than imported: provision already imports release for the bucket
+// key conventions, so release importing provision back would be a cycle.
+// The name becomes a bucket key segment here exactly as it does there, so
+// the same discipline applies: this is what keeps a hostile name an opaque,
+// safe path component rather than a traversal.
+var sidecarWakeNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+
+func validSidecarWakeName(name string) bool { return sidecarWakeNameRe.MatchString(name) }
+
+// TouchSidecarWake bumps name's wake generation within app — the request
+// every box serving that sidecar converges on within seconds. by and reason
+// are carried verbatim onto the object for a human reading it during an
+// incident; neither is validated, because neither is ever parsed back into
+// a decision (see SidecarWake's own doc comment on carrying no authority).
+//
+// A CAS conflict is SUCCESS, not an error, for exactly TouchRestartGen's
+// reason: it means another writer touched the object between our read and
+// our write, so the signal this function exists to fire has already fired.
+// Retrying would only ask a sidecar that is already waking to wake again.
+//
+// os.ErrNotExist on the read means first touch (Generation starts at 1),
+// which is what makes the conflict carve-out safe here too: two first
+// touches race, one wins, both callers are correct — both asked for a wake,
+// and exactly one wake is what the box will do (WakeWorker is idempotent
+// regardless).
+func TouchSidecarWake(ctx context.Context, s Store, acct, app, name, by, reason string) error {
+ if !ValidAccount(acct) {
+ return fmt.Errorf("sidecar wake: invalid account %q", acct)
+ }
+ if !ValidApp(app) {
+ return fmt.Errorf("sidecar wake: invalid app %q", app)
+ }
+ if !validSidecarWakeName(name) {
+ return fmt.Errorf("sidecar wake: invalid sidecar name %q", name)
+ }
+ key := SidecarWakeKey(acct, app, name)
+ raw, etag, err := s.Get(ctx, key)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ case err != nil:
+ return err
+ }
+ gen := 0
+ if err == nil {
+ var prev SidecarWake
+ if uerr := json.Unmarshal(raw, &prev); uerr == nil {
+ gen = prev.Generation
+ }
+ }
+ body, err := json.Marshal(SidecarWake{
+ Schema: 1,
+ Generation: gen + 1,
+ At: time.Now().UTC().Format(time.RFC3339),
+ By: by,
+ Reason: reason,
+ })
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, body, etag); err != nil {
+ if errors.Is(err, ErrCASConflict) {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
diff --git a/internal/release/sidecarwake_test.go b/internal/release/sidecarwake_test.go
index 1a3abfadb..1d23d12b5 100644
--- a/internal/release/sidecarwake_test.go
+++ b/internal/release/sidecarwake_test.go
@@ -1,8 +1,12 @@
package release
import (
+ "context"
"encoding/json"
+ "errors"
+ "os"
"testing"
+ "time"
)
// TestSidecarWakeKeyIsAnAppSibling pins the key's shape: a SIBLING prefix
@@ -46,3 +50,114 @@ func TestSidecarWakeJSONShape(t *testing.T) {
t.Fatalf("round trip = %+v, want %+v", back, w)
}
}
+
+// sidecarWakeEtag reads name's wake etag, "" for absent — the exact
+// observation a box's pass makes, so a test asserting on it asserts on the
+// signal itself rather than on this file's internals.
+func sidecarWakeEtag(t *testing.T, s Store, acct, app, name string) string {
+ t.Helper()
+ _, etag, err := s.Get(context.Background(), SidecarWakeKey(acct, app, name))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return ""
+ }
+ t.Fatalf("read sidecar wake: %v", err)
+ }
+ return etag
+}
+
+func TestTouchSidecarWakeCreatesAndMoves(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := TouchSidecarWake(ctx, s, "bdm", "eleven", "bot", "member:bdm", "operator wake"); err != nil {
+ t.Fatalf("first TouchSidecarWake: %v", err)
+ }
+ raw, first, err := s.Get(ctx, SidecarWakeKey("bdm", "eleven", "bot"))
+ if err != nil {
+ t.Fatalf("Get after first touch: %v", err)
+ }
+ var w SidecarWake
+ if err := json.Unmarshal(raw, &w); err != nil {
+ t.Fatalf("decode sidecar wake: %v", err)
+ }
+ if w.Schema != 1 {
+ t.Fatalf("Schema = %d, want 1", w.Schema)
+ }
+ if w.Generation != 1 {
+ t.Fatalf("Generation = %d, want 1 on first touch", w.Generation)
+ }
+ if w.By != "member:bdm" || w.Reason != "operator wake" {
+ t.Fatalf("By/Reason = %q/%q, want the values passed in", w.By, w.Reason)
+ }
+ if _, err := time.Parse(time.RFC3339, w.At); err != nil {
+ t.Fatalf("At %q is not RFC3339: %v", w.At, err)
+ }
+ // The etag is the whole point: a second request must be a DIFFERENT
+ // object, and Generation must climb, or the box's comparison sees
+ // nothing and nothing wakes.
+ if err := TouchSidecarWake(ctx, s, "bdm", "eleven", "bot", "member:bdm", "second wake"); err != nil {
+ t.Fatalf("second TouchSidecarWake: %v", err)
+ }
+ second := sidecarWakeEtag(t, s, "bdm", "eleven", "bot")
+ if second == first {
+ t.Fatalf("etag %q unchanged after a second request — the wake signal never fires", second)
+ }
+ raw2, _, err := s.Get(ctx, SidecarWakeKey("bdm", "eleven", "bot"))
+ if err != nil {
+ t.Fatalf("Get after second touch: %v", err)
+ }
+ var w2 SidecarWake
+ if err := json.Unmarshal(raw2, &w2); err != nil {
+ t.Fatalf("decode sidecar wake: %v", err)
+ }
+ if w2.Generation != 2 {
+ t.Fatalf("Generation after second touch = %d, want 2", w2.Generation)
+ }
+}
+
+// racingSidecarWakePutStore lands a different writer's value at key just
+// before forwarding the caller's Put, which then fails CAS against the etag
+// the caller read — two operators (or a person and a retrying CLI) asking
+// for a wake at the same moment. Mirrors racingRestartPutStore exactly.
+type racingSidecarWakePutStore struct {
+ Store
+ key string
+ done bool
+}
+
+func (r *racingSidecarWakePutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == r.key && !r.done {
+ r.done = true
+ other := []byte(`{"schema":1,"generation":1,"at":"2026-01-01T00:00:00Z","by":"member:other","reason":"racing"}`)
+ if _, err := r.Store.Put(ctx, key, other, ifMatch); err != nil {
+ return "", err
+ }
+ }
+ return r.Store.Put(ctx, key, body, ifMatch)
+}
+
+func TestTouchSidecarWakeCASConflictIsSuccess(t *testing.T) {
+ inner := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ racing := &racingSidecarWakePutStore{Store: inner, key: SidecarWakeKey("bdm", "eleven", "bot")}
+ if err := TouchSidecarWake(ctx, racing, "bdm", "eleven", "bot", "member:bdm", "operator wake"); err != nil {
+ t.Fatalf("TouchSidecarWake on a lost CAS race: %v, want nil (the request already fired)", err)
+ }
+ if !racing.done {
+ t.Fatal("the racing writer never ran — this test proved nothing")
+ }
+}
+
+func TestTouchSidecarWakeRefusesInvalidNames(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := TouchSidecarWake(ctx, s, "../etc", "eleven", "bot", "member:bdm", "x"); err == nil {
+ t.Fatal("TouchSidecarWake accepted an invalid account")
+ }
+ if err := TouchSidecarWake(ctx, s, "bdm", "../etc", "bot", "member:bdm", "x"); err == nil {
+ t.Fatal("TouchSidecarWake accepted an invalid app")
+ }
+ if err := TouchSidecarWake(ctx, s, "bdm", "eleven", "../etc", "member:bdm", "x"); err == nil {
+ t.Fatal("TouchSidecarWake accepted an invalid sidecar name")
+ }
+}