carlos / platform Public

memory drop-in under a lock; the restart watch keeps wall time and a present counter (#284)

Summary

Astra's third pass on slice A: three P2s.
- writeMemoryDropIn runs under a flock on the drop-in's directory, so a
  run by hand overlapping a timer's cannot remove the reload marker the
  other just left.
- The restart watch strips monotonic readings (now.Round(0)): every
  time it holds is one it could have loaded, so a clock step is
  measured the same way either side of an agent restart, and a watch it
  wrote always passes its own validation.
- A saved unit history with a missing or null "last" is discarded.
Census overlay regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0fa3b84c1db755bef461e608ec39f7c22e9cc42f parent db57e5c
8 files changed, +154 −10
  • internal/host/restartwatch.go +20 −0
  • internal/host/restartwatch_test.go +37 −0
  • internal/ops/flock_unix.go +15 −0
  • internal/ops/flock_windows.go +18 −0
  • internal/ops/litestream_memory.go +17 −3
  • internal/ops/ops_test.go +40 −0
  • internal/provision/plan14_root_reads_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-ls284.tsv +6 −6

CI cancelled — superseded by a newer push — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m

amadan: job cancelled — a newer push superseded it
diff --git a/internal/host/restartwatch.go b/internal/host/restartwatch.go
index 028cfbc06..5f0141b8c 100644
--- a/internal/host/restartwatch.go
+++ b/internal/host/restartwatch.go
@@ -91,6 +91,11 @@ func (w *restartWatch) tick(now time.Time, units map[string]unitRestartState) ma
if units == nil {
return nil
}
+ // Wall time only. A time from time.Now carries a monotonic reading that
+ // a saved-and-loaded one does not, and comparing the two kinds measures a
+ // clock step differently depending on which side of a restart each time
+ // came from. Every time this watch holds is one it could have loaded.
+ now = now.Round(0)
if w.Units == nil || now.Before(w.At) || (!w.At.IsZero() && now.Sub(w.At) > restartWindow) {
w.Units = map[string]*unitRestartHistory{}
}
@@ -154,6 +159,21 @@ func loadRestartWatch(path string) restartWatch {
if err != nil || json.Unmarshal(b, &w) != nil || !w.wellFormed() {
return restartWatch{}
}
+ // A missing or null "last" decodes as 0, which is a real counter value,
+ // so presence is checked on its own.
+ var present struct {
+ Units map[string]struct {
+ Last *int64 `json:"last"`
+ } `json:"units"`
+ }
+ if json.Unmarshal(b, &present) != nil {
+ return restartWatch{}
+ }
+ for name := range w.Units {
+ if present.Units[name].Last == nil {
+ return restartWatch{}
+ }
+ }
return w
}
diff --git a/internal/host/restartwatch_test.go b/internal/host/restartwatch_test.go
index abdbe2979..1dea08fc7 100644
--- a/internal/host/restartwatch_test.go
+++ b/internal/host/restartwatch_test.go
@@ -166,6 +166,8 @@ func TestRestartWatchDiscardsASavedWatchItDidNotWrite(t *testing.T) {
"another version": `{"v":2,"at":"` + at + `","units":{}}`,
"no time": `{"v":1,"units":{"carlos-litestream.service":{"last":5,"quiet_since":"` + at + `"}}}`,
"empty unit history": `{"v":1,"at":"` + at + `","units":{"carlos-litestream.service":{}}}`,
+ "no counter": `{"v":1,"at":"` + at + `","units":{"carlos-litestream.service":{"quiet_since":"` + at + `"}}}`,
+ "null counter": `{"v":1,"at":"` + at + `","units":{"carlos-litestream.service":{"last":null,"quiet_since":"` + at + `"}}}`,
"null unit history": `{"v":1,"at":"` + at + `","units":{"carlos-litestream.service":null}}`,
"negative counter": `{"v":1,"at":"` + at + `","units":{"carlos-litestream.service":{"last":-3,"quiet_since":"` + at + `"}}}`,
"negative rise": `{"v":1,"at":"` + at + `","units":{"carlos-litestream.service":{"last":5,"quiet_since":"` + at + `","bumps":[{"at":"` + at + `","n":-900}]}}}`,
@@ -240,3 +242,38 @@ func TestParseUnitRestarts(t *testing.T) {
t.Fatalf("no loaded units = %v, want an empty map, not nil", got)
}
}
+
+// The loop hands tick times from time.Now, which carry a monotonic reading;
+// what it loads back does not. The watch keeps wall time only, so a watch it
+// wrote is one it will accept, and a clock step is measured the same way on
+// both sides of an agent restart.
+func TestRestartWatchKeepsWallTimeOnly(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "restarts.json")
+ var w restartWatch
+ start := time.Now()
+ w.tick(start, oneUnit(3))
+ w.tick(start.Add(time.Minute), oneUnit(7))
+ if err := saveRestartWatch(path, w); err != nil {
+ t.Fatal(err)
+ }
+ loaded := loadRestartWatch(path)
+ if loaded.Units == nil {
+ t.Fatal("a watch ticked with time.Now was discarded on load")
+ }
+ if got := loaded.tick(start.Add(2*time.Minute), oneUnit(7)); got[lsUnit].Count != 4 {
+ t.Fatalf("after load: %+v, want the 4 restarts it saved", got[lsUnit])
+ }
+ for _, h := range w.Units {
+ if h.QuietSince != h.QuietSince.Round(0) || w.At != w.At.Round(0) {
+ t.Fatalf("the watch kept a monotonic reading: at=%v quiet_since=%v", w.At, h.QuietSince)
+ }
+ }
+
+ // A backward step with nothing loaded: still a rebaseline.
+ var live restartWatch
+ live.tick(start, oneUnit(10))
+ live.tick(start.Add(time.Minute), oneUnit(14))
+ if got := live.tick(start.Add(-time.Hour), oneUnit(14)); got[lsUnit].Count != 0 || got[lsUnit].Settled {
+ t.Fatalf("a live backward step kept %+v", got[lsUnit])
+ }
+}
diff --git a/internal/ops/flock_unix.go b/internal/ops/flock_unix.go
new file mode 100644
index 000000000..29c48345a
--- /dev/null
+++ b/internal/ops/flock_unix.go
@@ -0,0 +1,15 @@
+//go:build !windows
+
+package ops
+
+import (
+ "os"
+ "syscall"
+)
+
+// lockDir takes an exclusive advisory lock on an open directory, and
+// unlockDir drops it. syscall.Flock is on both linux and darwin, which
+// covers every platform that runs an ops verb.
+func lockDir(d *os.File) error { return syscall.Flock(int(d.Fd()), syscall.LOCK_EX) }
+
+func unlockDir(d *os.File) { syscall.Flock(int(d.Fd()), syscall.LOCK_UN) } //nolint:errcheck
diff --git a/internal/ops/flock_windows.go b/internal/ops/flock_windows.go
new file mode 100644
index 000000000..7889789b7
--- /dev/null
+++ b/internal/ops/flock_windows.go
@@ -0,0 +1,18 @@
+//go:build windows
+
+package ops
+
+import (
+ "errors"
+ "os"
+)
+
+// lockDir refuses on Windows rather than pretending to lock. The ops verbs
+// run on a Linux box; the Windows build exists only so the carlos CLI
+// compiles for workstation use. Refusing is the safe failure: the caller
+// writes nothing when the lock cannot be taken.
+func lockDir(d *os.File) error {
+ return errors.New("ops verbs lock box-side (linux) only")
+}
+
+func unlockDir(d *os.File) {}
diff --git a/internal/ops/litestream_memory.go b/internal/ops/litestream_memory.go
index 55f359c0d..6a8e9fd78 100644
--- a/internal/ops/litestream_memory.go
+++ b/internal/ops/litestream_memory.go
@@ -90,9 +90,26 @@ func LitestreamMemoryDropIn(n int) string {
// to rewrite, and would never reload a cap an earlier run failed to load.
// The marker is written first and removed last, so every way of stopping in
// between leaves it for the next run. systemd ignores it (no .conf suffix).
+//
+// The whole of it runs under a lock on the drop-in's directory. The verb is
+// run by hand and may be run by a timer, and two runs interleaved could have
+// one remove the marker the other had just left for a reload that failed.
func writeMemoryDropIn(path string, n int, reload func() error) (changed bool, err error) {
want := LitestreamMemoryDropIn(n)
pending := path + ".reload-pending"
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return false, err
+ }
+ dir, err := os.Open(filepath.Dir(path))
+ if err != nil {
+ return false, err
+ }
+ defer dir.Close()
+ if err := lockDir(dir); err != nil {
+ return false, err
+ }
+ defer unlockDir(dir)
+
prior, readErr := os.ReadFile(path)
if readErr != nil && !os.IsNotExist(readErr) {
return false, readErr
@@ -101,9 +118,6 @@ func writeMemoryDropIn(path string, n int, reload func() error) (changed bool, e
if _, err := os.Lstat(pending); current && os.IsNotExist(err) {
return false, nil
}
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return false, err
- }
if err := safefile.WriteFile(pending, nil, 0o644); err != nil {
return false, err
}
diff --git a/internal/ops/ops_test.go b/internal/ops/ops_test.go
index 7e9d89a79..3262997e1 100644
--- a/internal/ops/ops_test.go
+++ b/internal/ops/ops_test.go
@@ -998,3 +998,43 @@ func TestWriteMemoryDropInRetriesAfterAFailedReload(t *testing.T) {
t.Fatalf("stray marker: changed=%v err=%v reloads=%d, want true, nil, 4", changed, err, reloads)
}
}
+
+// Two runs must not interleave. If a second run could start while the first
+// is between its reload and removing its marker, the first would go on to
+// remove the marker the second had just left for a reload that failed, and
+// the second's cap would sit on disk unloaded for good.
+func TestWriteMemoryDropInRunsOneAtATime(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "d", "50-memory.conf")
+ second := make(chan error, 1)
+ firstReload := func() error {
+ go func() {
+ _, err := writeMemoryDropIn(path, 300, func() error { return errors.New("no systemd here") })
+ second <- err
+ }()
+ select {
+ case err := <-second:
+ t.Errorf("a second run finished (%v) while the first was still reloading", err)
+ case <-time.After(300 * time.Millisecond):
+ }
+ return nil
+ }
+ if changed, err := writeMemoryDropIn(path, 100, firstReload); !changed || err != nil {
+ t.Fatalf("first run: changed=%v err=%v, want true, nil", changed, err)
+ }
+ select {
+ case err := <-second:
+ if err == nil {
+ t.Fatal("the second run's reload was meant to fail")
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("the second run never ran after the first finished")
+ }
+ // The second run's debt is still on record, and the next run pays it.
+ if _, err := os.Stat(path + ".reload-pending"); err != nil {
+ t.Fatalf("the second run's marker is gone: %v", err)
+ }
+ reloads := 0
+ if changed, err := writeMemoryDropIn(path, 300, func() error { reloads++; return nil }); !changed || err != nil || reloads != 1 {
+ t.Fatalf("the run after: changed=%v err=%v reloads=%d, want true, nil, 1", changed, err, reloads)
+ }
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index d3c0f085a..00669f8d0 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -667,7 +667,7 @@ func TestPlan14RootReadCensus(t *testing.T) {
// Census ruling ls284 (#284): the litestream memory drop-in, the unit's
// restart backoff and the agent's restart-counter probe and its saved state add seven frozen
// declarations.
- seal = "4501bab20e84943df805b7727eba4aa64e21da84d56e80da5847187ddc6561c8"
+ seal = "13bc746ce6afb179c887894b36e18cbc0c1e7852e32317c6811aa8a8d3253c56"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/testdata/plan14-root-reads-ls284.tsv b/internal/provision/testdata/plan14-root-reads-ls284.tsv
index 06ecb0245..8a7611406 100644
--- a/internal/provision/testdata/plan14-root-reads-ls284.tsv
+++ b/internal/provision/testdata/plan14-root-reads-ls284.tsv
@@ -2,21 +2,21 @@
# The ruling is requested from the census owner on amadan #280; these rows state the branch's own account. Earlier overlays are immutable.
@legacy f942faf6b1bda4b171de3930c7a9f5627d4db32a2f802c5ecb10ef26410e0af9 internal/console/templates/metrics.html external legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=The alerts table shows a rule's detail beside its name. Same alert state objects, read by the same console sweep; no new source or authority. file:external@0 8732b0e3a4e60029b6c13109be12b15f0a48f51f0fb3c7578e6a52fc26e93140
internal/console/templates/metrics.html external legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=The alerts table shows a rule's detail beside its name. Same alert state objects, read by the same console sweep; no new source or authority. file:external@0 8732b0e3a4e60029b6c13109be12b15f0a48f51f0fb3c7578e6a52fc26e93140
-@legacy 4d3f9d6b930b32c1fd002d7a831aaf4c7317afd20972ba356031c135ffef8a1b internal/host/restartwatch.go loadRestartWatch legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=The agent, as carlos, reads back its own restart-watch state from the data directory's monitor folder, beside the alert state it already keeps there. Feeds an alert only; never read by root and no root decision. file:ReadFile@81 1507415bd6d74eb96c6e23de214e9da98cfa6be25a4888d3ecf95b56c45d508c
-internal/host/restartwatch.go loadRestartWatch legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=The agent, as carlos, reads back its own restart-watch state from the data directory's monitor folder, beside the alert state it already keeps there. Feeds an alert only; never read by root and no root decision. file:ReadFile@81 1507415bd6d74eb96c6e23de214e9da98cfa6be25a4888d3ecf95b56c45d508c
+@legacy 6d9ea00f5c39150015f9d150c7886e01cd9472ed5835b6279122b8cfd30b781e internal/host/restartwatch.go loadRestartWatch legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=The agent, as carlos, reads back its own restart-watch state from the data directory's monitor folder, beside the alert state it already keeps there. Feeds an alert only; never read by root and no root decision. file:ReadFile@81 d1c0460e2692dfa99a1a5f2454c6d47d5678edcdf5830ed495e323c126fab26a
+internal/host/restartwatch.go loadRestartWatch legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=The agent, as carlos, reads back its own restart-watch state from the data directory's monitor folder, beside the alert state it already keeps there. Feeds an alert only; never read by root and no root decision. file:ReadFile@81 d1c0460e2692dfa99a1a5f2454c6d47d5678edcdf5830ed495e323c126fab26a
@legacy bc86270eeccc35765f5ecd7771202ed99605065e5e703c23bc14934aa79ddb3d internal/host/restartwatch.go platformUnitRestarts legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=The agent, as carlos, asks systemd for the restart counters of loaded carlos-*.service units; a fixed command with no registry or tenant input, the same class as internal/host/probes.go adoptTimerActive under J4 root-input. Feeds an alert only; no root decision. file:CommandContext@166 fde6a651c0a8a66c324124a2bd3085959c9b575cae08e2d65d2eb666761b0dcc
internal/host/restartwatch.go platformUnitRestarts legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=The agent, as carlos, asks systemd for the restart counters of loaded carlos-*.service units; a fixed command with no registry or tenant input, the same class as internal/host/probes.go adoptTimerActive under J4 root-input. Feeds an alert only; no root decision. file:CommandContext@166 fde6a651c0a8a66c324124a2bd3085959c9b575cae08e2d65d2eb666761b0dcc
@legacy e39e5045ef43883a78c327f56fbd90e501ff3c012c8ddf512b0f7585b75623c1 internal/host/units/carlos-litestream.service external legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Memory cap default 512M with GOMEMLIMIT, and restart backoff (RestartSteps, RestartMaxDelaySec). User, paths, command and sandboxing unchanged; no new source or authority. file:external@0 2fc82f963202103a16a89aaecdcaeb89c6b9fee8e9d0e09cb76389a2326c1714
internal/host/units/carlos-litestream.service external legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Memory cap default 512M with GOMEMLIMIT, and restart backoff (RestartSteps, RestartMaxDelaySec). User, paths, command and sandboxing unchanged; no new source or authority. file:external@0 2fc82f963202103a16a89aaecdcaeb89c6b9fee8e9d0e09cb76389a2326c1714
-@legacy db8b810137dce7ad80730610211e0a8a2262242dacedf00621e2cca9b3818dcf internal/ops/litestream_memory.go writeMemoryDropIn legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Root reads the root-owned drop-in this verb itself wrote, and lstats its own reload-pending marker beside it, to decide whether to rewrite and reload; the same compare-before-write as writeIfChanged under J4 root-input. The path is fixed or an operator-supplied flag. file:Lstat@342,file:ReadFile@183 32b3e20e28898170b36664fe67715582425888f7e90aeaa9c2afb561799b28fe
-internal/ops/litestream_memory.go writeMemoryDropIn legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Root reads the root-owned drop-in this verb itself wrote, and lstats its own reload-pending marker beside it, to decide whether to rewrite and reload; the same compare-before-write as writeIfChanged under J4 root-input. The path is fixed or an operator-supplied flag. file:Lstat@342,file:ReadFile@183 32b3e20e28898170b36664fe67715582425888f7e90aeaa9c2afb561799b28fe
+@legacy 8d4ad62d122c3adc99b4ac55ff45d965ec1982a024245825a2a37dd82acec4e1 internal/ops/litestream_memory.go writeMemoryDropIn legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Root opens the drop-in's own directory to hold a lock, reads the root-owned drop-in this verb itself wrote, and lstats its own reload-pending marker beside it, to decide whether to rewrite and reload; the same compare-before-write as writeIfChanged under J4 root-input. The path is fixed or an operator-supplied flag. file:Lstat@613,file:Open@264,file:ReadFile@454 9b0fb46e224f97d4740165498abe45a1c25004a955c6176bce20b08398b46f7c
+internal/ops/litestream_memory.go writeMemoryDropIn legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Root opens the drop-in's own directory to hold a lock, reads the root-owned drop-in this verb itself wrote, and lstats its own reload-pending marker beside it, to decide whether to rewrite and reload; the same compare-before-write as writeIfChanged under J4 root-input. The path is fixed or an operator-supplied flag. file:Lstat@613,file:Open@264,file:ReadFile@454 9b0fb46e224f97d4740165498abe45a1c25004a955c6176bce20b08398b46f7c
@legacy dc2175dd0445552045146497dd473327ec0ddca2de728f6b8a2c5c755b82420d internal/ops/ops.go runLitestream legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Same registry open and prior-config read. Adds one root-owned write, the memory drop-in beside the unit, sized from the database count this run computed, and a daemon-reload; no new input is trusted. data-literal@214,file:Open@1383,file:ReadFile@2357 380a01961cc02f2a18673bc4ad0a0bc1112905d4726f8ab9e7b370da36686306
internal/ops/ops.go runLitestream legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Same registry open and prior-config read. Adds one root-owned write, the memory drop-in beside the unit, sized from the database count this run computed, and a daemon-reload; no new input is trusted. data-literal@214,file:Open@1383,file:ReadFile@2357 380a01961cc02f2a18673bc4ad0a0bc1112905d4726f8ab9e7b370da36686306
@legacy cb8030cd13f53803aca45d166742f194f1cb00e1d881263a776b79a334a37189 internal/ops/ops.go systemdDaemonReload legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Root runs a fixed systemctl daemon-reload with no caller input, the same class as internal/host/bootstrap.go systemctl under J4 root-input. file:Command@48 3edadda9e71b806a78d14160f2da0b6091212c4ee55933a327856be811290a74
internal/ops/ops.go systemdDaemonReload legacy accepted-legacy accepted-legacy;rev3=census-ruling-ls284;basis=Root runs a fixed systemctl daemon-reload with no caller input, the same class as internal/host/bootstrap.go systemctl under J4 root-input. file:Command@48 3edadda9e71b806a78d14160f2da0b6091212c4ee55933a327856be811290a74
@coverage internal/console 85d4f9521a2acc08aa1276f414be41cea32aa4275f761546e77e951d0d58b558
@coverage internal/console/templates e622e1e901447dc067aba32e4e520eef4279ffcdd807695f012fd38f646d48b4
-@coverage internal/host 563e0efbe9bd9de3e4d6844cb6d650d5cd221681fed5594ecd502d45cef68826
+@coverage internal/host 8bf2f3b97227361f378bbd8943d2d0e157a66f6f90baa5291fb8a13649d4e7de
@coverage internal/host/units e06e4a1a0d9d45f8e7494f0419834bf23e14fb2cd4861b94ef905bfaf795a7d1
@coverage internal/metrics 1adc51fc485ba895970c22277b7bff997c24ebededbd19f48a10e75cf531323a
-@coverage internal/ops 31e815c0a685f7ef7013c9295a1f570faff3b6846901c215617832b436ffffc1
+@coverage internal/ops 1bd2f9d768a424e8b1fbd1744bba475985b49f43e7ecca836bbadf593fe91ad0