host: wire the agent socket, worker boot pass, and version write
4 files changed,
+474
−4
internal/activator/activator.go+37 −4internal/activator/activator_test.go+57 −0internal/host/agent.go+150 −0internal/host/agent_test.go+230 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 8c3aa660d..22df6d142 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -189,9 +189,17 @@ type Backend interface { |
| // in production (routes.next_due), so a sleeping host's timer survives agent |
| // restarts instead of dying with the process. *registry.Registry satisfies it |
| // directly. |
| +// |
| +// SetVersions rides along here rather than on a separate interface (Task 8): |
| +// the registry handle Service already holds for the due-time index is the |
| +// same one that owns routes.version/prev_version, and in every deployment |
| +// (production and every test in this package) the two are the same |
| +// *registry.Registry — a second interface would only name a distinction |
| +// that doesn't exist anywhere it's used. NoteWorkerReady is its one caller. |
| type DueStore interface { |
| SetNextDue(host string, due int64) error |
| DueInstances(now int64) ([]registry.Route, error) |
| + SetVersions(host, ver, prev string) error |
| } |
| // Lease is a held-but-not-yet-replicating lease (wake in progress; Eleven's |
| @@ -1339,26 +1347,51 @@ func (a *Service) NoteWorkerContact(host string) { |
| // NoteWorkerReady records a worker's own "I am up" — last-activity, the build |
| // it reports running, and the end of its settle window. Deliberately the |
| // child's claim, not ours: §9.2's honesty rule is that a sidecar which never |
| -// reports shows unknown, never "current". (Task 8 additionally persists the |
| -// version through the registry's version setter; this is the in-memory half |
| -// the agent socket needs today.) |
| +// reports shows unknown, never "current". |
| // |
| // It acts on a WAKING entry as well as an awake one — that is the whole point |
| // of the settle window: a child that boots fast says so and its wake resolves |
| // immediately instead of waiting out sidecarSettle. |
| +// |
| +// The registry write (Task 8) is the durable half of the same claim — the |
| +// in-memory e.workerVersion above is what the agent itself remembers between |
| +// restarts of nothing; the row is what `carlos sidecars ls` and everything |
| +// else off-process reads. It happens OUTSIDE a.mu: SetVersions is a SQL |
| +// write, and nothing under the entry lock should wait on disk. |
| +// |
| +// An EMPTY version is never written. §9.2's rule is that a sidecar which |
| +// never reports shows unknown — and the registry's own default for an |
| +// unset version IS the empty string (SetVersions is never called for a |
| +// fresh row), so "unknown" is represented by an ABSENT write, not by a |
| +// stamped "". Calling SetVersions with "" here would be a stamp: it would |
| +// overwrite whatever version the row already carried (from an earlier |
| +// report, or an adopt pass) with a value that reads as "verified empty" |
| +// rather than "this child said nothing this time" — the overclaim the rule |
| +// exists to forbid. So an empty report still ends the settle window and |
| +// stamps last-activity (the child IS up; it just isn't naming a build) and |
| +// simply skips the write. |
| func (a *Service) NoteWorkerReady(host, version string) { |
| a.mu.Lock() |
| - defer a.mu.Unlock() |
| e := a.entries[host] |
| if e == nil || !e.worker { |
| + a.mu.Unlock() |
| return |
| } |
| e.lastActive = a.now() |
| e.workerVersion = version |
| + prev := e.route.Version |
| if !e.readyNoted { |
| e.readyNoted = true |
| close(e.readyc) |
| } |
| + a.mu.Unlock() |
| + |
| + if version == "" { |
| + return |
| + } |
| + if err := a.due.SetVersions(host, version, prev); err != nil { |
| + log.Printf("activator: record worker version for %s: %v", host, err) |
| + } |
| } |
| // SetWorkerNextDue records a sidecar's next scheduled work, straight to the |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index fc40a3ca9..417ce31a4 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -2623,6 +2623,63 @@ func TestWorkerReadyEndsSettleWindowEarly(t *testing.T) { |
| } |
| } |
| +// TestNoteWorkerReadyPersistsVersionToRegistry pins Task 8's registry half: |
| +// a worker's own claim over the agent socket must land on the row through |
| +// the registry's existing version setter, so `carlos sidecars ls` (and |
| +// anything else reading routes.version) can see it — not just the in-memory |
| +// entry the agent socket itself consults. |
| +func TestNoteWorkerReadyPersistsVersionToRegistry(t *testing.T) { |
| + a, b, _, reg := testActivator(t, time.Hour) |
| + host := "bot.x.internal" |
| + if err := reg.Upsert(testWorkerRoute(host)); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + if err := a.WakeWorker(testWorkerRoute(host)); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + noteReadyAsChild(t, a, b, host, "v9") |
| + |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil { |
| + t.Fatalf("get %s: %+v, %v", host, rt, err) |
| + } |
| + if rt.Version != "v9" { |
| + t.Fatalf("registry Version = %q, want %q", rt.Version, "v9") |
| + } |
| +} |
| + |
| +// TestNoteWorkerReadyEmptyVersionNeverStampsUnknown pins §9.2's honesty rule: |
| +// a child that reports ready with no version claim must leave the registry |
| +// column exactly as it was (its default "" — displayed as unknown) rather |
| +// than write an empty string over whatever was there. Recording "" IS a |
| +// version stamp, and an empty stamp reads as "verified empty" rather than |
| +// "never reported" — the wrong claim. |
| +func TestNoteWorkerReadyEmptyVersionNeverStampsUnknown(t *testing.T) { |
| + a, b, _, reg := testActivator(t, time.Hour) |
| + host := "bot.x.internal" |
| + // Seed a version on the row BEFORE the wake, the way a prior adopt pass |
| + // would have — an empty report must not clobber it either. |
| + if err := reg.Upsert(testWorkerRoute(host)); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + if err := reg.SetVersions(host, "v8", ""); err != nil { |
| + t.Fatalf("seed version: %v", err) |
| + } |
| + |
| + if err := a.WakeWorker(testWorkerRoute(host)); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + noteReadyAsChild(t, a, b, host, "") |
| + |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil { |
| + t.Fatalf("get %s: %+v, %v", host, rt, err) |
| + } |
| + if rt.Version != "v8" { |
| + t.Fatalf("registry Version = %q after an empty ready report, want the seeded %q untouched", rt.Version, "v8") |
| + } |
| +} |
| + |
| // --- always-on workers: supervision ------------------------------------------ |
| func alwaysOnWorkerRoute(host string) registry.Route { |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index b7498b778..863ec22e1 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -143,6 +143,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/agentsock" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/caps" |
| @@ -276,6 +277,43 @@ func RunAgent(args []string) { |
| svc.Start() |
| act = svc |
| log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle) |
| + |
| + // The agent socket: the one channel a sidecar dials back over |
| + // (internal/agentsock, Task 7). Constructed only in this branch — |
| + // the same condition that constructs the activator — because with |
| + // hibernation off there are no workers on the box at all, and |
| + // nothing would ever call in. cfg.AgentSocket is never empty here: |
| + // activator.ConfigFromEnv defaults it to DefaultAgentSocket. |
| + // |
| + // The cancel is deferred right here, not called explicitly at the |
| + // shutdown site below: RunAgent's defers run LIFO at return, so this |
| + // one (registered first) fires AFTER the shutdown block's own |
| + // `defer cancel()` for svc.Shutdown's context — i.e. once the |
| + // children are already down — while staying the one place this |
| + // context's lifetime is decided, which is what satisfies go vet's |
| + // lostcancel check. |
| + agentSockCtx, agentSockCancel := context.WithCancel(context.Background()) |
| + defer agentSockCancel() |
| + agentSrv := agentsock.New(svc) |
| + go serveAgentSocket(agentSockCtx, cfg.AgentSocket, agentSrv.Serve) |
| + log.Printf("agent: agent socket armed at %s", cfg.AgentSocket) |
| + |
| + // The boot pass (Task 8): a sidecar that does not hibernate must be |
| + // running whenever the agent is — an agent restart otherwise leaves |
| + // it down until a request or its due time happens to arrive, and an |
| + // always-on worker has no request path at all to wake it. Run off |
| + // the startup path (its own goroutine, not blocking RunAgent) since |
| + // WakeWorker can block for the settle window per host and this must |
| + // never delay the edge coming up; runWorkerBootPass itself wakes |
| + // its candidates concurrently, bounded, rather than serially. |
| + go func() { |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("agent: worker boot pass: list routes: %v", err) |
| + return |
| + } |
| + runWorkerBootPass(routes, svc.WakeWorker) |
| + }() |
| } |
| // Fleet box binding publish, startup half (customer-fleets plan, Task 6): |
| @@ -337,6 +375,118 @@ func RunAgent(args []string) { |
| ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| defer cancel() |
| svc.Shutdown(ctx) |
| + // The agent socket's own defer (above, at its construction) stops it |
| + // once this function returns — after Shutdown, per the LIFO ordering |
| + // explained there. Service is single-use once Shutdown has run (its |
| + // lifeCtx is cancelled), so there is nothing left to authorize a call |
| + // against by the time the socket actually stops either way. |
| + } |
| +} |
| + |
| +// --- Task 8: the agent socket's retry/backoff, and the worker boot pass ---- |
| + |
| +// workersToStart is the boot pass's selection: a sidecar that does not |
| +// hibernate must be running whenever the agent is. A hibernating sidecar is |
| +// deliberately NOT started here — it waits for its due time, an operator |
| +// command, or (later) a delivery signal, which is the whole point of the |
| +// class. Status is checked the same way the due-time sweep checks it |
| +// (activator.go's sweepOnce): only "ready" (or the legacy "" some pre-status |
| +// rows carry) is eligible — a row still provisioning, or draining, must not |
| +// be spawned. |
| +func workersToStart(routes []registry.Route) []registry.Route { |
| + var out []registry.Route |
| + for _, rt := range routes { |
| + if rt.Kind == "sidecar" && !rt.Hibernate && (rt.Status == "" || rt.Status == "ready") { |
| + out = append(out, rt) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// workerBootConcurrency bounds how many sidecars the boot pass wakes at |
| +// once. WakeWorker blocks for the settle window (activator's sidecarSettle, |
| +// 10s in production) whenever a child never calls /v1/ready — a slow or |
| +// silent sidecar, or simply one that only survives the window rather than |
| +// reporting early — so an unbounded fan-out could still spawn every child on |
| +// the box in the same instant (a thundering herd against S3 leases and |
| +// restore reads), while a serial pass would cost up to 10 seconds PER |
| +// sidecar of agent startup. This bound trades both off: bounded fan-out, not |
| +// one-at-a-time. |
| +const workerBootConcurrency = 8 |
| + |
| +// runWorkerBootPass wakes every route workersToStart selects out of routes, |
| +// concurrently (bounded by workerBootConcurrency) rather than one at a time |
| +// — see workerBootConcurrency's doc comment for why serial is too slow and |
| +// unbounded is too bursty. wake is svc.WakeWorker in production; a seam here |
| +// so the concurrency shape is testable without a real activator.Service. A |
| +// wake that errors is logged and does not stop the rest — one sidecar that |
| +// can't come up must not strand every other one on the box at boot. |
| +func runWorkerBootPass(routes []registry.Route, wake func(registry.Route) error) { |
| + toStart := workersToStart(routes) |
| + if len(toStart) == 0 { |
| + return |
| + } |
| + sem := make(chan struct{}, workerBootConcurrency) |
| + var wg sync.WaitGroup |
| + for _, rt := range toStart { |
| + rt := rt |
| + wg.Add(1) |
| + sem <- struct{}{} |
| + go func() { |
| + defer wg.Done() |
| + defer func() { <-sem }() |
| + if err := wake(rt); err != nil { |
| + log.Printf("agent: worker boot pass: wake %s: %v", rt.Host, err) |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| +} |
| + |
| +// agentSocketRetryBase/Cap bound the agent socket's rebind backoff — vars, |
| +// not consts, so a test can shrink them (sidecarSettle's precedent in |
| +// internal/activator). |
| +var ( |
| + agentSocketRetryBase = time.Second |
| + agentSocketRetryCap = 30 * time.Second |
| +) |
| + |
| +// serveAgentSocket runs serve (agentsock.Server.Serve in production) in a |
| +// retry loop with doubling backoff, forever, until ctx is cancelled. serve |
| +// is a seam so the retry/backoff shape is testable without a real unix |
| +// socket. |
| +// |
| +// A failed bind must NEVER be fatal to the agent: agentsock.Serve |
| +// deliberately REFUSES a socket another listener already owns (rather than |
| +// unlinking it) to prevent two agents both believing they own the box's |
| +// workers, and on an agent restart the old process's listener can take a |
| +// few seconds to release — that refusal is expected and transient. An agent |
| +// that exited because a socket was briefly busy would take down every route |
| +// on the box, so this loop only ever logs and retries; it has no path that |
| +// calls log.Fatal or returns an error to a caller who might. |
| +// |
| +// A clean return from serve (nil, once ctx is cancelled — agentsock.Serve's |
| +// own contract) ends the loop immediately with no retry: that is a normal |
| +// shutdown, not a failure. |
| +func serveAgentSocket(ctx context.Context, path string, serve func(context.Context, string) error) { |
| + backoff := agentSocketRetryBase |
| + for { |
| + err := serve(ctx, path) |
| + if err == nil || ctx.Err() != nil { |
| + return |
| + } |
| + log.Printf("agent: agent socket %s: %v — retrying in %s", path, err, backoff) |
| + timer := time.NewTimer(backoff) |
| + select { |
| + case <-timer.C: |
| + case <-ctx.Done(): |
| + timer.Stop() |
| + return |
| + } |
| + backoff *= 2 |
| + if backoff > agentSocketRetryCap { |
| + backoff = agentSocketRetryCap |
| + } |
| } |
| } |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| index 07758c854..a21ecdabe 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "crypto/ed25519" |
| "encoding/base64" |
| "errors" |
| + "fmt" |
| "log" |
| "net/http" |
| "net/http/httptest" |
| @@ -16,6 +17,7 @@ import ( |
| "sort" |
| "strings" |
| "sync" |
| + "sync/atomic" |
| "testing" |
| "time" |
| @@ -1790,3 +1792,231 @@ func TestGenPollPassRouteShapeCostsNoBucketReads(t *testing.T) { |
| t.Fatalf("the rebinding tick made %d bucket reads, want exactly 1 (the app's generation object, as before)", got) |
| } |
| } |
| + |
| +// ---- Task 8: the agent socket, the boot pass, and the version write ------- |
| + |
| +// waitForCond polls until cond holds — the shared shape every wait-for-a- |
| +// goroutine test in this package needs (fastConvergeLoop's own tests have |
| +// no such helper because none of them race a goroutine against a channel). |
| +func waitForCond(t *testing.T, what string, cond func() bool) { |
| + t.Helper() |
| + deadline := time.Now().Add(5 * time.Second) |
| + for time.Now().Before(deadline) { |
| + if cond() { |
| + return |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + t.Fatalf("timed out waiting for %s", what) |
| +} |
| + |
| +// TestWorkerBootPassStartsOnlyNonHibernatingSidecars is Task 8's own test |
| +// (brief step 1, verbatim): a sidecar that does not hibernate must be |
| +// running whenever the agent is; a hibernating one waits for its due time, |
| +// an operator command, or a delivery signal — the whole point of the class. |
| +// Non-sidecar routes and a non-ready sidecar are excluded too: the boot pass |
| +// exists to start THIS box's own always-on sidecars, not to touch instances |
| +// (which have their own convergence) or a row still mid-provisioning. |
| +func TestWorkerBootPassStartsOnlyNonHibernatingSidecars(t *testing.T) { |
| + routes := []registry.Route{ |
| + {Host: "a.x.sidecar.internal", Kind: "sidecar", Hibernate: false, Status: "ready"}, |
| + {Host: "b.x.sidecar.internal", Kind: "sidecar", Hibernate: true, Status: "ready"}, |
| + {Host: "c.example.com", Kind: "instance", Hibernate: false, Status: "ready"}, |
| + {Host: "d.x.sidecar.internal", Kind: "sidecar", Hibernate: false, Status: "draining"}, |
| + } |
| + got := workersToStart(routes) |
| + if len(got) != 1 || got[0].Host != "a.x.sidecar.internal" { |
| + t.Fatalf("workersToStart = %v — only a ready, non-hibernating sidecar starts at boot; a hibernating one waits for its timer or a command", got) |
| + } |
| +} |
| + |
| +// TestWorkerBootPassWakesEveryNonHibernatingSidecar pins the end-to-end |
| +// wiring shape: given N candidate routes, the boot pass calls wake exactly |
| +// once per candidate, and a wake that errors does not stop the rest from |
| +// being tried (one bad sidecar must not strand every other one on the box |
| +// at boot). |
| +func TestWorkerBootPassWakesEveryNonHibernatingSidecar(t *testing.T) { |
| + routes := []registry.Route{ |
| + {Host: "a.x.sidecar.internal", Kind: "sidecar", Status: "ready"}, |
| + {Host: "b.x.sidecar.internal", Kind: "sidecar", Status: "ready"}, |
| + {Host: "c.x.sidecar.internal", Kind: "sidecar", Hibernate: true, Status: "ready"}, // excluded |
| + } |
| + var mu sync.Mutex |
| + woken := map[string]int{} |
| + wake := func(rt registry.Route) error { |
| + mu.Lock() |
| + woken[rt.Host]++ |
| + mu.Unlock() |
| + if rt.Host == "a.x.sidecar.internal" { |
| + return errors.New("boom") |
| + } |
| + return nil |
| + } |
| + runWorkerBootPass(routes, wake) |
| + |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if woken["a.x.sidecar.internal"] != 1 || woken["b.x.sidecar.internal"] != 1 { |
| + t.Fatalf("woken = %v, want exactly one call each for a and b", woken) |
| + } |
| + if _, ok := woken["c.x.sidecar.internal"]; ok { |
| + t.Fatalf("a hibernating sidecar was woken at boot: %v", woken) |
| + } |
| +} |
| + |
| +// TestWorkerBootPassWakesConcurrently pins the carried requirement that the |
| +// boot pass must NOT wake sidecars one at a time: WakeWorker blocks for the |
| +// settle window (10s) when nothing answers /v1/ready, so a serial pass over |
| +// N sidecars would cost 10N seconds of agent startup. This drives more |
| +// in-flight wakes than the concurrency bound and proves the bound is |
| +// actually reached (not stuck at 1, which a serial implementation would |
| +// show) and never exceeded (which an unbounded implementation would show). |
| +func TestWorkerBootPassWakesConcurrently(t *testing.T) { |
| + n := workerBootConcurrency + 3 // strictly more than the bound |
| + routes := make([]registry.Route, n) |
| + for i := range routes { |
| + routes[i] = registry.Route{ |
| + Host: fmt.Sprintf("s%d.x.sidecar.internal", i), Kind: "sidecar", Status: "ready", |
| + } |
| + } |
| + |
| + 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 |
| + } |
| + |
| + done := make(chan struct{}) |
| + go func() { |
| + runWorkerBootPass(routes, wake) |
| + close(done) |
| + }() |
| + |
| + waitForCond(t, "the boot pass to saturate its 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("boot pass did not finish after its wakes were released") |
| + } |
| +} |
| + |
| +// ---- the agent socket: retry/backoff on a bind that lost the race -------- |
| + |
| +// TestServeAgentSocketReturnsOnCleanShutdown pins the non-error path: a nil |
| +// return (agentsock.Serve's contract once ctx is cancelled and the listener |
| +// shut down cleanly) must end the loop immediately, with no retry. |
| +func TestServeAgentSocketReturnsOnCleanShutdown(t *testing.T) { |
| + var calls int32 |
| + serve := func(context.Context, string) error { |
| + atomic.AddInt32(&calls, 1) |
| + return nil |
| + } |
| + serveAgentSocket(context.Background(), "/tmp/does-not-matter.sock", serve) |
| + if n := atomic.LoadInt32(&calls); n != 1 { |
| + t.Fatalf("serve calls = %d, want exactly 1 — a clean nil return must never retry", n) |
| + } |
| +} |
| + |
| +// TestServeAgentSocketRetriesWithBackoffOnBusySocket pins the carried |
| +// requirement that a failed bind — agentsock.Serve deliberately refusing a |
| +// socket another (likely still-shutting-down) incarnation still owns — must |
| +// NEVER be fatal: the loop retries with backoff instead of returning the |
| +// error up to a log.Fatal that would take the whole agent, and every route |
| +// on the box, down over a transient restart race. |
| +func TestServeAgentSocketRetriesWithBackoffOnBusySocket(t *testing.T) { |
| + oldBase, oldCap := agentSocketRetryBase, agentSocketRetryCap |
| + agentSocketRetryBase = time.Millisecond |
| + agentSocketRetryCap = 4 * time.Millisecond |
| + defer func() { agentSocketRetryBase, agentSocketRetryCap = oldBase, oldCap }() |
| + |
| + var calls int32 |
| + succeeded := make(chan struct{}) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + serve := func(c context.Context, path string) error { |
| + n := atomic.AddInt32(&calls, 1) |
| + if n < 4 { |
| + return errors.New("agentsock: /run/carlos/agent.sock is already served by a live listener") |
| + } |
| + close(succeeded) |
| + <-c.Done() // the fourth "bind" holds until shutdown, like the real Serve |
| + return nil |
| + } |
| + |
| + done := make(chan struct{}) |
| + go func() { |
| + serveAgentSocket(ctx, "/run/carlos/agent.sock", serve) |
| + close(done) |
| + }() |
| + |
| + select { |
| + case <-succeeded: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("a busy socket was never retried to success") |
| + } |
| + cancel() |
| + select { |
| + case <-done: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("serveAgentSocket did not return after its context was cancelled") |
| + } |
| +} |
| + |
| +// TestServeAgentSocketStopsRetryingOnContextCancel pins the other half of |
| +// "never fatal, never stuck": a permanently busy socket must not wedge the |
| +// goroutine forever — cancelling ctx ends the retry loop promptly even |
| +// mid-backoff. |
| +func TestServeAgentSocketStopsRetryingOnContextCancel(t *testing.T) { |
| + oldBase, oldCap := agentSocketRetryBase, agentSocketRetryCap |
| + agentSocketRetryBase = time.Millisecond |
| + agentSocketRetryCap = 2 * time.Millisecond |
| + defer func() { agentSocketRetryBase, agentSocketRetryCap = oldBase, oldCap }() |
| + |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + var calls int32 |
| + serve := func(context.Context, string) error { |
| + atomic.AddInt32(&calls, 1) |
| + return errors.New("still busy") |
| + } |
| + done := make(chan struct{}) |
| + go func() { |
| + serveAgentSocket(ctx, "/run/carlos/agent.sock", serve) |
| + close(done) |
| + }() |
| + |
| + waitForCond(t, "at least one retry to have happened", func() bool { |
| + return atomic.LoadInt32(&calls) >= 2 |
| + }) |
| + cancel() |
| + select { |
| + case <-done: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("serveAgentSocket did not stop retrying after context cancellation") |
| + } |
| +} |