carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 0aa4789fda71219cf0aa1392d98992688f5ed243 parent dc333fb
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) {