| index 086540b33..de4a994f0 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -43,6 +43,8 @@ package activator |
| |
| import ( |
| "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| "errors" |
| "fmt" |
| "log" |
| @@ -74,8 +76,16 @@ type Config struct { |
| Region string // CARLOS_HIBERNATE_REGION (optional; AWS default chain resolves "") |
| Prefix string // CARLOS_HIBERNATE_PREFIX (optional key prefix inside the bucket) |
| Idle time.Duration // CARLOS_HIBERNATE_IDLE (default 15m; seconds-granular, e.g. "90s") |
| + // AgentSocket is the unix socket a worker child dials to reach back — |
| + // the direction that makes a sidecar possible at all: the agent listens, |
| + // children call in; the platform never dials a sidecar. Handed to every |
| + // worker as CARLOS_AGENT_SOCKET. Empty falls back to DefaultAgentSocket. |
| + AgentSocket string |
| } |
| |
| +// DefaultAgentSocket is where the agent listens for its worker children. |
| +const DefaultAgentSocket = "/run/carlos/agent.sock" |
| + |
| // ConfigFromEnv reads the CARLOS_HIBERNATE_* env vars. A nil, nil return |
| // means the bucket is unset: hibernation disabled, don't construct a Service. |
| func ConfigFromEnv() (*Config, error) { |
| @@ -84,10 +94,11 @@ func ConfigFromEnv() (*Config, error) { |
| return nil, nil |
| } |
| cfg := &Config{ |
| - Bucket: bucket, |
| - Region: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_REGION")), |
| - Prefix: strings.Trim(strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_PREFIX")), "/"), |
| - Idle: 15 * time.Minute, |
| + Bucket: bucket, |
| + Region: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_REGION")), |
| + Prefix: strings.Trim(strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_PREFIX")), "/"), |
| + Idle: 15 * time.Minute, |
| + AgentSocket: DefaultAgentSocket, |
| } |
| if v := strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_IDLE")); v != "" { |
| d, err := time.ParseDuration(v) |
| @@ -293,6 +304,23 @@ type actEntry struct { |
| sidecarStarted time.Time // when the current sidecar spawned (backoff reset) |
| sidecarBackoff time.Duration // last restart delay (doubles, capped) |
| sidecarRetryAt time.Time // earliest next restart attempt |
| + |
| + // Standalone sidecar ("worker") entries. A worker is this entry's own |
| + // proc, not a passenger on someone else's: the co-spawn fields above stay |
| + // zero for it, and it never becomes a co-spawn parent either. |
| + // |
| + // worker, workerIdle, workerMaxRun and workerToken are set once, at entry |
| + // construction, BEFORE the wake goroutine is launched, and never written |
| + // again — so wake/doWake read them off-lock safely (the `go` statement is |
| + // the happens-before edge). spawnedAt and workerVersion are written under |
| + // a.mu like every other post-wake field. |
| + worker bool // this entry is a sidecar: no socket, no requests |
| + workerIdle time.Duration // 0 = fall back to cfg.Idle |
| + workerMaxRun time.Duration // 0 = no ceiling |
| + workerToken string // the per-wake agent-socket token |
| + |
| + spawnedAt time.Time // when this wake's child spawned — the max_run clock |
| + workerVersion string // the build the child reported over the agent socket |
| } |
| |
| // Sidecar restart pacing — Restart=always semantics without a supervisor: the |
| @@ -399,7 +427,7 @@ type Service struct { |
| sweepStop chan struct{} |
| sweepDone chan struct{} |
| |
| - // OnWake, when set, is called with the host after EVERY successful wake |
| + // OnWake, when set, is called with the ROUTE after EVERY successful wake |
| // — fleet boxes use it to publish a binding object to the deployment |
| // bucket (customer-fleets plan, Task 6) naming this box as the current |
| // holder of host's lease; nil (every non-fleet deployment) is a pure |
| @@ -410,7 +438,14 @@ type Service struct { |
| // hosts, leases, and processes. wake() invokes it in its own goroutine |
| // (see below) so a slow or failing publish can never delay a request |
| // that's waiting on this same wake. |
| - OnWake func(host string) |
| + // |
| + // The whole route rather than the bare host because the hook's own |
| + // refusal is kind-shaped: a binding must never be published for a |
| + // sidecar, and the wake path already HAS the route. Passing the host |
| + // alone forced the hook to re-read the registry to learn the kind, which |
| + // made a security-relevant refusal depend on an I/O call that can fail — |
| + // and fail OPEN, since a read error must not stop legitimate bindings. |
| + OnWake func(rt registry.Route) |
| } |
| |
| // New wires the state machine to its side-effect backend and due-time store. |
| @@ -613,7 +648,7 @@ func (a *Service) releaseFunc(e *actEntry) func() { |
| func (a *Service) wake(e *actEntry) { |
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) |
| defer cancel() |
| - w, err := a.doWake(ctx, e.route) |
| + w, err := a.doWake(ctx, e) |
| |
| // The member-facing record of this wake (spec §5): composed here, where |
| // the route and the whole error are in hand — the journal line below the |
| @@ -622,6 +657,9 @@ func (a *Service) wake(e *actEntry) { |
| emitEvent(a.Logs, e.route, "error", memberSafeWakeReason(err)) |
| } else { |
| msg := "instance woke" |
| + if e.worker { |
| + msg = "sidecar started" |
| + } |
| if w.spawnVersion != "" { |
| msg += " (version " + w.spawnVersion + ")" |
| } |
| @@ -640,6 +678,10 @@ func (a *Service) wake(e *actEntry) { |
| e.sidecarBackoff = 0 |
| e.sidecarRetryAt = time.Time{} // a wake-time spawn failure retries on the next sweep |
| e.lastActive = a.now() |
| + // The max_run clock starts when the child does. Set for every entry |
| + // (it costs a field write) but read only for workers: an instance's |
| + // run length is bounded by its idle window and its members. |
| + e.spawnedAt = a.now() |
| // The build this child actually execed, captured at Start() inside the |
| // backend (not re-read here — a ship during the wake window would make a |
| // re-read name the new target while the process runs the old binary, and |
| @@ -648,13 +690,13 @@ func (a *Service) wake(e *actEntry) { |
| e.spawnVersion = w.spawnVersion |
| e.staleSince = time.Time{} |
| if a.OnWake != nil { |
| - // Fire-and-forget, off this goroutine: e.host is read once here, |
| + // Fire-and-forget, off this goroutine: e.route is read once here, |
| // before this goroutine can be reused for anything else — the |
| // hook must never share e.host's own goroutine's fate (a wedged |
| // bucket Put must not hold e.ready closed any longer than the |
| // wake itself already takes). |
| - host := e.host |
| - go a.OnWake(host) |
| + rt := e.route |
| + go a.OnWake(rt) |
| } |
| } |
| a.mu.Unlock() |
| @@ -673,7 +715,13 @@ type wokenInstance struct { |
| spawnVersion string // live/<host> target read at exec — for build convergence |
| } |
| |
| -func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance, error) { |
| +// doWake runs the side effects of one wake. It takes the ENTRY, not the bare |
| +// route, because two of its steps depend on what kind of child this is — the |
| +// spawn call and the co-spawn — and those facts (e.worker, e.workerToken) are |
| +// fixed at entry construction, before the wake goroutine starts, so reading |
| +// them here without a.mu is safe by construction. |
| +func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, error) { |
| + rt := e.route |
| host := rt.Host |
| |
| // 1. The lease first: everything after this line is done as the prefix's |
| @@ -720,7 +768,21 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance |
| // on boot, so waiting for the socket guarantees the file exists for |
| // litestream to open. Writes that land in the gap are picked up by the |
| // replica's first sync. |
| - proc, spawnVer, err := a.backend.Start(ctx, rt) |
| + // |
| + // A worker takes the other door: it binds nothing, so Start's health |
| + // probe has no socket to dial and would spend its whole timeout failing |
| + // the wake. StartWorker spawns and returns — readiness is the child's own |
| + // call over the agent socket, which is why the wake env below carries the |
| + // socket path and this wake's token. |
| + var ( |
| + proc Proc |
| + spawnVer string |
| + ) |
| + if e.worker { |
| + proc, spawnVer, err = a.backend.StartWorker(ctx, rt, a.workerEnv(e)) |
| + } else { |
| + proc, spawnVer, err = a.backend.Start(ctx, rt) |
| + } |
| if err != nil { |
| _ = lease.Release(ctx) |
| return nil, fmt.Errorf("start %s: %w", host, err) |
| @@ -746,6 +808,13 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance |
| // failure is loud but never fails the wake: members come before bots, and |
| // the sweeper retries with backoff. |
| w := &wokenInstance{rep: rep, proc: proc, spawnVersion: spawnVer} |
| + // A worker is nobody's parent: it IS a sidecar, so it never co-spawns one. |
| + // Skipped outright rather than left to HasSidecar's answer — the staging |
| + // file is keyed by host, and a host that happened to have one staged must |
| + // not turn a standalone sidecar into a supervisor. |
| + if e.worker { |
| + return w, nil |
| + } |
| if a.backend.HasSidecar(rt) { |
| w.sidecarWanted = true |
| if sc, err := a.backend.StartSidecar(ctx, rt); err != nil { |
| @@ -806,6 +875,193 @@ func (a *Service) Bounce(host string) bool { |
| return true |
| } |
| |
| +// --- standalone sidecars (workers) ------------------------------------------- |
| +// |
| +// A worker is a member of this same state machine with the request path |
| +// removed. Nothing routes to a sidecar — the edge refuses the row on kind, and |
| +// ServeHTTP gates on `Kind == "instance"` — so no request can ever wake one: |
| +// every wake arrives from the due-time sweep, an operator command, or the |
| +// agent's boot pass, and its only sign of life is contact over the agent |
| +// socket. beginRequest, BeginSidecarRequest and ServeHTTP are untouched. |
| + |
| +// newWorkerEntry builds the waking entry for a standalone sidecar: the route's |
| +// own ceilings (0 = platform default / no ceiling) and a freshly minted |
| +// per-wake token. Every field it sets is written BEFORE the entry is published |
| +// to a.entries and the wake goroutine starts, so the wake path may read them |
| +// without a.mu; nothing rewrites them afterwards. |
| +func (a *Service) newWorkerEntry(rt registry.Route) *actEntry { |
| + return &actEntry{ |
| + host: rt.Host, |
| + route: rt, |
| + state: actWaking, |
| + ready: make(chan struct{}), |
| + worker: true, |
| + workerIdle: time.Duration(rt.IdleSeconds) * time.Second, |
| + workerMaxRun: time.Duration(rt.MaxRunSeconds) * time.Second, |
| + workerToken: newWorkerToken(), |
| + } |
| +} |
| + |
| +// newWorkerToken mints the per-wake agent-socket credential. A fresh one every |
| +// wake is the point: a token from a previous incarnation of the child never |
| +// matches, so an orphan that survived a stop cannot keep talking to the agent |
| +// as though it were the live worker. A rand failure is fatal to the wake, not |
| +// silently downgraded — an empty token would authorize nothing (WorkerToken's |
| +// "" is the refusal), but a PREDICTABLE one would authorize everyone. |
| +func newWorkerToken() string { |
| + var b [32]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + // crypto/rand.Read never returns an error on any platform we ship to; |
| + // if it somehow did, an unguessable-token-less wake must not proceed. |
| + panic("activator: crypto/rand failed: " + err.Error()) |
| + } |
| + return hex.EncodeToString(b[:]) |
| +} |
| + |
| +// WakeWorker brings a sidecar up, or does nothing if it is already awake. |
| +// It is the request-less twin of beginRequest: a sidecar has no socket, so no |
| +// request can ever wake one — every wake arrives from the due-time sweep, an |
| +// operator command, or the agent's boot pass. |
| +// |
| +// The route arrives whole and is the only input: the idle window and the run |
| +// ceiling ride on the row, so no caller (and no sweep tick) needs a bucket |
| +// read to decide a worker has outrun its ceiling. |
| +// |
| +// Idempotent: concurrent and repeated calls coalesce on the ONE wake, exactly |
| +// as concurrent requests do — three calls produce one child. |
| +func (a *Service) WakeWorker(rt registry.Route) error { |
| + deadline := time.NewTimer(wakeWaitBound) |
| + defer deadline.Stop() |
| + for { |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + if e == nil { |
| + // Asleep → kick off ONE wake and FALL THROUGH to the switch still |
| + // holding the lock, waiting on the entry we just created. Unlocking |
| + // and re-reading a.entries[host] here is the #184 bug: a wake that |
| + // fails deletes its entry, so the initiator would find nothing and |
| + // start a second one. |
| + e = a.newWorkerEntry(rt) |
| + a.entries[rt.Host] = e |
| + go a.wake(e) |
| + } else if !e.worker { |
| + // A live entry for this host that is NOT a worker means the route |
| + // changed kind under a running instance (or two rows collided). |
| + // Refuse rather than adopt someone else's child: its proc, lease |
| + // and idle discipline are the instance path's. |
| + a.mu.Unlock() |
| + return fmt.Errorf("wake worker %s: an instance entry already holds this host", rt.Host) |
| + } |
| + |
| + switch e.state { |
| + case actAwake: |
| + a.mu.Unlock() |
| + return nil // already up: a no-op, not a second child |
| + |
| + case actWaking: |
| + ready := e.ready |
| + a.mu.Unlock() |
| + select { |
| + case <-ready: |
| + // A failed wake reports to every coalesced caller; retrying |
| + // here would turn one bad wake into a storm (beginRequest's |
| + // rule, for the same reason). |
| + return e.err |
| + case <-deadline.C: |
| + return errWakeTimeout |
| + } |
| + |
| + case actStopping: |
| + stopped := e.stopped |
| + a.mu.Unlock() |
| + select { |
| + case <-stopped: |
| + continue // entry is gone; loop wakes it fresh |
| + case <-deadline.C: |
| + return errWakeTimeout |
| + } |
| + } |
| + } |
| +} |
| + |
| +// workerEnv is what the wake hands the child: where to call back, who it is, |
| +// and this wake's credential. The backend applies it OVER the tenant's own env |
| +// file so a tenant cannot forge its own token (StartWorker's contract). |
| +func (a *Service) workerEnv(e *actEntry) map[string]string { |
| + sock := a.cfg.AgentSocket |
| + if sock == "" { |
| + sock = DefaultAgentSocket |
| + } |
| + name, _, _ := strings.Cut(e.host, ".") // the sidecar's declared name |
| + return map[string]string{ |
| + "CARLOS_AGENT_SOCKET": sock, |
| + "CARLOS_SIDECAR_TOKEN": e.workerToken, |
| + "CARLOS_SIDECAR_HOST": e.host, |
| + "CARLOS_SIDECAR_NAME": name, |
| + } |
| +} |
| + |
| +// NoteWorkerContact stamps last-activity from an agent-socket call. It is a |
| +// worker's only sign of life: nothing routes to a sidecar, so no request will |
| +// ever stamp one, and without contact the idle clock runs to its window and |
| +// the sweeper sleeps it. |
| +func (a *Service) NoteWorkerContact(host string) { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + if e := a.entries[host]; e != nil && e.worker { |
| + e.lastActive = a.now() |
| + } |
| +} |
| + |
| +// NoteWorkerReady records a worker's own "I am up" — last-activity plus the |
| +// build it reports running. 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.) |
| +func (a *Service) NoteWorkerReady(host, version string) { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + if e := a.entries[host]; e != nil && e.worker { |
| + e.lastActive = a.now() |
| + e.workerVersion = version |
| + } |
| +} |
| + |
| +// SetWorkerNextDue records a sidecar's next scheduled work, straight to the |
| +// due-time index — while the child is ALIVE and can say so. That is why the |
| +// stop path never probes a worker: the answer is already recorded, and a probe |
| +// of a process we are about to kill could only be worse. |
| +func (a *Service) SetWorkerNextDue(host string, at int64) error { |
| + return a.due.SetNextDue(host, at) |
| +} |
| + |
| +// SleepWorker stops an awake worker now — the /v1/idle call, "I have nothing |
| +// to do". It is a stop REQUESTED immediately rather than an idle timestamp |
| +// nudged backwards, so the ordinary stop path (park the DB, release the lease) |
| +// does the work. Anything other than an awake worker is a no-op: this must |
| +// never start something, and never touch an instance. |
| +func (a *Service) SleepWorker(host string) { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + if e := a.entries[host]; e != nil && e.worker && e.state == actAwake { |
| + emitEvent(a.Logs, e.route, "info", "sidecar reported idle — stopping") |
| + a.beginStopLocked(e) |
| + } |
| +} |
| + |
| +// WorkerToken reports the live per-wake token for host, or "" — the agent |
| +// socket's authorization check. A token from a previous wake never matches, |
| +// and a host that is asleep, waking, stopping or not a worker at all has no |
| +// token, so every such call is refused. |
| +func (a *Service) WorkerToken(host string) string { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + if e := a.entries[host]; e != nil && e.worker && e.state == actAwake { |
| + return e.workerToken |
| + } |
| + return "" |
| +} |
| + |
| // stop drives awake→asleep: stop the child (its SIGTERM drain is the final |
| // write), checkpoint what's on disk to S3 and release the lease, then forget |
| // the entry. The local DB stays in place — it's the warm cache for the next |
| @@ -815,7 +1071,11 @@ func (a *Service) stop(e *actEntry) { |
| // convergence cycling, bounce, shutdown all pass through here, so an |
| // "exited" line WITHOUT this line before it reads as what it is — a |
| // crash. |
| - emitEvent(a.Logs, e.route, "info", "instance stopping") |
| + stopping := "instance stopping" |
| + if e.worker { |
| + stopping = "sidecar stopping" |
| + } |
| + emitEvent(a.Logs, e.route, "info", stopping) |
| |
| // Co-stop, sidecar FIRST — before the next-due probe and the instance's |
| // own SIGTERM. Two reasons: the sidecar must never outlive its instance |
| @@ -845,23 +1105,33 @@ func (a *Service) stop(e *actEntry) { |
| // not cfg.Idle. That's accepted — a bounce is rare relative to request |
| // traffic, and each cycle is still a bounded SIGTERM drain, not a busy |
| // spin. |
| - dueCtx, cancelDue := context.WithTimeout(context.Background(), 5*time.Second) |
| - due, err := a.backend.NextDue(dueCtx, e.route) |
| - cancelDue() |
| - if err != nil { |
| - log.Printf("activator: next-due probe %s: %v — recording none (it sleeps until a request)", e.host, err) |
| - due = 0 |
| - } |
| - if err := a.due.SetNextDue(e.host, due); err != nil { |
| - log.Printf("activator: record next-due %s: %v", e.host, err) |
| - } else if due > 0 { |
| - log.Printf("activator: %s next scheduled delivery at %s — will wake for it", e.host, time.Unix(due, 0).UTC().Format(time.RFC3339)) |
| + // |
| + // A WORKER skips this step entirely — both the probe and the write. It has |
| + // no socket to answer on, and its next-due already arrived over the agent |
| + // socket while it was alive (SetWorkerNextDue wrote it straight to the |
| + // registry), which is strictly better than asking a process we are about |
| + // to kill. Writing here would be actively wrong: the probe would fail, and |
| + // the failure path records 0 — erasing the schedule that is a sleeping |
| + // sidecar's ONLY way back up, since nothing can dial one. |
| + if !e.worker { |
| + dueCtx, cancelDue := context.WithTimeout(context.Background(), 5*time.Second) |
| + due, err := a.backend.NextDue(dueCtx, e.route) |
| + cancelDue() |
| + if err != nil { |
| + log.Printf("activator: next-due probe %s: %v — recording none (it sleeps until a request)", e.host, err) |
| + due = 0 |
| + } |
| + if err := a.due.SetNextDue(e.host, due); err != nil { |
| + log.Printf("activator: record next-due %s: %v", e.host, err) |
| + } else if due > 0 { |
| + log.Printf("activator: %s next scheduled delivery at %s — will wake for it", e.host, time.Unix(due, 0).UTC().Format(time.RFC3339)) |
| + } |
| } |
| |
| // Separate budgets: a SIGTERM drain that runs long must not eat the final |
| // checkpoint's time — the S3 sync is the part that makes sleep durable. |
| procCtx, cancelProc := context.WithTimeout(context.Background(), actStopTimeout) |
| - err = e.proc.Stop(procCtx) |
| + err := e.proc.Stop(procCtx) |
| cancelProc() |
| if err != nil { |
| log.Printf("activator: stop %s: %v", e.host, err) |
| @@ -938,11 +1208,33 @@ func (a *Service) sweepOnce() { |
| a.beginStopLocked(e) |
| continue |
| } |
| - if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle { |
| + // The idle predicate is shared verbatim; only its WINDOW can differ. |
| + // A worker declares its own on its row (Task 1) so this sweep never |
| + // needs a bucket read to decide one has sat idle long enough; zero — |
| + // every instance, and any sidecar that declared nothing — falls back |
| + // to the platform default, so an instance's behaviour is unchanged. |
| + // inflight is always 0 for a worker (nothing routes to it) and |
| + // lastActive is stamped by agent-socket contact instead of by a |
| + // released request, so the same expression means the right thing for |
| + // both. |
| + idleWindow := a.cfg.Idle |
| + if e.workerIdle > 0 { |
| + idleWindow = e.workerIdle |
| + } |
| + if e.inflight == 0 && now.Sub(e.lastActive) >= idleWindow { |
| log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second)) |
| a.beginStopLocked(e) |
| continue |
| } |
| + // A worker that never reports idle still has a ceiling: a wedged |
| + // reconnect loop must not hold a lease and a box forever. Zero means |
| + // no ceiling — an always-on sidecar is a legitimate shape, so this |
| + // must never invent one nobody declared. |
| + if e.worker && e.workerMaxRun > 0 && now.Sub(e.spawnedAt) >= e.workerMaxRun { |
| + log.Printf("activator: %s hit its max_run of %s — stopping", e.host, e.workerMaxRun) |
| + a.beginStopLocked(e) |
| + continue |
| + } |
| // Converge onto a shipped build. A running child never re-reads |
| // live/<host>, so once a ship repoints the symlink this awake child is |
| // serving a stale binary. Idle hosts converge for free on their next |
| @@ -982,6 +1274,14 @@ func (a *Service) sweepOnce() { |
| // only hibernating hosts are activator children and their systemd |
| // sidecar units stay disabled — the staged sidecar has exactly one |
| // possible runner. |
| + // |
| + // Never for a worker: it is a sidecar itself, not a host that has one |
| + // (the same refusal doWake makes at wake time — restated here because |
| + // this adoption arm can fire on a long-awake entry that never went |
| + // through step 6 again). |
| + if e.worker { |
| + continue |
| + } |
| if !e.sidecarWanted && a.backend.HasSidecar(e.route) { |
| e.sidecarWanted = true |
| log.Printf("activator: sidecar staged for awake %s — adopting", e.host) |
| @@ -1035,7 +1335,17 @@ func (a *Service) sweepOnce() { |
| // (the lease would let exactly one win, but loudly, forever). Eleven: |
| // `if a.mesh != nil && !a.mesh.owns(rt.Host) { continue }`. |
| log.Printf("activator: %s has a scheduled delivery due — waking it", rt.Host) |
| - e := &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{})} |
| + // A sidecar row comes up as a WORKER (registry.DueInstances returns |
| + // both kinds, because the due-time index is a sidecar's only timer |
| + // wake source — nothing can dial one). Woken through the instance path |
| + // it would spawn via Start and spend the whole health-probe timeout |
| + // dialling a socket it never binds. |
| + var e *actEntry |
| + if rt.Kind == "sidecar" { |
| + e = a.newWorkerEntry(rt) |
| + } else { |
| + e = &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{})} |
| + } |
| a.entries[rt.Host] = e |
| go a.wake(e) |
| } |
| @@ -1059,6 +1369,16 @@ func (a *Service) RetireExcept(keep map[string]bool) { |
| if keep[e.host] { |
| continue |
| } |
| + // Workers are outside this reconcile's world entirely. `keep` is the |
| + // EDGE's managed set, built by edge.Reload — which refuses a sidecar |
| + // row on kind as the first act of its loop, so a worker's host can |
| + // never appear in it. Without this guard every edge reload (any route |
| + // change at all) would stop every running standalone sidecar. A |
| + // sidecar's removal authority belongs to the sidecar reconcile pass, |
| + // not to the edge's route map. |
| + if e.worker { |
| + continue |
| + } |
| switch e.state { |
| case actAwake: |
| log.Printf("activator: %s is no longer hibernate-managed — releasing", e.host) |