host: the lifecycle property's renew waits until each controller renewed or lost
Main's DirStore now fsyncs every Put, so a hostexec renewal outlasted the fixed 20 ms settle and the next pass raced a loss still in flight (seed 104, invariant f). renew waits, bounded at one second, until every open controller has renewed at the new tick or lost its lease.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+28
−2
internal/host/relocationlifecycle_linux_test.go+28 −2
diff --git a/internal/host/relocationlifecycle_linux_test.go b/internal/host/relocationlifecycle_linux_test.go| index 2ad97642c..6dec4c869 100644 |
| --- a/internal/host/relocationlifecycle_linux_test.go |
| +++ b/internal/host/relocationlifecycle_linux_test.go |
| @@ -205,7 +205,7 @@ func newLifeWorld(t *testing.T) *lifeWorld { |
| defer w.mu.Unlock() |
| return w.installed[o.Claim.Op.ID], nil |
| }, |
| - Admit: func(_ context.Context, o hostclaim.Observed, _ string) (uint64, bool, error) { |
| + Admit: func(_ context.Context, o hostclaim.Observed, _ bool) (uint64, bool, error) { |
| c := w.current() |
| w.mu.Lock() |
| present := w.installed[c.Op.ID] |
| @@ -465,12 +465,38 @@ func (w *lifeWorld) violate(s string) { |
| w.mu.Unlock() |
| } |
| -// renew fires hostexec's timers so every controller renews (or loses). |
| +// renew fires hostexec's timers so every controller renews (or loses), and |
| +// waits, bounded, until each open controller has done one or the other. A |
| +// renewal's lease write is a durable store write (main's DirStore fsyncs |
| +// the file and its directory), so a fixed short sleep let the next pass |
| +// race a loss still in flight: its asynchronous cleanup could retire the |
| +// local install between the pass's check and its admission. |
| func (w *lifeWorld) renew() { |
| w.clock.tick() |
| + wall, _, _ := w.clock.Now() |
| + deadline := time.Now().Add(time.Second) |
| + for _, c := range w.open() { |
| + for !renewedOrLost(w.ctx, c, wall) && time.Now().Before(deadline) { |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + } |
| time.Sleep(20 * time.Millisecond) |
| } |
| +// renewedOrLost reports whether c has lost its lease, or renewed it at the |
| +// tick to wall. |
| +func renewedOrLost(ctx context.Context, c RelocationController, wall time.Time) bool { |
| + select { |
| + case <-c.Lost(): |
| + return true |
| + default: |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 10*time.Millisecond) |
| + defer cancel() |
| + st, err := c.Ready(ctx) |
| + return err == nil && !st.Expires.Before(wall.Add(execctl.TTL)) |
| +} |
| + |
| func (w *lifeWorld) record() (execctl.State, bool, bool) { |
| raw, err := os.ReadFile(filepath.Join(w.dataDir, ".hostexec", w.host+".json")) |
| if errors.Is(err, os.ErrNotExist) { |