isolated exec: plan 3 final-review fixes (regular-file opens, fence before Acquire, stop retries, listener close after done, copy)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
20 files changed,
+699
−174
internal/activator/activator.go+102 −31internal/activator/activator_test.go+155 −27internal/activator/backend_exec.go+53 −43internal/activator/backend_unit.go+14 −0internal/activator/layout.go+18 −1internal/activator/maintenance_test.go+12 −12internal/activator/paths.go+8 −1internal/activator/prepare.go+3 −3internal/activator/unitproc.go+35 −16internal/activator/unitproc_test.go+65 −19internal/appconfig/syncexec.go+29 −6internal/appconfig/syncexec_test.go+76 −0internal/host/agent.go+11 −3internal/instancectl/client.go+5 −0internal/instancefs/instancefs.go+6 −0internal/instancefs/instancefs_linux.go+27 −3internal/instancefs/instancefs_linux_test.go+28 −0internal/lease/endpoint_test.go+40 −0internal/lease/lease.go+10 −0internal/schedule/runner.go+2 −9
CI passed — run details
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index b88df25d1..80f737ae2 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -322,6 +322,13 @@ type actEntry struct { |
| // proc.Exited() for every pendingStop entry and finishes the stop |
| // (finishPendingStop) the moment it's proven. |
| pendingStop bool |
| + // stopRetrying is true while a retried proc.Stop call for a still-running |
| + // pendingStop entry is in flight, so finishPendingStops does not launch a |
| + // second one on top of it. stopRetriedAt is when the last retry was |
| + // issued; finishPendingStops waits actStopTimeout between retries, the |
| + // same cadence proc.Stop itself is bounded by. |
| + stopRetrying bool |
| + stopRetriedAt time.Time |
| } |
| // Sidecar restart pacing — Restart=always semantics without a supervisor: the |
| @@ -335,18 +342,18 @@ const ( |
| ) |
| // sidecarStopTimeout serves two related bounds. First, its name: the |
| -// sidecar's own stop at sleep time — `sidecar run` has no drain handler, so |
| +// sidecar's own stop at sleep time. `sidecar run` has no drain handler, so |
| // the SIGTERM is effectively immediate, and the SIGKILL fallback behind this |
| // timeout is insurance. Second (design §6.7): how long stop() waits on |
| // sidecarStartDone for an in-flight sidecar restart to land before giving up |
| // and proceeding on whatever e.sidecar currently is. The restart goroutine's |
| // own entry-identity check makes that second wait purely a promptness |
| -// trade-off, never a correctness one — nothing is orphaned or misplaced |
| +// trade-off, never a correctness one. Nothing is orphaned or misplaced |
| // whether it fires in time or times out. |
| const sidecarStopTimeout = 5 * time.Second |
| // stopDiscardedSidecar stops a sidecar process a restart attempt returned |
| -// but could not install — the wake it belonged to moved on (stopped, or |
| +// but could not install. The wake it belonged to moved on (stopped, or |
| // replaced by a fresh wake of the same host) while the call was in flight. |
| // Bounded by sidecarStopTimeout so a wedged Stop can't leak the goroutine |
| // forever; fire-and-forget, since nothing is waiting on this beyond the |
| @@ -837,7 +844,7 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance |
| // actually running. Spawning against a stale DBPath/Socket would boot |
| // the instance onto the wrong place on disk or bind the wrong socket, so |
| // the wake re-reads the row it's actually about to spawn and carries |
| - // that forward — every step below reads rt, not the edge's copy. |
| + // that forward. Every step below reads rt, not the edge's copy. |
| if a.Routes != nil { |
| fresh, err := a.Routes.Get(rt.Host) |
| if err != nil { |
| @@ -857,6 +864,21 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance |
| a.mu.Unlock() |
| } |
| + // 0.5. Refuse a fenced or conflicting row before it ever reaches Acquire |
| + // or Restore (design §6.8). The in-Start/in-Seed refusal below still |
| + // exists as the second line of defense, but by the time either of those |
| + // runs the lease is already held and Restore has already written to |
| + // disk. For a fenced row that means restoring into a database a |
| + // migration journal says is being vacated, and for a conflict row it |
| + // means writing one host's database into another host's instance |
| + // directory. Checking here, on the row this wake just refreshed against, |
| + // closes both before either write happens. |
| + if c, ok := a.backend.(Classifier); ok { |
| + if layout, reason := c.Layout(rt); layout == LayoutFenced || layout == LayoutConflict { |
| + return w, fmt.Errorf("%s: refusing to wake: %s", rt.Host, reason) |
| + } |
| + } |
| + |
| // 1. The lease first: everything after this line is done as the prefix's |
| // single writer. Contention is not retried here — on one box it means a |
| // previous incarnation hasn't released; tell the client to come back after |
| @@ -905,7 +927,16 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance |
| if fresh && rt.Seed != "" { |
| w.stopOutcome.uncertain = true |
| if err := a.backend.Seed(ctx, rt); err != nil { |
| - w.stopOutcome.leaseReleased = lease.Release(ctx) == nil |
| + if errors.Is(err, ErrSeedUnproven) { |
| + // A SIGKILL survivor of the seed may still be writing the |
| + // database: releasing the lease here would be exactly the |
| + // write-after-release hazard the lease exists to prevent |
| + // (design §6.2), so it is left for its own TTL to expire, |
| + // the same way stop() leaves ErrStillRunning. |
| + log.Printf("activator: %s: %v; leaving the lease to its TTL", host, err) |
| + } else { |
| + w.stopOutcome.leaseReleased = lease.Release(ctx) == nil |
| + } |
| return w, fmt.Errorf("seed %s: %w", host, err) |
| } |
| w.stopOutcome.uncertain = false |
| @@ -1050,14 +1081,14 @@ func (a *Service) stop(e *actEntry) { |
| // A sidecar restart (sweepOnce) runs its backend.StartSidecar call off |
| // a.mu (design §6.7) and may still be in flight when a stop begins. Wait, |
| - // bounded by sidecarStopTimeout, for it to land — either it installs a |
| + // bounded by sidecarStopTimeout, for it to land: either it installs a |
| // fresh e.sidecar in time and the co-stop below stops that one directly, |
| // or it doesn't and the restart goroutine's own entry-identity check (it |
| // re-reads a.entries[host] on the far side of the call) finds this entry |
| // gone or stopping and stops whatever it started instead. Either way |
| // nothing is left orphaned; this wait only decides which of the two |
| // stops it. sidecarStartDone is closed by the restart goroutine when it |
| - // finishes — however it finishes — so this waits on that signal directly |
| + // finishes, however it finishes, so this waits on that signal directly |
| // rather than polling sidecarStarting on a timer. |
| a.mu.Lock() |
| startDone := e.sidecarStartDone |
| @@ -1123,13 +1154,19 @@ func (a *Service) stop(e *actEntry) { |
| // (design §6.3, codex R2-5): stop() bails out here rather than |
| // falling through to the checkpoint and lease release below, |
| // leaving the entry in stopping with pendingStop set. |
| - // sweepOnce polls proc.Exited() for it and calls finishStop — |
| - // the rest of what this function would have done — the moment |
| + // sweepOnce polls proc.Exited() for it and calls finishStop, |
| + // the rest of what this function would have done, the moment |
| // termination is actually proven. |
| log.Printf("activator: stop %s: %v; keeping the lease until termination is proven", e.host, err) |
| a.mu.Lock() |
| e.pendingStop = true |
| e.stopOutcome = outcome |
| + // The call just above is this entry's first stop attempt, and it |
| + // was already bounded by actStopTimeout. Recording it here is |
| + // what makes finishPendingStops' own cadence a true actStopTimeout |
| + // apart from THIS attempt, rather than firing a redundant retry |
| + // the moment pendingStop becomes visible to it. |
| + e.stopRetriedAt = a.now() |
| a.mu.Unlock() |
| return |
| } |
| @@ -1146,15 +1183,15 @@ func (a *Service) stop(e *actEntry) { |
| // finishPendingStop finishes a stop that was left pending because proc.Stop |
| // returned ErrStillRunning (design §6.3): now that proc.Exited() has proven |
| // termination, spec §6.3 calls for retrying proc.Stop once more before |
| -// declaring the main process closed — "sweepOnce retries proc.Stop on its |
| -// cadence until done closes" — so outcome.mainStop reflects an actual |
| -// observation rather than an assumption baked in at the ErrStillRunning |
| -// return. A unitProc that has already exited takes its done branch and |
| -// returns nil immediately; a childProc's Stop is a no-op on an |
| -// already-exited process either way, so retrying costs nothing once the |
| -// process is well and truly gone. mainExited is unconditionally true — the |
| -// exit that made this entry eligible for this pass IS that proof — whatever |
| -// this retried Stop call itself reports. |
| +// declaring the main process closed. Spec §6.3 says "sweepOnce retries |
| +// proc.Stop on its cadence until done closes", so outcome.mainStop reflects |
| +// an actual observation rather than an assumption baked in at the |
| +// ErrStillRunning return. A unitProc that has already exited takes its |
| +// done branch and returns nil immediately; a childProc's Stop is a no-op |
| +// on an already-exited process either way, so retrying costs nothing once |
| +// the process is well and truly gone. mainExited is unconditionally true: |
| +// the exit that made this entry eligible for this pass IS that proof, |
| +// whatever this retried Stop call itself reports. |
| func (a *Service) finishPendingStop(e *actEntry, outcome *stopObservation) { |
| ctx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| err := e.proc.Stop(ctx) |
| @@ -1169,27 +1206,61 @@ func (a *Service) finishPendingStop(e *actEntry, outcome *stopObservation) { |
| // finishPendingStops scans for entries left pending because proc.Stop |
| // returned ErrStillRunning (design §6.3, codex R2-5) and finishes every one |
| // whose proc has since exited, via finishPendingStop. Called from |
| -// sweepOnce's own tick and, once more, from Shutdown — the sweeper is |
| +// sweepOnce's own tick and, once more, from Shutdown. The sweeper is |
| // stopped by the time Shutdown runs, so without this second call a process |
| // that exits during shutdown would never get its final checkpoint. The |
| // caller holds a.mu. |
| func (a *Service) finishPendingStops() { |
| for _, e := range a.entries { |
| - if e.pendingStop && e.proc != nil && e.proc.Exited() { |
| + if !e.pendingStop || e.proc == nil { |
| + continue |
| + } |
| + if e.proc.Exited() { |
| e.pendingStop = false |
| outcome := e.stopOutcome |
| if outcome == nil { |
| outcome = &stopObservation{} |
| } |
| go a.finishPendingStop(e, outcome) |
| + continue |
| } |
| + // Still running: spec §6.3 says sweepOnce retries proc.Stop on its |
| + // own cadence until done closes, and that applies here too, not only |
| + // once termination has already been proven some other way. Without |
| + // this, a stop whose first Control.Stop was itself lost (the socket |
| + // refused, a 502 mid-roll) never asks again, and the entry sits in |
| + // stopping holding the lease until the agent restarts. |
| + if e.stopRetrying || a.now().Sub(e.stopRetriedAt) < actStopTimeout { |
| + continue |
| + } |
| + e.stopRetrying = true |
| + e.stopRetriedAt = a.now() |
| + go a.retryPendingStop(e) |
| + } |
| +} |
| + |
| +// retryPendingStop re-issues proc.Stop for a pendingStop entry that is |
| +// still running, off a.mu like finishPendingStop's own call. Unlike that |
| +// one, a return here proves nothing by itself. Done closing is still what |
| +// proves termination, and the next finishPendingStops pass is what notices |
| +// it and calls finishPendingStop. This only clears the in-flight guard so a |
| +// later pass can try again. |
| +func (a *Service) retryPendingStop(e *actEntry) { |
| + ctx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| + err := e.proc.Stop(ctx) |
| + cancel() |
| + if err != nil && !errors.Is(err, ErrStillRunning) { |
| + log.Printf("activator: retry stop %s: %v", e.host, err) |
| } |
| + a.mu.Lock() |
| + e.stopRetrying = false |
| + a.mu.Unlock() |
| } |
| // finishStop is stop()'s (and finishPendingStop's) tail: the final |
| // checkpoint, lease release, and forgetting the entry. Split out (design |
| // §6.3, codex R2-5) so a pendingStop entry's eventual finish shares it with |
| -// stop()'s own ordinary path — outcome carries forward whatever stop() had |
| +// stop()'s own ordinary path. outcome carries forward whatever stop() had |
| // already observed (the sidecar co-stop, the due-time probe) before it hit |
| // ErrStillRunning and returned, plus whatever finishPendingStop's own |
| // retried proc.Stop just added. |
| @@ -1278,7 +1349,7 @@ func (a *Service) sweepOnce() { |
| // ErrStillRunning (design §6.3, codex R2-5): termination wasn't proven |
| // at the time, so stop() kept the lease and returned without forgetting |
| // the entry. Checked before the idle pass below for the same reason it's |
| - // a separate call at all — a pendingStop entry is in actStopping, not |
| + // a separate call at all: a pendingStop entry is in actStopping, not |
| // actAwake, so the idle pass would never reach it. |
| a.finishPendingStops() |
| cycled := 0 // build-convergence cycles started this tick (rate-limited) |
| @@ -1378,12 +1449,12 @@ func (a *Service) sweepOnce() { |
| // attempt from launching while one is already in flight. |
| // |
| // The far side re-checks by ENTRY IDENTITY (e0, the *actEntry |
| - // itself), not a generation counter (codex review round 1, |
| - // Critical): a per-entry counter that starts at 0 and is bumped |
| - // once when an entry becomes awake reads 1 for every entry's |
| - // first wake, so it can never tell "this entry" apart from "a |
| - // completely different entry — for the same host, after a stop |
| - // and rewake — that also just had its first wake". A pointer |
| + // itself), not a generation counter: a per-entry counter that |
| + // starts at 0 and is bumped once when an entry becomes awake |
| + // reads 1 for every entry's first wake, so it can never tell |
| + // "this entry" apart from "a completely different entry, for |
| + // the same host, after a stop and rewake, that also just had |
| + // its first wake". A pointer |
| // comparison against a.entries[host] has no such collision: a |
| // fresh wake always allocates a brand new *actEntry (begin() and |
| // the due-wake path both do `&actEntry{...}`), so identity alone |
| @@ -1404,7 +1475,7 @@ func (a *Service) sweepOnce() { |
| a.mu.Lock() |
| defer a.mu.Unlock() |
| if a.entries[host] != e0 { |
| - // This host's map slot no longer holds e0 at all — |
| + // This host's map slot no longer holds e0 at all: |
| // forgotten (stopped and deleted) or replaced by an |
| // entirely different entry from a fresh wake. e0 is |
| // unreachable from here on, so there's nothing left |
| @@ -1417,7 +1488,7 @@ func (a *Service) sweepOnce() { |
| // Still e0: this attempt owns its sidecarStarting flag |
| // and clears it (and the done channel already closing |
| // via the defer above) unconditionally, regardless of |
| - // what state e0 is in now — a stop may have begun |
| + // what state e0 is in now: a stop may have begun |
| // (flipping e0 to actStopping) while the call was in |
| // flight, and stop()'s own wait on sidecarStartDone |
| // must not run the full sidecarStopTimeout for nothing. |
| @@ -1558,7 +1629,7 @@ func (a *Service) Shutdown(ctx context.Context) { |
| } |
| a.mu.Lock() |
| // The sweeper is stopped as of the line above, so nothing else will ever |
| - // poll a pendingStop entry (design §6.3) again on its own — run the same |
| + // poll a pendingStop entry (design §6.3) again on its own. Run the same |
| // pass once here, and again on the ticker below while waiting, so a |
| // process that finally exits during shutdown still gets its final |
| // checkpoint instead of being abandoned mid-drain. |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index a7045124d..54217d827 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -42,11 +42,11 @@ type fakeRep struct { |
| mu sync.Mutex |
| err error |
| closed bool |
| - // b, when set, is bumped on Close — production's Rep.Close "does the |
| + // b, when set, is bumped on Close. production's Rep.Close "does the |
| // final checkpoint/sync and releases the lease" (Rep's doc comment), so |
| // a successful Close is the fake's equivalent of the lease coming free |
| // once a wake has gotten past Replicate (before that point, the raw |
| - // Lease.Release calls below count it instead — the two are mutually |
| + // Lease.Release calls below count it instead: the two are mutually |
| // exclusive per wake, since Replicate spends the Lease). |
| b *fakeBackend |
| } |
| @@ -135,8 +135,8 @@ func (p *fakeProc) stopCalls() int { |
| return p.stopAttempts |
| } |
| -// exit marks the process as having died on its own (a crash, or — for a |
| -// stuck proc — the delayed termination Stop couldn't prove at the time). |
| +// exit marks the process as having died on its own (a crash, or, for a |
| +// stuck proc, the delayed termination Stop couldn't prove at the time). |
| func (p *fakeProc) exit() { |
| p.mu.Lock() |
| p.dead = true |
| @@ -209,11 +209,11 @@ type fakeBackend struct { |
| sidecarErr error |
| sidecarStarts int |
| sidecars []*fakeProc |
| - // sidecarGate, when non-nil, blocks StartSidecar() until closed — models |
| + // sidecarGate, when non-nil, blocks StartSidecar() until closed. Models |
| // a stalled restart call so a test can assert it doesn't hold a.mu. |
| sidecarGate chan struct{} |
| // sidecarStartEntered, when non-nil, is closed the first time |
| - // StartSidecar is entered (before it waits on sidecarGate) — lets a test |
| + // StartSidecar is entered (before it waits on sidecarGate). Lets a test |
| // wait for a restart's call to actually begin rather than sleeping a |
| // guessed duration. |
| sidecarStartEntered chan struct{} |
| @@ -227,9 +227,23 @@ type fakeBackend struct { |
| // ErrStillRunning until the test calls its exit() (design §6.3). |
| procStuck bool |
| // startedRoutes records the registry.Route Start() actually received, |
| - // per call — doWake's route-refresh assertion (design §6.8) needs to see |
| + // per call. doWake's route-refresh assertion (design §6.8) needs to see |
| // what the backend was actually handed, not what the caller passed in. |
| startedRoutes []registry.Route |
| + // layoutFn, when set, is what Layout reports: a test's way to exercise |
| + // doWake's pre-Acquire fence check without a real ExecBackend. Nil means |
| + // LayoutFlat, "": every test that never sets this field gets the |
| + // behavior it always had, since a flat layout never fences a wake. |
| + layoutFn func(rt registry.Route) (Layout, string) |
| +} |
| + |
| +// Layout is fakeBackend's Classifier implementation, always present so |
| +// doWake's type assertion succeeds; layoutFn (above) decides what it says. |
| +func (b *fakeBackend) Layout(rt registry.Route) (Layout, string) { |
| + if b.layoutFn == nil { |
| + return LayoutFlat, "" |
| + } |
| + return b.layoutFn(rt) |
| } |
| func (b *fakeBackend) record(ev string) { |
| @@ -502,7 +516,7 @@ func waitFor(t *testing.T, what string, cond func() bool) { |
| t.Fatalf("timed out waiting for %s", what) |
| } |
| -// sidecarSettled reports whether host has no sidecar restart in flight — the |
| +// sidecarSettled reports whether host has no sidecar restart in flight. The |
| // call runs off a.mu (design §6.7), so a test driving several restarts back |
| // to back (each expecting the previous one's count and install to have |
| // already landed) waits on this rather than racing sweepOnce's own, now much |
| @@ -893,6 +907,51 @@ func TestActivatorSeedFailureFailsWakeAndReleasesLease(t *testing.T) { |
| } |
| } |
| +// TestActivatorSeedUnprovenLeavesTheLeaseToItsTTL pins design §6.2: a seed |
| +// whose cgroup never emptied may still have a SIGKILL survivor writing the |
| +// database, so doWake must not release the lease over it the way it does |
| +// for every other seed failure (design §6.3's ErrStillRunning gets the same |
| +// treatment in stop()). |
| +func TestActivatorSeedUnprovenLeavesTheLeaseToItsTTL(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + b.restoreFresh = true |
| + b.seedErr = fmt.Errorf("seed for badseed.example.com: %w: processes remain after a successful seed: still running", ErrSeedUnproven) |
| + rt := testRoute("badseed.example.com") |
| + rt.Seed = `["seed"]` |
| + |
| + _, err := a.beginRequest(context.Background(), rt) |
| + if err == nil { |
| + t.Fatal("wake succeeded despite an unproven seed") |
| + } |
| + if !errors.Is(err, ErrSeedUnproven) { |
| + t.Fatalf("wake error = %v, want it to wrap ErrSeedUnproven", err) |
| + } |
| + if got := b.releaseCount(); got != 0 { |
| + t.Fatalf("lease releases = %d for an unproven seed, want exactly 0", got) |
| + } |
| +} |
| + |
| +// TestActivatorWakeRefusesFencedRowBeforeAcquire pins design §6.8: a fenced |
| +// row is refused at the top of doWake, before the lease is ever acquired, |
| +// not only inside Start after the lease is already held. |
| +func TestActivatorWakeRefusesFencedRowBeforeAcquire(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + const reason = "layout migration in flight" |
| + b.layoutFn = func(registry.Route) (Layout, string) { return LayoutFenced, reason } |
| + rt := testRoute("fenced.example.com") |
| + |
| + _, err := a.beginRequest(context.Background(), rt) |
| + if err == nil { |
| + t.Fatal("wake succeeded against a fenced row") |
| + } |
| + if !strings.Contains(err.Error(), reason) { |
| + t.Fatalf("wake error = %q, want it to carry the fence reason %q", err, reason) |
| + } |
| + if acquires, _, starts := b.counts(); acquires != 0 || starts != 0 { |
| + t.Fatalf("fenced row reached the backend (acquires=%d starts=%d), want neither", acquires, starts) |
| + } |
| +} |
| + |
| // The other two doWake bail-outs release the lease exactly once too — the |
| // property the seed step slots into the middle of. |
| func TestActivatorWakeFailuresReleaseLeaseOnce(t *testing.T) { |
| @@ -2130,7 +2189,7 @@ func TestActivatorSidecarFailureDoesNotFailWake(t *testing.T) { |
| // The sweeper retries: attempt 2 immediately (first retry), then gated by |
| // the doubling backoff; once the fault clears, a retry lands the sidecar. |
| // The restart call itself now runs off a.mu (design §6.7), so a fresh |
| - // attempt lands asynchronously — waitFor gives it a moment rather than |
| + // attempt lands asynchronously. waitFor gives it a moment rather than |
| // asserting on sweepOnce's own (now much shorter) return. |
| a.sweepOnce() |
| waitFor(t, "sidecar restart attempt 2 to land", func() bool { return b.sidecarStartCount() == 2 && a.sidecarSettled(route.Host) }) |
| @@ -2225,8 +2284,8 @@ func TestActivatorSidecarRestartBackoff(t *testing.T) { |
| } |
| // The restart call itself runs off a.mu (design §6.7): each waitFor below |
| - // gives the previous attempt's goroutine a moment to land — both the |
| - // count and the entry's sidecarStarting flag — before the next kill/sweep, |
| + // gives the previous attempt's goroutine a moment to land, both the |
| + // count and the entry's sidecarStarting flag, before the next kill/sweep, |
| // so a slow scheduler can't make this sequence's next sweepOnce see the |
| // prior attempt still "in flight" and skip its own. |
| host := "bots.example.com" |
| @@ -4139,7 +4198,7 @@ func TestStopDoesNotReleaseTheLeaseOverAnUnprovenStop(t *testing.T) { |
| // Drive the sweep from inside the wait itself: finishing a pendingStop |
| // entry now retries proc.Stop (design §6.3) on the sweeper's own |
| // cadence, so a single sweepOnce() right after exit() is a race against |
| - // exactly when the sweeper would next notice — polling sweepOnce() |
| + // exactly when the sweeper would next notice. Polling sweepOnce() |
| // alongside the release count removes that race instead of hoping one |
| // call lands after exit() is visible. |
| waitFor(t, "the lease to release once termination is proven", func() bool { |
| @@ -4148,6 +4207,75 @@ func TestStopDoesNotReleaseTheLeaseOverAnUnprovenStop(t *testing.T) { |
| }) |
| } |
| +// finishPendingStops must retry the stop itself while the process is still |
| +// running, not only once it has already exited (design §6.3: "sweepOnce |
| +// retries proc.Stop on its cadence until done closes"). Without the retry, a |
| +// stop whose first Control.Stop call itself failed would never ask again. |
| +func TestFinishPendingStopsRetriesTheStopWhileStillRunning(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Minute) |
| + b.procStuck = true // Stop reports ErrStillRunning until exit(), and this proc never exits here |
| + rt := testRoute("stuck.example.com") |
| + arrangeAwake(t, a, rt) |
| + |
| + clk.advance(2 * time.Minute) |
| + a.sweepOnce() // idle: stop() runs, Stop reports ErrStillRunning, entry goes pendingStop |
| + p := b.lastProc() |
| + waitFor(t, "the first stop attempt", func() bool { return p.stopCalls() >= 1 }) |
| + |
| + // Immediately after: no retry yet, the entry's own actStopTimeout budget |
| + // has not passed since the first attempt. |
| + a.sweepOnce() |
| + if n := p.stopCalls(); n != 1 { |
| + t.Fatalf("stop retried before actStopTimeout elapsed (stopCalls=%d)", n) |
| + } |
| + |
| + clk.advance(actStopTimeout + time.Second) |
| + waitFor(t, "a second stop attempt once actStopTimeout has passed", func() bool { |
| + a.sweepOnce() |
| + return p.stopCalls() >= 2 |
| + }) |
| + if b.releaseCount() != 0 { |
| + t.Fatal("lease released while the process still reports running") |
| + } |
| + if a.entryCount() != 1 { |
| + t.Fatal("entry forgotten while the process still reports running") |
| + } |
| +} |
| + |
| +// The deferred Shutdown drain (ledger T5): a pendingStop entry whose process |
| +// finally exits DURING Shutdown still gets its final checkpoint and lease |
| +// release before Shutdown returns, via the same finishPendingStops pass |
| +// Shutdown's own drain loop re-runs on its poll ticker. |
| +func TestShutdownDrainsAPendingStopThatExitsMidShutdown(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Minute) |
| + b.procStuck = true |
| + rt := testRoute("shutdown-pending.example.com") |
| + arrangeAwake(t, a, rt) |
| + |
| + clk.advance(2 * time.Minute) |
| + a.sweepOnce() // stop() runs, Stop reports ErrStillRunning, entry goes pendingStop |
| + p := b.lastProc() |
| + waitFor(t, "the stop to be attempted", func() bool { return p.stopCalls() >= 1 }) |
| + |
| + // The process exits on its own shortly after Shutdown begins draining. |
| + // Shutdown's 100ms poll ticker is what has to notice it, not this test. |
| + go func() { |
| + time.Sleep(150 * time.Millisecond) |
| + p.exit() |
| + }() |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + a.Shutdown(ctx) |
| + |
| + if got := b.releaseCount(); got != 1 { |
| + t.Fatalf("lease releases after Shutdown = %d, want exactly 1", got) |
| + } |
| + if a.entryCount() != 0 { |
| + t.Fatal("Shutdown returned with a pending-stop entry still on the books") |
| + } |
| +} |
| + |
| // The sidecar restart must not hold the activator mutex across the start |
| // call (design §6.7, codex R2-6): a stalled sidecar start does not block |
| // another host's wake. |
| @@ -4155,7 +4283,7 @@ func TestSidecarRestartDoesNotHoldTheLockAcrossTheStart(t *testing.T) { |
| a, b, clk, _ := testActivator(t, time.Hour) |
| b.sidecarStaged = true |
| - // wake a.example.com first, ungated — the gate below is for the |
| + // wake a.example.com first, ungated. The gate below is for the |
| // sweeper's RESTART call, not this initial co-spawn. |
| arrangeAwake(t, a, testRoute("a.example.com")) |
| b.lastSidecar().exit() // the sweeper now wants a restart |
| @@ -4169,7 +4297,7 @@ func TestSidecarRestartDoesNotHoldTheLockAcrossTheStart(t *testing.T) { |
| clk.advance(time.Minute) |
| a.sweepOnce() // launches the restart; it blocks inside StartSidecar on gate |
| <-entered // wait for the restart's call to actually begin, not a guessed sleep |
| - // host b has no sidecar of its own — HasSidecar false keeps its wake's |
| + // host b has no sidecar of its own. HasSidecar false keeps its wake's |
| // own co-spawn (unrelated to what's under test) off the same gate. |
| b.mu.Lock() |
| b.sidecarStaged = false |
| @@ -4216,22 +4344,22 @@ func TestDoWakeRefreshesTheRouteFromTheRegistry(t *testing.T) { |
| } |
| // A restart whose StartSidecar call is still stalled must never install |
| -// onto a DIFFERENT wake of the same host once it finally returns (codex |
| -// review round 1, Critical). The bug it regression-tests: the discard check |
| -// used to compare a per-entry generation counter that is always 1 on an |
| -// entry's first wake (entries are never reused, so nothing ever bumps it a |
| -// second time), which meant it could never tell "this entry" apart from "a |
| -// completely different entry — for the same host, after a stop and rewake — |
| -// that also just had its own first wake". A stale restart could install its |
| -// result straight over a fresh wake's own sidecar. The fix (this test's |
| -// assertion) compares entry identity: the *actEntry pointer captured when |
| -// the restart launched, not a counter on it. |
| +// onto a DIFFERENT wake of the same host once it finally returns. The bug |
| +// it regression-tests: a discard check that compares a per-entry |
| +// generation counter is always 1 on an entry's first wake (entries are |
| +// never reused, so nothing ever bumps it a second time), which means it |
| +// can never tell "this entry" apart from "a completely different entry, |
| +// for the same host, after a stop and rewake, that also just had its own |
| +// first wake". A stale restart could install its result straight over a |
| +// fresh wake's own sidecar. The fix (this test's assertion) compares entry |
| +// identity: the *actEntry pointer captured when the restart launched, not |
| +// a counter on it. |
| // |
| // This test legitimately costs several seconds of wall time: stop() itself |
| // waits on the in-flight restart, bounded by sidecarStopTimeout, before it |
| // can finish taking the old entry down (that wait is a separate, already |
| -// covered property — see TestSidecarRestartDoesNotHoldTheLockAcrossTheStart |
| -// and stop()'s own doc comment) — the file has no seam to shrink |
| +// covered property, see TestSidecarRestartDoesNotHoldTheLockAcrossTheStart |
| +// and stop()'s own doc comment). The file has no seam to shrink |
| // sidecarStopTimeout for tests, so this accepts the wall time rather than |
| // adding one for a single regression test. |
| func TestStaleSidecarRestartNeverInstallsOntoAFreshWake(t *testing.T) { |
| @@ -4262,7 +4390,7 @@ func TestStaleSidecarRestartNeverInstallsOntoAFreshWake(t *testing.T) { |
| t.Fatal("Bounce'd entry never finished stopping (the stalled restart wedged the stop past its bound)") |
| } |
| - // A fresh wake of the same host gets its own sidecar — this is what the |
| + // A fresh wake of the same host gets its own sidecar. This is what the |
| // stale restart, still stalled, must not be allowed to overwrite. |
| b.mu.Lock() |
| b.sidecarGate = nil // the fresh wake's own co-spawn must not block on it too |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 77b0eca5c..8e78e615e 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -35,6 +35,14 @@ import ( |
| // socket and answer. A var so tests can shrink the failure path. |
| var spawnHealthTimeout = 30 * time.Second |
| +// ErrSeedUnproven is seedUnit's answer when a seed's cgroup never reports |
| +// empty (design §6.2): only a SIGKILL survivor reaches this, and while its |
| +// cgroup stays populated it may still be writing the database, so the |
| +// caller must treat this the way stop() treats ErrStillRunning and leave |
| +// the lease for its own TTL to expire rather than release it over a write |
| +// that might still be happening. |
| +var ErrSeedUnproven = errors.New("activator: seed unproven; processes remain") |
| + |
| // quarantineSuffix names where a restore that failed its integrity check is |
| // parked. It deliberately does NOT end in ".db": the box-wide litestream |
| // generator globs <data>/*.db, and a quarantined corpse must never be picked |
| @@ -108,12 +116,15 @@ type ExecBackend struct { |
| // feature — binds the control socket without those verbs, so an app that |
| // asks gets 404 rather than a socket that isn't there. |
| Timers schedule.TimerStore |
| - // Layout supplies the box facts the classification of §8.1 needs beyond |
| - // the row: journals and peers. Zero value: never fenced, never conflict. |
| - Layout LayoutInputs |
| + // LayoutFacts supplies the box facts the classification of §8.1 needs |
| + // beyond the row: journals and peers. Zero value: never fenced, never |
| + // conflict. Named apart from the Layout method (the Classifier |
| + // interface's Layout(rt) (Layout, string)) so a struct field and a |
| + // method can coexist on the same type. |
| + LayoutFacts LayoutInputs |
| // Control is the root instance-control channel a nested wake talks to |
| // (design §5.3). Nil refuses every nested wake with "instance control |
| - // channel not configured" rather than dialing nothing — a flat or |
| + // channel not configured" rather than dialing nothing. A flat or |
| // foreign host never needs it, and a nil Control does not touch them. |
| Control Control |
| // UnitState answers `systemctl show` for the observer (design §6.5). |
| @@ -374,7 +385,7 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| } |
| // seedArgv parses rt.Seed's JSON argv array and appends the --db flag, the |
| -// composition both spawn paths need identically (design §6.2) — an empty |
| +// composition both spawn paths need identically (design §6.2). An empty |
| // declared seed (an empty array, len 0) returns a nil argv, which both |
| // callers treat as "nothing to run" (doWake only calls Seed for a |
| // non-empty rt.Seed; this stays defensive rather than assuming that). |
| @@ -392,7 +403,7 @@ func seedArgv(rt registry.Route, dbPath string) ([]string, error) { |
| } |
| // removeSeedDB deletes a failed seed's own half-built database and its |
| -// WAL/SHM siblings — the cleanup both spawn paths run when, and only when, |
| +// WAL/SHM siblings: the cleanup both spawn paths run when, and only when, |
| // seedOwnsDB says the database is the seed's own work (seedCreatedTheDB). |
| // Shared so the two paths cannot drift on which files "the database" means. |
| func removeSeedDB(dbPath string) { |
| @@ -563,9 +574,9 @@ func (c *capBuffer) String() string { |
| return string(c.b) |
| } |
| -// seedUnit is Seed's nested body: the same contract as seedChild — the |
| -// merged env, the CARLOS_ADMIN_TOKEN mint, the cleanup on a failure this |
| -// call itself caused — run through the root instance-control channel |
| +// seedUnit is Seed's nested body: the same contract as seedChild, namely |
| +// the merged env, the CARLOS_ADMIN_TOKEN mint, and the cleanup on a failure |
| +// this call itself caused, run through the root instance-control channel |
| // instead of a direct exec (design §6.2). Argv and env come from the same |
| // seedArgv and withoutVars calls seedChild uses, so the two cannot drift. |
| func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route) error { |
| @@ -595,14 +606,14 @@ func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route) error { |
| return err |
| } |
| // Whether the database existed BEFORE the seed ran decides whether a |
| - // failed seed may clean up after itself below — seedChild's rule, |
| + // failed seed may clean up after itself below, seedChild's rule, |
| // unchanged for the unit path. |
| _, statErr := os.Stat(dbPath) |
| seedOwnsDB := seedCreatedTheDB(statErr) |
| log.Printf("activator: seeding %s: %s", host, strings.Join(argv, " ")) |
| - // The client already bounds a seed call at instancectl.DeadlineSeed — |
| - // wrapping ctx any tighter here (seedTimeout, at 2m, is shorter) would |
| + // The client already bounds a seed call at instancectl.DeadlineSeed. |
| + // Wrapping ctx any tighter here (seedTimeout, at 2m, is shorter) would |
| // guarantee the failure path on every seed that legitimately runs |
| // longer than that. Wrapping at the SAME deadline is redundant with the |
| // client's own timeout but harmless, and keeps this call visibly |
| @@ -624,19 +635,19 @@ func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route) error { |
| emptyErr := b.waitSeedCgroupEmpty(wctx, rt) |
| wcancel() |
| if emptyErr != nil { |
| - return fmt.Errorf("seed for %s: processes remain after a successful seed: %w", host, emptyErr) |
| + return fmt.Errorf("seed for %s: %w: processes remain after a successful seed: %v", host, ErrSeedUnproven, emptyErr) |
| } |
| log.Printf("activator: seeded %s", host) |
| return nil |
| } |
| -// seedUnitFailed is seedUnit's shared failure tail (controller ruling, fix |
| -// round 1): on EITHER failure shape — Control.Seed itself errored, or it |
| -// answered with a non-success result — the seed unit is stopped and its |
| -// cgroup is proven empty BEFORE any database cleanup runs, never after. |
| -// The earlier ordering ran the cleanup on the bare result, which could race |
| -// a seed process SIGKILL hadn't actually reaped yet — exactly the class of |
| -// bug Stop's own ErrStillRunning discipline exists to rule out elsewhere. |
| +// seedUnitFailed is seedUnit's shared failure tail: on EITHER failure |
| +// shape, whether Control.Seed itself errored or it answered with a |
| +// non-success result, the seed unit is stopped and its cgroup is proven |
| +// empty BEFORE any database cleanup runs, never after. Running the cleanup |
| +// on the bare result instead could race a seed process SIGKILL hadn't |
| +// actually reaped yet, exactly the class of bug Stop's own ErrStillRunning |
| +// discipline exists to rule out elsewhere. |
| // removeSeedDB runs only when seedOwnsDB AND the wait proved the cgroup |
| // empty; when it did not, nothing is removed and the returned error says |
| // so, because a database is not safe to delete out from under a process |
| @@ -654,7 +665,7 @@ func (b *ExecBackend) seedUnitFailed(ctx context.Context, rt registry.Route, dbP |
| emptyErr := b.waitSeedCgroupEmpty(wctx, rt) |
| wcancel() |
| if emptyErr != nil { |
| - return fmt.Errorf("%w (the seed's processes remain after stopping it; its database was left in place: %v)", cause, emptyErr) |
| + return fmt.Errorf("%w: %w (the seed's processes remain after stopping it; its database was left in place: %v)", cause, ErrSeedUnproven, emptyErr) |
| } |
| if seedOwnsDB { |
| removeSeedDB(dbPath) |
| @@ -945,13 +956,13 @@ func (b *ExecBackend) startChild(ctx context.Context, rt registry.Route) (Proc, |
| return p, spawnVer, nil |
| } |
| -// agentOwnedVars composes the child's agent-owned environment variables — |
| -// the control socket, the purge channel, the region — on top of fileEnv, |
| -// exactly as startChild did inline before the unit spawn path needed the |
| -// same composition. Extracted so the two paths cannot drift (design §7.2): |
| -// haveControl is whether this call's control listener actually bound |
| -// (childVars must follow the OUTCOME of the bind, not the intention — see |
| -// the long comment this replaced, still true here). |
| +// agentOwnedVars composes the child's agent-owned environment variables, |
| +// namely the control socket, the purge channel, and the region, on top of |
| +// fileEnv, exactly as startChild did inline before the unit spawn path |
| +// needed the same composition. Extracted so the two paths cannot drift |
| +// (design §7.2): haveControl is whether this call's control listener |
| +// actually bound. childVars must follow the OUTCOME of the bind, not the |
| +// intention, per the long comment this replaced, still true here. |
| func (b *ExecBackend) agentOwnedVars(rt registry.Route, fileEnv map[string]string, haveControl bool) map[string]string { |
| childVars := fileEnv |
| if haveControl { |
| @@ -973,24 +984,24 @@ func (b *ExecBackend) agentOwnedVars(rt registry.Route, fileEnv map[string]strin |
| } |
| // normalizeSocketMode is the PLATFORM's 0660 on a spawned instance's socket |
| -// (issue #103), not the app's — extracted from startChild so the unit path |
| +// (issue #103), not the app's. Extracted from startChild so the unit path |
| // (whose own bind is the trampoline's, not this process's exec) applies the |
| // identical rule rather than a second copy that can drift. So a deployment's |
| -// enrolled cross-user consumers (the carlos-sockets group — group-inherited |
| +// enrolled cross-user consumers (the carlos-sockets group, group-inherited |
| // from the setgid /run/carlos, see host.tmpfilesConf) can dial, no matter |
| -// what the child's umask or its own post-bind chmod produced — titogo |
| +// what the child's umask or its own post-bind chmod produced. titogo |
| // tightened its socket to 0600 and stranded the sibling router that proxies |
| // to it. Called after the health wait, so the app's own bind/chmod has |
| // already happened and this wins. |
| // |
| // The two guards make "only a socket THIS backend's child bound" true |
| // rather than merely intended. waitHealthy tolerates a squatter (its own |
| -// contract — it only dials), so a child that lost the bind race with a |
| +// contract: it only dials), so a child that lost the bind race with a |
| // stray supervisor (EADDRINUSE, exits) can still see the health wait |
| // succeed off the SQUATTER's answers; a dead child means the socket is |
| // not ours to widen (that route is the #17 external-socket advisory's |
| // territory). The Lstat keeps the chmod from ever following a |
| -// non-socket — a symlink planted at the route's path must not become a |
| +// non-socket: a symlink planted at the route's path must not become a |
| // mode change somewhere else. On a box with no consumers group the |
| // socket's group is just `carlos`, so the wider mode grants nobody |
| // anything. All three branches log and never fail the wake: a box with |
| @@ -1010,7 +1021,7 @@ func (b *ExecBackend) normalizeSocketMode(rt registry.Route, proc Proc) { |
| // startUnit is Start's nested body (design §6): the same admin token, env |
| // composition and health gate as startChild, but the process itself is |
| // started by the root instance-control channel from a launch file rather |
| -// than exec'd directly — the agent has no privilege to enter the tenant's |
| +// than exec'd directly. The agent has no privilege to enter the tenant's |
| // namespace, only the control service does. |
| func (b *ExecBackend) startUnit(ctx context.Context, rt registry.Route) (Proc, string, error) { |
| if err := b.nestedGate(rt); err != nil { |
| @@ -1065,12 +1076,12 @@ func (b *ExecBackend) startUnit(ctx context.Context, rt registry.Route) (Proc, s |
| // this here rather than waiting for it to happen inside up.Stop |
| // means the observer can never see a foreign invocation appear and |
| // fire its own redundant stop for a unit we already know we are |
| - // about to stop (controller ruling, fix round 1). |
| + // about to stop. |
| up.stopRequested.Store(true) |
| stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| defer cancel() |
| if serr := up.Stop(stopCtx); serr != nil { |
| - // The health error, not this one, is the wake's failure — the |
| + // The health error, not this one, is the wake's failure. The |
| // observer stands down on its own once the stop actually |
| // lands (stopRequested above), so an ErrStillRunning here |
| // just means it had not landed yet within actStopTimeout, not |
| @@ -1083,12 +1094,11 @@ func (b *ExecBackend) startUnit(ctx context.Context, rt registry.Route) (Proc, s |
| return up, spawnVer, nil |
| } |
| -// nestedGate is the pre-flight check every nested spawn path runs first |
| -// (controller ruling, fix round 1): no root instance-control channel |
| -// configured, or the box itself is not fit to run an isolated instance |
| -// (NestedOK's own mounts and /ready check), refuses before anything else |
| -// runs. Shared so seedUnit and startSidecarUnit gate on NestedOK exactly |
| -// like startUnit does — before this ruling, only startUnit checked it. |
| +// nestedGate is the pre-flight check every nested spawn path runs first: no |
| +// root instance-control channel configured, or the box itself is not fit |
| +// to run an isolated instance (NestedOK's own mounts and /ready check), |
| +// refuses before anything else runs. Shared so seedUnit and |
| +// startSidecarUnit gate on NestedOK exactly like startUnit does. |
| func (b *ExecBackend) nestedGate(rt registry.Route) error { |
| if b.Control == nil { |
| return fmt.Errorf("%s: instance control channel not configured", rt.Host) |
| @@ -1279,7 +1289,7 @@ func (b *ExecBackend) startSidecarChild(_ context.Context, rt registry.Route) (P |
| } |
| // startSidecarUnit is StartSidecar's nested body: a sidecar has no health |
| -// wait either way, and no control listener of its own — sidecar env is a |
| +// wait either way, and no control listener of its own. Sidecar env is a |
| // single layer, read straight from its own file, never merged with the |
| // instance's two-layer config. |
| func (b *ExecBackend) startSidecarUnit(ctx context.Context, rt registry.Route) (Proc, error) { |
diff --git a/internal/activator/backend_unit.go b/internal/activator/backend_unit.go| index 4969187c8..d1a7ddd19 100644 |
| --- a/internal/activator/backend_unit.go |
| +++ b/internal/activator/backend_unit.go |
| @@ -306,6 +306,20 @@ func (b *SplitBackend) NextDue(ctx context.Context, rt registry.Route) (int64, e |
| return b.pick(rt).NextDue(ctx, rt) |
| } |
| func (b *SplitBackend) HasSidecar(rt registry.Route) bool { return b.pick(rt).HasSidecar(rt) } |
| + |
| +// Layout implements Classifier for the composed backend: a unit-backed row |
| +// is always today's flat unit shape (the exec-only layouts do not apply to |
| +// it), and an exec row defers to the exec side's own classification. |
| +func (b *SplitBackend) Layout(rt registry.Route) (Layout, string) { |
| + if rt.Backing == registry.BackingUnit { |
| + return LayoutFlat, "" |
| + } |
| + if c, ok := b.Exec.(Classifier); ok { |
| + return c.Layout(rt) |
| + } |
| + return LayoutFlat, "" |
| +} |
| + |
| func (b *SplitBackend) StartSidecar(ctx context.Context, rt registry.Route) (Proc, error) { |
| return b.pick(rt).StartSidecar(ctx, rt) |
| } |
diff --git a/internal/activator/layout.go b/internal/activator/layout.go| index 4efc111f6..61f9ddd85 100644 |
| --- a/internal/activator/layout.go |
| +++ b/internal/activator/layout.go |
| @@ -5,6 +5,7 @@ package activator |
| import ( |
| "fmt" |
| + "log" |
| "path/filepath" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -63,6 +64,15 @@ func ActivatorManaged(rt registry.Route) bool { |
| return rt.Kind == "instance" && rt.Hibernate && rt.Socket != "" && rt.Backing == "" && rt.Tunnel == "" |
| } |
| +// Classifier lets doWake ask a backend for a route's layout before it does |
| +// anything the layout might forbid: acquiring the lease or restoring a |
| +// database. A backend that does not implement it (a test double, say) is |
| +// simply not asked, and the in-Start/in-Seed refusal still catches a fenced |
| +// or conflicting row after the fact. |
| +type Classifier interface { |
| + Layout(rt registry.Route) (Layout, string) |
| +} |
| + |
| // Classify returns the layout and, for anything but flat and nested, the |
| // reason a member or operator sees. |
| func Classify(rt registry.Route, in LayoutInputs) (Layout, string) { |
| @@ -75,7 +85,14 @@ func Classify(rt registry.Route, in LayoutInputs) (Layout, string) { |
| same = func(a, b string) bool { return provision.InstanceID(a) == provision.InstanceID(b) } |
| } |
| peers, err := in.Peers() |
| - if err == nil { |
| + if err != nil { |
| + // Fail open, not the fenced/conflict refusal itself: a registry |
| + // read that fails here would otherwise silently skip the |
| + // conflict check with no trace that it never ran. Loud, so a |
| + // registry problem during a wake shows up in carlos logs rather |
| + // than only in a collision nobody could explain later. |
| + log.Printf("activator: %s: peer list for conflict check: %v (proceeding)", rt.Host, err) |
| + } else { |
| for _, p := range peers { |
| if p.Host != rt.Host && ActivatorManaged(p) && same(p.Host, rt.Host) { |
| return LayoutConflict, fmt.Sprintf("instance identifier collides with %s; neither host wakes until one is removed", p.Host) |
diff --git a/internal/activator/maintenance_test.go b/internal/activator/maintenance_test.go| index 90f2196b6..8a78ed15f 100644 |
| --- a/internal/activator/maintenance_test.go |
| +++ b/internal/activator/maintenance_test.go |
| @@ -380,16 +380,16 @@ func TestMaintenanceFailedBackendStartRemainsUnproven(t *testing.T) { |
| } |
| } |
| -// Before design §6.7 (codex R2-6) this restart ran synchronously under a.mu, |
| -// so a fence attempt that raced a stalled StartSidecar found the lock held |
| -// and came back ErrFenceNotInstalled without ever touching the entry. Task 5 |
| -// moved the call off a.mu specifically so a stalled restart can't block |
| -// anything else — which includes FenceAndStop: it now installs its fence |
| -// immediately. What still bounds a short-context attempt is stop()'s own |
| -// wait (sidecarStopTimeout) for the stalled restart to land before it can |
| -// act on e.sidecar — so the outcome changes (ErrStopUnproven, fence |
| -// installed) but a stalled sidecar still keeps a checked stop from |
| -// completing instantly, which is the property this test exists to cover. |
| +// Before design §6.7 this restart ran synchronously under a.mu, so a fence |
| +// attempt that raced a stalled StartSidecar found the lock held and came |
| +// back ErrFenceNotInstalled without ever touching the entry. Moving the |
| +// call off a.mu means a stalled restart can't block anything else, |
| +// including FenceAndStop: it now installs its fence immediately. What |
| +// still bounds a short-context attempt is stop()'s own wait |
| +// (sidecarStopTimeout) for the stalled restart to land before it can act |
| +// on e.sidecar, so the outcome changes (ErrStopUnproven, fence installed) |
| +// but a stalled sidecar still keeps a checked stop from completing |
| +// instantly, which is the property this test exists to cover. |
| func TestMaintenanceFenceSerializesSidecarSweepReplacement(t *testing.T) { |
| a, e, _, side, rep := maintenanceAwake(t) |
| gate, entered := make(chan struct{}), make(chan struct{}) |
| @@ -399,7 +399,7 @@ func TestMaintenanceFenceSerializesSidecarSweepReplacement(t *testing.T) { |
| a.backend = &maintenanceBackend{fakeBackend: base, side: next, sideGate: gate, sideEntered: entered, rep: rep} |
| side.exited.Store(true) |
| e.sidecarWanted = true |
| - // sweepOnce itself now returns immediately — the restart it launches |
| + // sweepOnce itself now returns immediately. The restart it launches |
| // runs in its own goroutine, which is what's actually gated below. |
| a.sweepOnce() |
| <-entered |
| @@ -529,7 +529,7 @@ func TestMaintenanceFailedSidecarHandlesRemainOwned(t *testing.T) { |
| old.exited.Store(true) |
| a.entries[rt.Host] = &actEntry{host: rt.Host, route: rt, state: actAwake, proc: main, rep: rep, sidecar: old, sidecarWanted: true, lastActive: a.now()} |
| a.sweepOnce() |
| - // The restart call runs off a.mu (design §6.7) — give it a |
| + // The restart call runs off a.mu (design §6.7); give it a |
| // moment to land before inspecting what it installed. |
| waitFor(t, "sidecar restart to land", func() bool { return base.sidecarStartCount() == 1 && a.sidecarSettled(rt.Host) }) |
| } |
diff --git a/internal/activator/paths.go b/internal/activator/paths.go| index 00d207288..a3901caec 100644 |
| --- a/internal/activator/paths.go |
| +++ b/internal/activator/paths.go |
| @@ -23,6 +23,13 @@ type instancePaths struct { |
| configEnv string |
| } |
| +// Layout is ExecBackend's Classifier implementation: the same |
| +// classification pathsFor already computes, exposed so doWake can check it |
| +// before Acquire or Restore ever run. |
| +func (b *ExecBackend) Layout(rt registry.Route) (Layout, string) { |
| + return Classify(rt, b.layoutInputs()) |
| +} |
| + |
| func (b *ExecBackend) pathsFor(rt registry.Route) instancePaths { |
| l, reason := Classify(rt, b.layoutInputs()) |
| p := instancePaths{layout: l, reason: reason, dataDir: b.DataDir, runDir: provision.SocketDir} |
| @@ -51,7 +58,7 @@ func (b *ExecBackend) pathsFor(rt registry.Route) instancePaths { |
| // against, so a route built against the override dir still classifies as |
| // nested through Task 2's sockets() helper. |
| func (b *ExecBackend) layoutInputs() LayoutInputs { |
| - in := b.Layout |
| + in := b.LayoutFacts |
| in.DataDir = b.DataDir |
| if b.RunDirOverride != "" { |
| in.SocketDir = b.RunDirOverride |
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go| index 25cbad8f9..c776ae26d 100644 |
| --- a/internal/activator/prepare.go |
| +++ b/internal/activator/prepare.go |
| @@ -73,7 +73,7 @@ func (b *ExecBackend) prepare(rt registry.Route, role instancectl.Role) (instanc |
| if role == instancectl.RoleRun { |
| // Nothing is running: either this is the first invocation of this |
| // role for this host, or the unit is not active (Start would not |
| - // have reached prepare otherwise) — so any file at rt.Socket is a |
| + // have reached prepare otherwise), so any file at rt.Socket is a |
| // leftover from an earlier invocation, most likely one that was |
| // SIGKILLed before its own bind was ever established. On the |
| // nested path the agent itself never binds this socket (the app |
| @@ -81,7 +81,7 @@ func (b *ExecBackend) prepare(rt registry.Route, role instancectl.Role) (instanc |
| // so unlike startChild's flat spawn there is no "our own live |
| // process squatting on it" case this could ever race: removing it |
| // here is safe by construction, and leaving it in place would fail |
| - // EVERY later wake's health check with nothing but a dial timeout — |
| + // EVERY later wake's health check with nothing but a dial timeout, |
| // no member-facing diagnosis at all, unlike the flat path's #129 |
| // handling, which at least turns the bind failure into a named |
| // error (memberSafeWakeReason). |
| @@ -101,7 +101,7 @@ func (b *ExecBackend) prepare(rt registry.Route, role instancectl.Role) (instanc |
| // listenControl binds the control socket exactly as startChild does for an |
| // exec child (the tokenReader and schedule mount are unchanged, aside from |
| // reading beneath the instance directory rather than following a plain |
| -// path — design §3.2); a bind failure is logged and the instance runs |
| +// path, design §3.2); a bind failure is logged and the instance runs |
| // without its control verbs, as today. |
| func (b *ExecBackend) listenControl(rt registry.Route, envPath string) *edge.ControlListener { |
| tokenReader := func() (string, error) { |
diff --git a/internal/activator/unitproc.go b/internal/activator/unitproc.go| index 47ce5de61..480a6a3a0 100644 |
| --- a/internal/activator/unitproc.go |
| +++ b/internal/activator/unitproc.go |
| @@ -26,7 +26,7 @@ var ErrStillRunning = errors.New("activator: stop unproven; processes remain") |
| // unitProc is a nested host's Proc (design §6.3): the unit the control |
| // service started, the listeners the agent holds for it, and the observer |
| // that proves termination. Named apart from backend_unit.go's svcUnitProc, |
| -// a whole-app tenant's own systemd unit — this one is a nested exec |
| +// a whole-app tenant's own systemd unit: this one is a nested exec |
| // instance the activator spawns through the root instance-control channel. |
| type unitProc struct { |
| b *ExecBackend |
| @@ -40,11 +40,18 @@ type unitProc struct { |
| exited atomic.Bool |
| done chan struct{} |
| closeOnce atomic.Bool |
| + // listenersArmed guards the done-watcher goroutine Stop's ctx.Done |
| + // branch starts: several callers bound their wait on Stop shorter than |
| + // the unit may actually take to die (a sidecar co-stop, a discarded |
| + // sidecar, a failed health wait) and never call Stop again once it |
| + // returns ErrStillRunning, which otherwise leaves ctl and logs open |
| + // for as long as the agent runs. Armed at most once per unitProc. |
| + listenersArmed atomic.Bool |
| // stopRequested is set before the FIRST Control.Stop this proc's owner |
| - // ever issues for it (fix round 1, controller ruling): once true, the |
| - // observer stops treating a differently-invoked or not-yet-invoked unit |
| - // as a FOREIGN restart to stop on its own — it is instead the proof |
| - // that our own invocation is already over (see observe). |
| + // ever issues for it: once true, the observer stops treating a |
| + // differently-invoked or not-yet-invoked unit as a FOREIGN restart to |
| + // stop on its own. It is instead the proof that our own invocation is |
| + // already over (see observe). |
| stopRequested atomic.Bool |
| } |
| @@ -79,6 +86,19 @@ func (p *unitProc) Stop(ctx context.Context) error { |
| p.closeListeners() |
| return nil |
| case <-ctx.Done(): |
| + // The caller is giving up on waiting, not on the stop itself: the |
| + // unit may still be mid-drain, so the listeners must stay open while |
| + // a writer could still exist. But several callers never call Stop |
| + // again, and closing them still has to happen once done eventually |
| + // closes, or they leak for the life of the agent. |
| + // One goroutine per unitProc, armed once, does that regardless of |
| + // whether anyone ever retries. |
| + if !p.listenersArmed.Swap(true) { |
| + go func() { |
| + <-p.done |
| + p.closeListeners() |
| + }() |
| + } |
| return ErrStillRunning |
| } |
| } |
| @@ -134,14 +154,13 @@ func (b *ExecBackend) observe(p *unitProc) { |
| // A stop of OUR OWN invocation is outstanding (Stop was already |
| // called): systemd starts a fresh invocation of the same unit only |
| // once the previous one's stop job has fully completed, so seeing |
| - // anything that is not provably that same old invocation — a |
| + // anything that is not provably that same old invocation, a |
| // different id, or none reported yet while the unit sits in a |
| - // non-terminal ActiveState (mid-cycle to a fresh one) — is itself |
| + // non-terminal ActiveState (mid-cycle to a fresh one), is itself |
| // proof our invocation is over. No cgroup check is needed here: |
| // there is no "our process might still be running" left to |
| // disprove, because systemd's own state machine already disproved |
| - // it by allowing a new invocation to begin (controller ruling, fix |
| - // round 1). |
| + // it by allowing a new invocation to begin. |
| if p.stopRequested.Load() && err == nil && !terminal && (foreign || inv == "") { |
| emitEvent(b.Logs, p.rt, "warn", "instance exited (status unknown: replaced after stop)") |
| p.exited.Store(true) |
| @@ -161,7 +180,7 @@ func (b *ExecBackend) observe(p *unitProc) { |
| if terminal || (foreign && foreignStopped) { |
| exists, populated, eerr := events(p.slice, p.unit) |
| if eerr != nil { |
| - // A read error is not evidence of anything — not exited, |
| + // A read error is not evidence of anything. Not exited, |
| // not still running, just unknown. Treating it as "cgroup |
| // empty" would let a still-running process's exit be |
| // reported anyway, exactly what this proof scheme exists |
| @@ -206,12 +225,12 @@ func (b *ExecBackend) observe(p *unitProc) { |
| } |
| // exitLine words the event line from the advisory record observe already |
| -// read (fix round 1: read once, pass to both exitLine and exitLevel, so the |
| -// two can never disagree from reading the file twice with something |
| -// changing in between), the same way childProc does: a non-zero status is a |
| -// warn line, a clean exit is info, and no record is unknown. The record is |
| -// tenant-writable text (design §6.5, final review of plan 2), so every |
| -// field that reaches the line passes through recordToken first. |
| +// read once and passes to both exitLine and exitLevel, so the two can |
| +// never disagree from reading the file twice with something changing in |
| +// between, the same way childProc does: a non-zero status is a warn line, |
| +// a clean exit is info, and no record is unknown. The record is |
| +// tenant-writable text (design §6.5), so every field that reaches the line |
| +// passes through recordToken first. |
| func exitLine(rec instancectl.ExitRecord, ok bool) string { |
| switch { |
| case !ok: |
diff --git a/internal/activator/unitproc_test.go b/internal/activator/unitproc_test.go| index 05374a3ee..3c445dac1 100644 |
| --- a/internal/activator/unitproc_test.go |
| +++ b/internal/activator/unitproc_test.go |
| @@ -27,14 +27,14 @@ type fakeControl struct { |
| startErr error |
| stopHook func(host string, role instancectl.Role) |
| readyErr error |
| - // onStart runs inside Start, in the fake control's own goroutine — the |
| + // onStart runs inside Start, in the fake control's own goroutine, the |
| // same place the real control service's trampoline would exec the app |
| - // and the app would bind its socket (fix round 1: prepare's stale-socket |
| - // removal races a test's OWN pre-bound fixture socket when that bind |
| - // happens before Start is even called, so tests move it here instead). |
| + // and the app would bind its socket: prepare's stale-socket removal |
| + // races a test's OWN pre-bound fixture socket when that bind happens |
| + // before Start is even called, so tests move it here instead. |
| onStart func() |
| // onSeed runs inside Seed, standing in for whatever a real seed process |
| - // might have written to disk before it failed — a test uses it to give |
| + // might have written to disk before it failed. A test uses it to give |
| // seedOwnsDB cleanup something real to (not) remove. |
| onSeed func() |
| } |
| @@ -134,7 +134,7 @@ func TestStartUnitWritesTheLaunchFileAndReturnsAUnitProc(t *testing.T) { |
| b, rt := nestedBackend(t, ctl, unit) |
| // The fake control does not start anything, so health must be faked: |
| // bind a listener at rt.Socket that answers 200, from inside Start |
| - // itself — where the app's own bind would actually happen — so |
| + // itself, where the app's own bind would actually happen, so |
| // prepare's stale-socket removal (which runs before Start ever calls |
| // the fake control) cannot race it. |
| var stopServing func() |
| @@ -259,7 +259,7 @@ func TestStartUnitRefusesWhenTheBoxIsNotReadyOrFenced(t *testing.T) { |
| t.Error("a start was issued on a box that is not ready") |
| } |
| b.NestedOK = nil |
| - b.Layout.Journal = func(string) bool { return true } |
| + b.LayoutFacts.Journal = func(string) bool { return true } |
| if _, _, err := b.Start(context.Background(), rt); err == nil || !strings.Contains(err.Error(), "migration in flight") { |
| t.Fatalf("fenced wake = %v", err) |
| } |
| @@ -319,11 +319,11 @@ func (e *eventCapture) last() (capturedEvent, bool) { |
| return e.events[len(e.events)-1], true |
| } |
| -// TestUnitProcExitLineBoundsAForgedExitRecord covers fix round 1's |
| -// controller ruling on the exit record: it is tenant-writable text, so a |
| -// record with fields no real trampoline would ever write must still |
| -// produce a bounded, single-line, member-safe event — and the record must |
| -// be consumed (removed) exactly once. |
| +// TestUnitProcExitLineBoundsAForgedExitRecord covers the exit record's |
| +// ruling: it is tenant-writable text, so a record with fields no real |
| +// trampoline would ever write must still produce a bounded, single-line, |
| +// member-safe event, and the record must be consumed (removed) exactly |
| +// once. |
| func TestUnitProcExitLineBoundsAForgedExitRecord(t *testing.T) { |
| ctl := &fakeControl{inv: "inv1"} |
| unit := &fakeUnit{} |
| @@ -383,7 +383,7 @@ func TestUnitProcExitLineBoundsAForgedExitRecord(t *testing.T) { |
| // TestStartUnitWithEmptyInvocationFailsCleanly covers the case the control |
| // service reports no invocation at all: the unit already exited before its |
| // start was observed (plan 2 final review). The wake must fail with a |
| -// diagnosis naming that, and — because nothing is running — no unitProc |
| +// diagnosis naming that, and, because nothing is running, no unitProc |
| // is ever created to observe, so Control must never see a stop. |
| func TestStartUnitWithEmptyInvocationFailsCleanly(t *testing.T) { |
| ctl := &fakeControl{inv: ""} |
| @@ -399,8 +399,8 @@ func TestStartUnitWithEmptyInvocationFailsCleanly(t *testing.T) { |
| // TestStopKeepsListenersOpenUntilTerminationIsProven covers Stop's |
| // ordering contract: an unproven stop (ErrStillRunning) must leave the |
| -// control and log listeners open — a writer that may still exist must |
| -// still be able to reach them — and only a stop that observes done may |
| +// control and log listeners open, since a writer that may still exist must |
| +// still be able to reach them, and only a stop that observes done may |
| // close them. |
| func TestStopKeepsListenersOpenUntilTerminationIsProven(t *testing.T) { |
| ctl := &fakeControl{inv: "inv1"} |
| @@ -444,10 +444,56 @@ func TestStopKeepsListenersOpenUntilTerminationIsProven(t *testing.T) { |
| } |
| } |
| +// TestStopClosesListenersAfterDoneWithNoSecondStopCall pins the fix for the |
| +// leak Stop's ctx.Done branch used to have: several real callers (the |
| +// sidecar co-stop, a discarded sidecar, a failed health wait) never call |
| +// Stop a second time once it returns ErrStillRunning. The listeners must |
| +// still close once termination is proven, with nobody asking again. |
| +func TestStopClosesListenersAfterDoneWithNoSecondStopCall(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv1"} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + var stopServing func() |
| + ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) } |
| + defer func() { |
| + if stopServing != nil { |
| + stopServing() |
| + } |
| + }() |
| + unit.set("active", "inv1", true, true) |
| + p, _, err := b.Start(context.Background(), rt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + logSocket := b.logSocket(rt.Host, instancectl.RoleRun) |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) |
| + defer cancel() |
| + if err := p.Stop(ctx); !errors.Is(err, ErrStillRunning) { |
| + t.Fatalf("Stop = %v, want ErrStillRunning", err) |
| + } |
| + |
| + unit.set("inactive", "inv1", false, false) |
| + waitFor(t, "the unit to exit", func() bool { return p.Exited() }) |
| + |
| + deadline := time.Now().Add(2 * time.Second) |
| + for { |
| + c, err := net.Dial("unix", logSocket) |
| + if err != nil { |
| + break // the listener closed on its own; no second Stop call was made |
| + } |
| + c.Close() |
| + if time.Now().After(deadline) { |
| + t.Fatal("log socket still accepts a dial 2s after the unit exited, with no second Stop call") |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| +} |
| + |
| // TestSeedUnitFailureStopsBeforeCleanupAndKeepsTheDBWhenProcessesRemain |
| -// covers the controller ruling's ordering: Control.Stop for the seed unit |
| -// runs, and is proven by an empty cgroup, BEFORE any database cleanup — |
| -// and when the cgroup never reports empty, nothing is removed at all. The |
| +// covers the ordering rule: Control.Stop for the seed unit runs, and is |
| +// proven by an empty cgroup, BEFORE any database cleanup, and when the |
| +// cgroup never reports empty, nothing is removed at all. The |
| // wait is bounded by actStopTimeout, a const this package does not let a |
| // test shrink, so this test genuinely waits out that bound. |
| func TestSeedUnitFailureStopsBeforeCleanupAndKeepsTheDBWhenProcessesRemain(t *testing.T) { |
| @@ -456,7 +502,7 @@ func TestSeedUnitFailureStopsBeforeCleanupAndKeepsTheDBWhenProcessesRemain(t *te |
| b, rt := nestedBackend(t, ctl, unit) |
| rt.Seed = `["seed","--fast"]` |
| // Stand in for a real seed process that wrote its half-built database |
| - // before dying — created only once Seed actually runs, so the PRE-seed |
| + // before dying, created only once Seed actually runs, so the PRE-seed |
| // stat that decides seedOwnsDB still sees it absent (seedOwnsDB=true). |
| ctl.onSeed = func() { |
| if err := os.WriteFile(rt.DBPath, []byte("half-built"), 0o600); err != nil { |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index 9b286db07..04d9865b8 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -903,11 +903,28 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private |
| bounceLogged(rt.Host, bounce) |
| return |
| } |
| - cur, err := os.ReadFile(path) |
| - existed := err == nil |
| - if err != nil && !os.IsNotExist(err) { |
| - fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) |
| - return |
| + // A nested row's current file lives on tenant-owned ground, so its read |
| + // goes through instancefs the same way its write below does: a plain |
| + // os.ReadFile would block forever on a FIFO a tenant planted at this |
| + // name (isolated-exec design §3.3). The flat directory is agent-owned |
| + // throughout and keeps the plain read it always used. |
| + var cur []byte |
| + var existed bool |
| + var err error |
| + if nestedRoute(dataDir, rt) { |
| + cur, err = instancefs.ReadBeneathLimit(filepath.Dir(path), filepath.Base(path), 1<<20) |
| + existed = err == nil |
| + if err != nil && !os.IsNotExist(err) { |
| + fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| + } else { |
| + cur, err = os.ReadFile(path) |
| + existed = err == nil |
| + if err != nil && !os.IsNotExist(err) { |
| + fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| } |
| // Where sync.go has its defaultMissing/lostKeys guard, this path has |
| // nothing — deliberately. That guard exists because /etc/carlos/apps |
| @@ -1087,7 +1104,13 @@ func reapNestedConfigEnv(live map[string]bool, dataDir string) { |
| if e.IsDir() { |
| continue |
| } |
| - if strings.HasSuffix(e.Name(), configEnvSuffix+".tmp") { |
| + // A nested config file is written through instancefs.WriteAtomic, |
| + // whose temp name is ".<name>.tmp-<hex>" beside the real name, not |
| + // "<name>.tmp" the way the flat directory's plain rename leaves |
| + // one: the leading dot and the "-<hex>" suffix are both load |
| + // bearing here, or a crashed pass's leftover (it holds decrypted |
| + // secrets) is never reaped. |
| + if strings.HasPrefix(e.Name(), ".") && strings.Contains(e.Name(), configEnvSuffix+".tmp-") { |
| if err := os.Remove(filepath.Join(instDir, e.Name())); err != nil { |
| fmt.Printf(" %s: remove stale config temp file: %v\n", e.Name(), err) |
| } |
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go| index ee6c48fbb..28c504177 100644 |
| --- a/internal/appconfig/syncexec_test.go |
| +++ b/internal/appconfig/syncexec_test.go |
| @@ -12,6 +12,8 @@ import ( |
| "testing" |
| "time" |
| + "golang.org/x/sys/unix" |
| + |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/relay" |
| @@ -174,6 +176,80 @@ func TestSyncExecWritesANestedRouteIntoItsInstanceDir(t *testing.T) { |
| } |
| } |
| +// TestSyncExecFIFOAtANestedBundleDoesNotHangThePass pins the review fix: a |
| +// nested row's existing bundle is read through instancefs, so a FIFO a |
| +// tenant planted at that name (isolated-exec design §3.3, no tenant-planted |
| +// inode may block the agent) must fail fast rather than hang SyncExec's |
| +// whole pass forever behind one host's blocking read(2). A sibling host on |
| +// the same pass must still converge normally. |
| +func TestSyncExecFIFOAtANestedBundleDoesNotHangThePass(t *testing.T) { |
| + ctx, s, reg, dd, priv, _ := execKit(t) |
| + pk, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "eleven", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "twelve", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + okHost, fifoHost := "ok.example.com", "fifo.example.com" |
| + for _, h := range []string{okHost, fifoHost} { |
| + if err := os.MkdirAll(provision.InstanceDir(dd, h), 0o755); err != nil { |
| + t.Fatalf("mkdir instance dir for %s: %v", h, err) |
| + } |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: okHost, Kind: "instance", App: "eleven", Account: "bab", |
| + Status: "ready", DBPath: provision.NestedDBPath(dd, okHost), Socket: provision.NestedSocket(okHost), |
| + }); err != nil { |
| + t.Fatalf("Upsert %s: %v", okHost, err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: fifoHost, Kind: "instance", App: "twelve", Account: "bab", |
| + Status: "ready", DBPath: provision.NestedDBPath(dd, fifoHost), Socket: provision.NestedSocket(fifoHost), |
| + }); err != nil { |
| + t.Fatalf("Upsert %s: %v", fifoHost, err) |
| + } |
| + // Planted where syncExecHost's existed-check reads BEFORE it ever writes |
| + // anything: a tenant that got here first, not a crash artifact. |
| + fifoPath := filepath.Join(provision.InstanceDir(dd, fifoHost), fifoHost+".config.env") |
| + if err := unix.Mkfifo(fifoPath, 0o600); err != nil { |
| + t.Fatalf("Mkfifo: %v", err) |
| + } |
| + |
| + var bounced []string |
| + bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| + done := make(chan error, 1) |
| + go func() { done <- SyncExec(ctx, reg, s, pk, dd, bounce, "", nil) }() |
| + select { |
| + case err := <-done: |
| + if err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("SyncExec hung behind the FIFO'd host instead of failing that one host fast") |
| + } |
| + |
| + okPath := filepath.Join(provision.InstanceDir(dd, okHost), okHost+".config.env") |
| + got, err := os.ReadFile(okPath) |
| + if err != nil { |
| + t.Fatalf("sibling host's config was not written: %v", err) |
| + } |
| + if string(got) != "LOG_LEVEL=debug\n" { |
| + t.Fatalf("sibling config env = %q, want LOG_LEVEL=debug", got) |
| + } |
| + if len(bounced) != 1 || bounced[0] != okHost { |
| + t.Fatalf("bounced = %v, want exactly [%s] (the FIFO'd host must not bounce)", bounced, okHost) |
| + } |
| + // The FIFO itself is untouched: syncExecHost's read failed before it |
| + // ever reached a write. |
| + if fi, err := os.Lstat(fifoPath); err != nil || fi.Mode()&os.ModeNamedPipe == 0 { |
| + t.Fatalf("FIFO at %s was replaced or removed: %v %v", fifoPath, fi, err) |
| + } |
| +} |
| + |
| // TestReapConfigEnvWalksInstanceDirs pins reapConfigEnv's nested half: a |
| // stale config file inside a gone host's instance directory is removed, and |
| // a live nested route's own file survives. |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 8f4b5e6c6..d2ff8f1c3 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -149,6 +149,7 @@ import ( |
| "flag" |
| "fmt" |
| "io" |
| + "io/fs" |
| "log" |
| "math/rand/v2" |
| "net" |
| @@ -313,10 +314,17 @@ func RunAgent(args []string) { |
| // handle, so a nested row's classification here always agrees with |
| // what the rest of this process already knows about the box. |
| eb.Control = instancectl.NewClient(controlsock.Path) |
| - eb.Layout = activator.LayoutInputs{ |
| + eb.LayoutFacts = activator.LayoutInputs{ |
| DataDir: dd, |
| - Journal: func(host string) bool { _, err := os.Stat(provision.LayoutJournalPath(dd, host)); return err == nil }, |
| - Peers: reg.List, |
| + // A journal that cannot be read is not proof there isn't one: an |
| + // EACCES or EIO on /data/layout must fence the row rather than |
| + // wake it as if nothing were in flight, so only a definite |
| + // ErrNotExist counts as "no journal". |
| + Journal: func(host string) bool { |
| + _, err := os.Stat(provision.LayoutJournalPath(dd, host)) |
| + return !errors.Is(err, fs.ErrNotExist) |
| + }, |
| + Peers: reg.List, |
| } |
| eb.NestedOK = nestedGate(instancefs.MountNoSymfollow, eb.Control.Ready) |
| svc = activator.New(*cfg, &activator.SplitBackend{ |
diff --git a/internal/instancectl/client.go b/internal/instancectl/client.go| index 689b08eb0..33dfa32cc 100644 |
| --- a/internal/instancectl/client.go |
| +++ b/internal/instancectl/client.go |
| @@ -54,6 +54,11 @@ func (e *NotReadyError) Error() string { |
| return "instance control: box not ready: " + strings.Join(fails, "; ") |
| } |
| +// httpClient builds a fresh Transport per call rather than caching one on c. |
| +// DisableKeepAlives is what makes that the right trade: a pooled idle |
| +// connection nothing ever reuses (each call already builds its own client) |
| +// would just sit open, leaking one file descriptor per call instead of |
| +// closing when the response is read. |
| func (c *Client) httpClient() *http.Client { |
| dial := c.Dial |
| if dial == nil { |
diff --git a/internal/instancefs/instancefs.go b/internal/instancefs/instancefs.go| index acad5dcff..93cb41447 100644 |
| --- a/internal/instancefs/instancefs.go |
| +++ b/internal/instancefs/instancefs.go |
| @@ -34,6 +34,12 @@ var ErrSymlink = errors.New("instancefs: path contains a symlink") |
| // ErrEscapes is returned for a name that is absolute or reaches above dir. |
| var ErrEscapes = errors.New("instancefs: path escapes its directory") |
| +// ErrNotRegular is returned when a read-only open resolves to anything |
| +// other than a regular file: a FIFO with no writer blocks open(2) |
| +// indefinitely, and a directory or device is never a legitimate target for |
| +// the files this package reads on a tenant's behalf. |
| +var ErrNotRegular = errors.New("instancefs: not a regular file") |
| + |
| // cleanRel validates name as a relative path that stays inside dir. |
| func cleanRel(name string) (string, error) { |
| if name == "" || filepath.IsAbs(name) { |
diff --git a/internal/instancefs/instancefs_linux.go b/internal/instancefs/instancefs_linux.go| index 12fd93c5c..333d8a089 100644 |
| --- a/internal/instancefs/instancefs_linux.go |
| +++ b/internal/instancefs/instancefs_linux.go |
| @@ -43,7 +43,14 @@ func openDir(dir string) (int, error) { |
| } |
| // OpenBeneath opens name under dir with openat2, following no symlink at |
| -// any component and never leaving dir. |
| +// any component and never leaving dir. A read-only open also carries |
| +// O_NONBLOCK on the wire and is then checked with fstat: a FIFO with no |
| +// writer blocks open(2) indefinitely, and a tenant can plant one anywhere |
| +// under an instance directory the agent reads by contract (its own run |
| +// directory, its own env files). O_NONBLOCK is a no-op for opening a |
| +// regular file, so this changes nothing for the read paths that are |
| +// already the common case; a non-regular target is refused with |
| +// ErrNotRegular instead of hanging the caller forever. |
| func OpenBeneath(dir, name string, flag int, perm os.FileMode) (*os.File, error) { |
| rel, err := cleanRel(name) |
| if err != nil { |
| @@ -54,8 +61,13 @@ func OpenBeneath(dir, name string, flag int, perm os.FileMode) (*os.File, error) |
| return nil, err |
| } |
| defer unix.Close(dfd) |
| + wireFlags := uint64(flag) |
| + readOnly := flag == os.O_RDONLY |
| + if readOnly { |
| + wireFlags |= unix.O_NONBLOCK |
| + } |
| how := unix.OpenHow{ |
| - Flags: uint64(flag) | unix.O_CLOEXEC, |
| + Flags: wireFlags | unix.O_CLOEXEC, |
| Mode: uint64(perm.Perm()), |
| Resolve: resolveFlags, |
| } |
| @@ -69,7 +81,19 @@ func OpenBeneath(dir, name string, flag int, perm os.FileMode) (*os.File, error) |
| } |
| return nil, &os.PathError{Op: "openat2", Path: filepath.Join(dir, rel), Err: err} |
| } |
| - return os.NewFile(uintptr(fd), filepath.Join(dir, rel)), nil |
| + path := filepath.Join(dir, rel) |
| + if readOnly { |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + unix.Close(fd) |
| + return nil, &os.PathError{Op: "fstat", Path: path, Err: err} |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG { |
| + unix.Close(fd) |
| + return nil, fmt.Errorf("%w: %s", ErrNotRegular, path) |
| + } |
| + } |
| + return os.NewFile(uintptr(fd), path), nil |
| } |
| // WriteAtomic writes data to a fresh O_EXCL temp file beside name and |
diff --git a/internal/instancefs/instancefs_linux_test.go b/internal/instancefs/instancefs_linux_test.go| index 674a2a73d..158f05a2e 100644 |
| --- a/internal/instancefs/instancefs_linux_test.go |
| +++ b/internal/instancefs/instancefs_linux_test.go |
| @@ -8,6 +8,9 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| ) |
| func TestOpenBeneathReadsAPlainFile(t *testing.T) { |
| @@ -194,6 +197,31 @@ func TestMountOptionOfIsTheGeneralForm(t *testing.T) { |
| _ = got // / is not reliably ro or rw across test hosts; only the absence of an error matters here. |
| } |
| +// TestOpenBeneathRefusesAFIFOInsteadOfBlocking pins the review fix: a FIFO |
| +// with no writer blocks open(2) indefinitely, and a tenant can plant one at |
| +// any name the agent reads by contract. The read must fail fast with |
| +// ErrNotRegular rather than hang the caller, so this runs it in a goroutine |
| +// with its own timeout: a regression here must fail the test, not wedge it. |
| +func TestOpenBeneathRefusesAFIFOInsteadOfBlocking(t *testing.T) { |
| + dir := t.TempDir() |
| + if err := unix.Mkfifo(filepath.Join(dir, "h.env"), 0o600); err != nil { |
| + t.Fatalf("Mkfifo: %v", err) |
| + } |
| + done := make(chan error, 1) |
| + go func() { |
| + _, err := ReadBeneath(dir, "h.env") |
| + done <- err |
| + }() |
| + select { |
| + case err := <-done: |
| + if !errors.Is(err, ErrNotRegular) { |
| + t.Fatalf("ReadBeneath a FIFO: err = %v, want ErrNotRegular", err) |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("ReadBeneath blocked on a FIFO with no writer instead of refusing it") |
| + } |
| +} |
| + |
| func TestReadBeneathLimitRefusesALargeFile(t *testing.T) { |
| dir := t.TempDir() |
| if err := os.WriteFile(filepath.Join(dir, "big"), make([]byte, 4096), 0o600); err != nil { |
diff --git a/internal/lease/endpoint_test.go b/internal/lease/endpoint_test.go| index 935e9cb00..75fb3f9f1 100644 |
| --- a/internal/lease/endpoint_test.go |
| +++ b/internal/lease/endpoint_test.go |
| @@ -10,9 +10,12 @@ package lease |
| import ( |
| "context" |
| "encoding/json" |
| + "fmt" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| "sync" |
| "testing" |
| @@ -261,3 +264,40 @@ func TestResolveRegion_PrefersAWSRegionEnv(t *testing.T) { |
| t.Errorf("resolveRegion = %q, want %q (AWS_REGION must outrank the non-AWS default)", got, "nbg1") |
| } |
| } |
| + |
| +// TestRestoreCreatesTheOutputPathsParentDirectory pins the review fix: a |
| +// nested row's instance directory does not exist before its first wake (it |
| +// is created by the spawn path, which runs after Restore), so Restore must |
| +// create OutputPath's parent itself rather than fail with ENOENT before the |
| +// spawn path ever gets a chance to. |
| +func TestRestoreCreatesTheOutputPathsParentDirectory(t *testing.T) { |
| + fakeCreds(t) |
| + // Every listing this exercises, the v0.3.x/LTX format comparison and |
| + // the LTX restore plan itself, is a ListObjectsV2 call; answering all |
| + // of them with an empty bucket makes Restore take the "no replica" |
| + // path, the one every fresh nested row actually takes on its first wake. |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "application/xml") |
| + fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?> |
| +<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><KeyCount>0</KeyCount><IsTruncated>false</IsTruncated></ListBucketResult>`) |
| + })) |
| + defer srv.Close() |
| + t.Setenv("CARLOS_S3_ENDPOINT", srv.URL) |
| + |
| + root := t.TempDir() |
| + dbPath := filepath.Join(root, "instances", "h.example.com", "h.example.com.db") |
| + if _, err := os.Stat(filepath.Dir(dbPath)); !os.IsNotExist(err) { |
| + t.Fatalf("test setup: parent directory already exists") |
| + } |
| + |
| + restored, err := Restore(context.Background(), dbPath, "test-bucket", "inst/h.example.com", "") |
| + if err != nil { |
| + t.Fatalf("Restore into a path whose parent is absent: %v", err) |
| + } |
| + if restored { |
| + t.Fatal("restored = true against an empty bucket, want false (nothing to restore)") |
| + } |
| + if fi, err := os.Stat(filepath.Dir(dbPath)); err != nil || !fi.IsDir() { |
| + t.Fatalf("Restore did not create the output path's parent directory: %v", err) |
| + } |
| +} |
diff --git a/internal/lease/lease.go b/internal/lease/lease.go| index 8755c2ed3..72761a941 100644 |
| --- a/internal/lease/lease.go |
| +++ b/internal/lease/lease.go |
| @@ -15,6 +15,7 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "os" |
| "path/filepath" |
| "sync" |
| "time" |
| @@ -410,6 +411,15 @@ func Restore(ctx context.Context, dbPath, bucket, prefix, region string) (bool, |
| } |
| client := newReplicaClient(bucket, prefix, region) |
| + // A nested row's instance directory may not exist yet the first time it |
| + // wakes: the directory is created by the spawn path (isolated-exec |
| + // design §3.1), which runs AFTER this restore. Without this, a fresh |
| + // nested row's very first wake fails with ENOENT before it ever gets to |
| + // create anything. |
| + if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil { |
| + return false, fmt.Errorf("create %s: %w", filepath.Dir(dbPath), err) |
| + } |
| + |
| // A nil *DB is litestream's own pattern for URL restores (the CLI does the |
| // same): Restore only needs the replica client and the output path. |
| r := litestream.NewReplicaWithClient(nil, client) |
diff --git a/internal/schedule/runner.go b/internal/schedule/runner.go| index b13b08001..888a5a784 100644 |
| --- a/internal/schedule/runner.go |
| +++ b/internal/schedule/runner.go |
| @@ -6,7 +6,6 @@ import ( |
| "fmt" |
| "log" |
| "net/http" |
| - "path/filepath" |
| "strconv" |
| "sync" |
| "time" |
| @@ -459,8 +458,8 @@ func BuildRequest(rt registry.Route, t registry.Timer, token, build string) (*ht |
| // keep <host>.env inside their own instance directory (design §7), and the |
| // host wiring (schedule_wiring.go) resolves that through the same |
| // classification the exec backend used to decide where it spawned the |
| -// instance. TokenFromEnvFile below is this with the flat resolver baked in, |
| -// which is every caller that predates the nested layout. |
| +// instance. Every caller passes a resolver here now; a caller that predates |
| +// the nested layout gets one that always names the flat path. |
| func TokenFromEnvPaths(envPath func(host string) string) func(host string) (string, error) { |
| return func(host string) (string, error) { |
| env, err := readEnvFile(envPath(host)) |
| @@ -473,9 +472,3 @@ func TokenFromEnvPaths(envPath func(host string) string) func(host string) (stri |
| return env["CARLOS_ADMIN_TOKEN"], nil |
| } |
| } |
| - |
| -// TokenFromEnvFile is the production Token: CARLOS_ADMIN_TOKEN from |
| -// <dataDir>/<host>.env, the file the activator owns (spec §6.4). |
| -func TokenFromEnvFile(dataDir string) func(host string) (string, error) { |
| - return TokenFromEnvPaths(func(host string) string { return filepath.Join(dataDir, host+".env") }) |
| -} |