activator: per-host gates serialise a host's group; the global lock guards only the map (Task 7b fix round 3)
- hostGate (mutex + owner) per host, looked up under strictMu, which never covers a start or filesystem work. Under the host's gate: the ownership check and an ordinary spawn's Cgroups.Start, a strict spawn taking ownership, the exit path's CleanupHost, a retry's directory removal, and S2's pending read. A stalled start blocks only its own host. - The exit path removes nothing while any owner, live or pending, holds the group, and holds the gate from that check through the removal. - pending is read and written only under the gate. - With Strict nil, spawn and the exit path take no new lock and create no gate: main's path exactly. - Tests (with -race and a per-backend hook): an older exit racing a new strict owner whose cleanup fails leaves the directory; a pending transition racing refusals; host A's stalled start does not block host B's spawns, S2 or exit cleanup; switched off, spawn and exit take no lock.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+363
−53
internal/activator/backend_exec.go+103 −45internal/activator/backend_exec_test.go+260 −8
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index e8515f3f5..0db744e1c 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -177,13 +177,54 @@ type ExecBackend struct { |
| // preflight. Nil: Cgroups.StrictPreflight, the very function |
| // PrepareStrict runs, so S2 and the spawn cannot disagree. |
| strictPreflight func() error |
| - // strictOwners holds, per host, the strict handle that owns the host's |
| - // group: from the strict spawn until its cleanup succeeds, including |
| - // any retries. While a host has an owner, no other spawn of that host, |
| - // strict or ordinary, may use the group, and the exit path may not |
| - // remove it (moves design §5.3). |
| - strictMu sync.Mutex |
| - strictOwners map[string]*strictOwner |
| + // strictGates holds one hostGate per host that has spawned with Strict |
| + // set. strictMu guards only the map's lookup and insert, never a start |
| + // or any filesystem work, so a stalled start blocks only its own host. |
| + strictMu sync.Mutex |
| + strictGates map[string]*hostGate |
| + // strictHook, when set, is called at named points of the strict |
| + // cleanup path. Test-only: it makes interleavings deterministic. |
| + strictHook func(point string) |
| +} |
| + |
| +// hostGate serialises everything that touches one host's group once Strict |
| +// is wired (moves design §5.3): the ownership check and an ordinary start, |
| +// a strict spawn taking ownership, the exit path's directory removal, a |
| +// retry's removal, and S2's read. owner, and owner.pending, are read and |
| +// written only with mu held. While owner is set, from a strict spawn until |
| +// its cleanup succeeds (retries included), no other spawn of the host may |
| +// use the group and nothing but the owner may remove it. |
| +type hostGate struct { |
| + mu sync.Mutex |
| + owner *strictOwner |
| +} |
| + |
| +// gate returns host's gate, creating it. |
| +func (b *ExecBackend) gate(host string) *hostGate { |
| + b.strictMu.Lock() |
| + defer b.strictMu.Unlock() |
| + if b.strictGates == nil { |
| + b.strictGates = map[string]*hostGate{} |
| + } |
| + g := b.strictGates[host] |
| + if g == nil { |
| + g = &hostGate{} |
| + b.strictGates[host] = g |
| + } |
| + return g |
| +} |
| + |
| +// lookupGate returns host's gate, or nil if it never had one. |
| +func (b *ExecBackend) lookupGate(host string) *hostGate { |
| + b.strictMu.Lock() |
| + defer b.strictMu.Unlock() |
| + return b.strictGates[host] |
| +} |
| + |
| +func (b *ExecBackend) hook(point string) { |
| + if b.strictHook != nil { |
| + b.strictHook(point) |
| + } |
| } |
| // ErrStrictGroupInUse is a spawn refused because a strict child of the same |
| @@ -274,22 +315,24 @@ func (b *ExecBackend) tier(rt registry.Route) cgroup.Limit { |
| // it drains and closes the group, for a plain one it does nothing. |
| // |
| // A strict handle owns the host's group from the spawn until its cleanup |
| -// succeeds (strictOwners). While it does, every other spawn of the host is |
| -// refused, an ordinary one included: the check and an ordinary spawn's |
| -// start happen under strictMu, so no strict group can be prepared for the |
| -// host in between. With Strict nil (a box with no moves wiring) none of |
| -// this runs: the spawn is main's exactly. |
| +// succeeds (hostGate.owner). While it does, every other spawn of the host |
| +// is refused, an ordinary one included: the check and an ordinary spawn's |
| +// start happen under the host's gate, so no strict group can be prepared |
| +// for the host in between. With Strict nil (a box with no moves wiring) |
| +// none of this runs and no lock is taken: the spawn is main's exactly. |
| func (b *ExecBackend) spawnInCgroup(rt registry.Route, build func() *exec.Cmd) (*exec.Cmd, func(), error) { |
| if b.Strict == nil { |
| cmd, err := b.Cgroups.Start(rt.Host, b.tier(rt), build) |
| return cmd, func() {}, err |
| } |
| strict := b.Strict(rt.Host) |
| - b.strictMu.Lock() |
| - if o := b.strictOwners[rt.Host]; o != nil { |
| - b.strictMu.Unlock() |
| + g := b.gate(rt.Host) |
| + g.mu.Lock() |
| + if o := g.owner; o != nil { |
| + pending := o.pending |
| + g.mu.Unlock() |
| cause := ErrStrictGroupInUse |
| - if o.pending { |
| + if pending { |
| cause = ErrStrictCleanupPending |
| } |
| if strict { |
| @@ -299,15 +342,12 @@ func (b *ExecBackend) spawnInCgroup(rt registry.Route, build func() *exec.Cmd) ( |
| } |
| if !strict { |
| cmd, err := b.Cgroups.Start(rt.Host, b.tier(rt), build) |
| - b.strictMu.Unlock() |
| + g.mu.Unlock() |
| return cmd, func() {}, err |
| } |
| o := &strictOwner{} |
| - if b.strictOwners == nil { |
| - b.strictOwners = map[string]*strictOwner{} |
| - } |
| - b.strictOwners[rt.Host] = o |
| - b.strictMu.Unlock() |
| + g.owner = o |
| + g.mu.Unlock() |
| sp, err := b.prepareStrictProc(rt.Host, b.tier(rt)) |
| if err != nil { |
| b.disown(rt.Host, o, false) |
| @@ -337,10 +377,12 @@ func (b *ExecBackend) prepareStrictProc(host string, limit cgroup.Limit) (strict |
| return p, nil |
| } |
| -// strictOwner is the strict handle that owns a host's group. pending is |
| -// set once its first cleanup attempt failed and retries own the group. |
| -// drained records a KillAndEmpty that already succeeded: the handle refuses |
| -// a second one, so a retry after a failed Close only closes. |
| +// strictOwner is the strict handle that owns a host's group. pending |
| +// (guarded by the host gate's mu) is set once its first cleanup attempt |
| +// failed and retries own the group. drained records a KillAndEmpty that |
| +// already succeeded: the handle refuses a second one, so a retry after a |
| +// failed Close only closes. sp and drained are touched only by the one |
| +// goroutine running the owner's cleanup at a time. |
| type strictOwner struct { |
| sp strictProc |
| pending bool |
| @@ -348,7 +390,8 @@ type strictOwner struct { |
| } |
| // attempt is one cleanup attempt: drain (unless already drained), then |
| -// close. |
| +// close. It runs outside the host gate (a drain may take strictDrainTimeout); |
| +// the owner it belongs to keeps every other user of the group out meanwhile. |
| func (o *strictOwner) attempt(host string) error { |
| if !o.drained { |
| ctx, cancel := context.WithTimeout(context.Background(), strictDrainTimeout) |
| @@ -371,7 +414,7 @@ func (o *strictOwner) attempt(host string) error { |
| // success the owner lets go and the exit path's cleanupHost removes the |
| // group as before. On a failure the owner stays, marked pending, retried on |
| // strictCleanupBackoff: until a retry succeeds every spawn of the host is |
| -// refused and the exit path leaves the group directory alone, because the |
| +// refused and only the owner may remove the group directory, because the |
| // handle's identity is that directory and removing it would strand the |
| // handle for ever. |
| func (b *ExecBackend) settleStrict(host string, o *strictOwner) { |
| @@ -379,34 +422,46 @@ func (b *ExecBackend) settleStrict(host string, o *strictOwner) { |
| b.disown(host, o, false) |
| return |
| } |
| - b.strictMu.Lock() |
| + b.hook("cleanup-failed") |
| + g := b.gate(host) |
| + g.mu.Lock() |
| o.pending = true |
| - b.strictMu.Unlock() |
| + g.mu.Unlock() |
| go b.retryStrictCleanup(host, o, append([]time.Duration(nil), strictCleanupBackoff...)) |
| } |
| // disown ends o's ownership of host's group. removeGroup removes the group |
| -// directory first, under the same lock, so no spawn can recreate it in |
| +// directory first, under the host gate, so no spawn can recreate it in |
| // between: the retry owner's last act. |
| func (b *ExecBackend) disown(host string, o *strictOwner, removeGroup bool) { |
| - b.strictMu.Lock() |
| - defer b.strictMu.Unlock() |
| - if b.strictOwners[host] != o { |
| + g := b.gate(host) |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + if g.owner != o { |
| return |
| } |
| if removeGroup { |
| b.Cgroups.CleanupHost(host) |
| } |
| - delete(b.strictOwners, host) |
| + g.owner = nil |
| } |
| -// cleanupHost is the exit path's CleanupHost, skipped while a strict |
| -// handle's cleanup is pending: the retry owner removes the group once the |
| -// handle is closed. |
| +// cleanupHost is the exit path's CleanupHost. With Strict nil it is main's |
| +// call, with no lock. Otherwise it runs under the host gate and removes |
| +// nothing while any strict owner, live or pending, holds the group: the |
| +// owner removes it once its handle is closed. |
| func (b *ExecBackend) cleanupHost(host string) { |
| - if b.strictCleanupPending(host) { |
| + if b.Strict == nil { |
| + b.Cgroups.CleanupHost(host) |
| + return |
| + } |
| + g := b.gate(host) |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + if g.owner != nil { |
| return |
| } |
| + b.hook("exit-cleanup-checked") |
| b.Cgroups.CleanupHost(host) |
| } |
| @@ -425,13 +480,16 @@ func (b *ExecBackend) retryStrictCleanup(host string, o *strictOwner, schedule [ |
| log.Printf("activator: %s: strict group still not cleaned up after %d retries; spawns of this host stay refused until the agent restarts", host, len(schedule)) |
| } |
| -// strictCleanupPending reports whether host's group is owned by a strict |
| -// handle whose cleanup is being retried. |
| +// strictCleanupPending reports, under the host gate, whether host's group |
| +// is owned by a strict handle whose cleanup is being retried. |
| func (b *ExecBackend) strictCleanupPending(host string) bool { |
| - b.strictMu.Lock() |
| - defer b.strictMu.Unlock() |
| - o := b.strictOwners[host] |
| - return o != nil && o.pending |
| + g := b.lookupGate(host) |
| + if g == nil { |
| + return false |
| + } |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + return g.owner != nil && g.owner.pending |
| } |
| // NextSpawnStrict is the source's S2 input (moves design §7.3, §5.3): will |
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go| index 51b3d0f2c..1b2af1451 100644 |
| --- a/internal/activator/backend_exec_test.go |
| +++ b/internal/activator/backend_exec_test.go |
| @@ -761,14 +761,11 @@ func TestStrictCleanupGivesUpButStaysRefused(t *testing.T) { |
| // ownStrictForTest registers sp as host's strict owner, as a strict spawn |
| // does, so a test can drive settleStrict directly. |
| func (b *ExecBackend) ownStrictForTest(host string, sp strictProc) *strictOwner { |
| - b.strictMu.Lock() |
| - defer b.strictMu.Unlock() |
| - if b.strictOwners == nil { |
| - b.strictOwners = map[string]*strictOwner{} |
| - } |
| - o := &strictOwner{sp: sp} |
| - b.strictOwners[host] = o |
| - return o |
| + g := b.gate(host) |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + g.owner = &strictOwner{sp: sp} |
| + return g.owner |
| } |
| // dirStrict is a strict handle whose identity is a real directory: the |
| @@ -978,3 +975,258 @@ func TestALiveStrictGroupRefusesOrdinarySpawns(t *testing.T) { |
| settle2() |
| } |
| } |
| + |
| +// hookAt returns a strictHook that calls fn at point and does nothing at |
| +// any other point. |
| +func hookAt(point string, fn func()) func(string) { |
| + return func(p string) { |
| + if p == point { |
| + fn() |
| + } |
| + } |
| +} |
| + |
| +// TestAnOlderExitCannotRemoveANewOwnersGroup: the exit path's removal and |
| +// a strict spawn's ownership are decided under the same host gate. An exit |
| +// that has checked "no owner" holds the gate through its removal, so a |
| +// strict spawn arriving meanwhile waits, then owns a group it created |
| +// itself; an exit that runs once any owner exists, live or pending, removes |
| +// nothing. A strict child whose cleanup then fails keeps its directory. |
| +func TestAnOlderExitCannotRemoveANewOwnersGroup(t *testing.T) { |
| + oldBackoff := strictCleanupBackoff |
| + strictCleanupBackoff = []time.Duration{time.Hour} |
| + t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) |
| + host := "race.example.com" |
| + b := testExecBackend(t) |
| + b.Cgroups = delegatedManager(t) |
| + groupDir := filepath.Join(b.Cgroups.Root(), cgroup.ExecName(host)) |
| + b.Strict = func(string) bool { return true } |
| + var handle *dirStrict |
| + b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { |
| + handle = newDirStrict(t, groupDir) |
| + handle.killFails = 100 |
| + return handle, nil |
| + } |
| + if err := os.MkdirAll(groupDir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The older exit has found no owner and is about to remove the group. |
| + // A strict spawn starts right then and must wait for the gate. |
| + spawned := make(chan error, 1) |
| + var settle func() |
| + var cmd *exec.Cmd |
| + b.strictHook = hookAt("exit-cleanup-checked", func() { |
| + go func() { |
| + var err error |
| + cmd, settle, err = b.spawnInCgroup(registry.Route{Host: host}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| + spawned <- err |
| + }() |
| + select { |
| + case err := <-spawned: |
| + t.Errorf("a strict spawn took the host while an exit held its gate: %v", err) |
| + spawned <- err |
| + case <-time.After(150 * time.Millisecond): |
| + } |
| + }) |
| + b.cleanupHost(host) |
| + b.strictHook = nil |
| + if err := <-spawned; err != nil { |
| + t.Fatalf("strict spawn after the exit: %v", err) |
| + } |
| + if _, err := os.Stat(groupDir); err != nil { |
| + t.Fatalf("the new owner has no group directory: %v", err) |
| + } |
| + // Any owner, live as here, keeps the directory from an exit. |
| + b.cleanupHost(host) |
| + if _, err := os.Stat(groupDir); err != nil { |
| + t.Fatalf("an exit removed a live owner's group: %v", err) |
| + } |
| + _ = cmd.Wait() |
| + settle() // the drain fails: the owner turns pending |
| + if !b.strictCleanupPending(host) { |
| + t.Fatal("cleanup is not pending after a failed drain") |
| + } |
| + b.cleanupHost(host) |
| + if _, err := os.Stat(groupDir); err != nil { |
| + t.Fatalf("an exit removed a pending owner's group: %v", err) |
| + } |
| + if !handle.identityOK() { |
| + t.Fatal("the retained handle lost its group") |
| + } |
| +} |
| + |
| +// TestAPendingTransitionRacesARefusal: a spawn refused while the owner is |
| +// between its failed cleanup attempt and turning pending sees a live owner |
| +// (ErrStrictGroupInUse); once pending, ErrStrictCleanupPending. pending is |
| +// only ever read and written under the host gate, so -race stays quiet |
| +// however the two interleave. |
| +func TestAPendingTransitionRacesARefusal(t *testing.T) { |
| + oldBackoff := strictCleanupBackoff |
| + strictCleanupBackoff = []time.Duration{time.Hour} |
| + t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) |
| + host := "transition.example.com" |
| + b := testExecBackend(t) |
| + b.Strict = func(string) bool { return false } |
| + o := b.ownStrictForTest(host, &flakyStrict{recordingStrict: newRecordingStrict(), killFails: 100}) |
| + spawn := func() error { |
| + _, _, err := b.spawnInCgroup(registry.Route{Host: host}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| + return err |
| + } |
| + var during error |
| + b.strictHook = hookAt("cleanup-failed", func() { during = spawn() }) |
| + b.settleStrict(host, o) |
| + b.strictHook = nil |
| + if !errors.Is(during, ErrStrictGroupInUse) { |
| + t.Fatalf("refusal before the owner turned pending: %v, want ErrStrictGroupInUse", during) |
| + } |
| + if err := spawn(); !errors.Is(err, ErrStrictCleanupPending) { |
| + t.Fatalf("refusal after: %v, want ErrStrictCleanupPending", err) |
| + } |
| + |
| + // The same transition against concurrent refusals, for -race. |
| + host2 := "transition2.example.com" |
| + o2 := b.ownStrictForTest(host2, &flakyStrict{recordingStrict: newRecordingStrict(), killFails: 100}) |
| + var wg sync.WaitGroup |
| + stop := make(chan struct{}) |
| + var looping sync.WaitGroup |
| + for i := 0; i < 8; i++ { |
| + wg.Add(1) |
| + looping.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + first := true |
| + for { |
| + _, _, err := b.spawnInCgroup(registry.Route{Host: host2}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| + if !errors.Is(err, ErrStrictGroupInUse) && !errors.Is(err, ErrStrictCleanupPending) { |
| + t.Errorf("spawn beside an owner: %v", err) |
| + } |
| + _ = b.NextSpawnStrict(host2) |
| + if first { |
| + looping.Done() |
| + first = false |
| + } |
| + select { |
| + case <-stop: |
| + return |
| + default: |
| + } |
| + } |
| + }() |
| + } |
| + looping.Wait() |
| + b.settleStrict(host2, o2) // turns pending while the refusals run |
| + time.Sleep(20 * time.Millisecond) |
| + close(stop) |
| + wg.Wait() |
| +} |
| + |
| +// TestAStalledStartBlocksOnlyItsHost: host A's ordinary start stalls inside |
| +// Cgroups.Start, holding A's gate. Host B's ordinary and strict spawns and |
| +// its NextSpawnStrict proceed; the global map lock is never held across a |
| +// start. |
| +func TestAStalledStartBlocksOnlyItsHost(t *testing.T) { |
| + b := testExecBackend(t) |
| + b.Cgroups = delegatedManager(t) |
| + b.strictPreflight = func() error { return nil } |
| + b.Strict = func(h string) bool { return h == "b-strict.example.com" } |
| + b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return newRecordingStrict(), nil } |
| + release := make(chan struct{}) |
| + entered := make(chan struct{}) |
| + var once sync.Once |
| + doneA := make(chan error, 1) |
| + go func() { |
| + cmd, settle, err := b.spawnInCgroup(registry.Route{Host: "a.example.com"}, func() *exec.Cmd { |
| + once.Do(func() { close(entered); <-release }) |
| + return exec.Command("/bin/true") |
| + }) |
| + if err == nil { |
| + _ = cmd.Wait() |
| + settle() |
| + } |
| + doneA <- err |
| + }() |
| + <-entered |
| + within := func(what string, fn func()) { |
| + t.Helper() |
| + done := make(chan struct{}) |
| + go func() { fn(); close(done) }() |
| + select { |
| + case <-done: |
| + case <-time.After(2 * time.Second): |
| + t.Fatalf("%s was blocked by host A's stalled start", what) |
| + } |
| + } |
| + within("host B's ordinary spawn", func() { |
| + cmd, settle, err := b.spawnInCgroup(registry.Route{Host: "b.example.com"}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| + if err != nil { |
| + t.Errorf("host B: %v", err) |
| + return |
| + } |
| + _ = cmd.Wait() |
| + settle() |
| + }) |
| + within("host B's strict spawn", func() { |
| + cmd, settle, err := b.spawnInCgroup(registry.Route{Host: "b-strict.example.com"}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| + if err != nil { |
| + t.Errorf("host B strict: %v", err) |
| + return |
| + } |
| + _ = cmd.Wait() |
| + settle() |
| + }) |
| + within("host B's NextSpawnStrict", func() { |
| + if c := b.NextSpawnStrict("b-strict.example.com"); !c.OK { |
| + t.Errorf("S2 for host B: %+v", c) |
| + } |
| + }) |
| + within("host B's exit cleanup", func() { b.cleanupHost("b.example.com") }) |
| + close(release) |
| + if err := <-doneA; err != nil { |
| + t.Fatalf("host A: %v", err) |
| + } |
| +} |
| + |
| +// TestSwitchedOffSpawnsTakeNoNewLock: with Strict nil, a spawn and its exit |
| +// path are main's: no host gate is created and the global map lock is |
| +// never taken (held by the test throughout, a spawn that wanted it would |
| +// hang). |
| +func TestSwitchedOffSpawnsTakeNoNewLock(t *testing.T) { |
| + old := spawnHealthTimeout |
| + spawnHealthTimeout = 300 * time.Millisecond |
| + t.Cleanup(func() { spawnHealthTimeout = old }) |
| + b := testExecBackend(t) |
| + b.Cgroups = delegatedManager(t) |
| + host := "off.example.com" |
| + argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| + b.strictMu.Lock() |
| + defer b.strictMu.Unlock() |
| + done := make(chan struct{}) |
| + go func() { |
| + defer close(done) |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, _ := b.Start(ctx, rt) |
| + if proc != nil { |
| + _ = proc.Stop(context.Background()) |
| + } |
| + }() |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("a switched-off spawn waited on the strict map lock") |
| + } |
| + waitForFile(t, argvPath, time.Second) |
| + // The exit path's call, again, with the lock held. |
| + cleaned := make(chan struct{}) |
| + go func() { b.cleanupHost(host); close(cleaned) }() |
| + select { |
| + case <-cleaned: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("a switched-off exit cleanup waited on the strict map lock") |
| + } |
| + if b.strictGates != nil { |
| + t.Fatalf("a switched-off spawn created host gates: %v", b.strictGates) |
| + } |
| +} |