carlos / platform Public

release: bake clock Save uses a unique temp name, not a fixed .tmp

Two uids write channel-first-seen.json (root's hourly adopt one-shot,
carlos-edge's fast loop via BakeDir). A crash between write and rename
under the old "<path>.tmp" name left cross-uid residue: a root-owned
0644 leftover the carlos uid can never O_TRUNC-rewrite (EACCES, no
repair short of root's next hourly pass), aborting that uid's whole
Pass every 2-3s tick until then.

os.CreateTemp mints a fresh name per attempt, so a crash never leaves
a temp file the next Save has to contend with. It also creates 0600,
which LoadFirstSeen silently turns into an empty clock when the other
uid can't read it — freezing stable adoption for a fresh 72h. Chmod to
0644 before the rename closes that.

Also: adopt.go's PassFilter now builds the migrated-clock path via
FirstSeenPath(cfg.bakeDir) instead of duplicating the join, dropping
the now-unused filepath import; FirstSeenPath's doc notes it is the
legacy, read-only location now that BakeDir owns the writable clock;
and the "no stray temp file" test globs for the new name pattern
instead of stat-ing the old fixed one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell df644dcbdb28d16bddc7399aa2c758f9625f4fa0 parent c5ae525
3 files changed, +48 −7
  • internal/release/adopt.go +1 −2
  • internal/release/bake.go +40 −3
  • internal/release/bake_test.go +7 −2
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index a5420dc5a..c654bb1da 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -11,7 +11,6 @@ import (
"fmt"
"io"
"os"
- "path/filepath"
"runtime"
"time"
@@ -207,7 +206,7 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
now := time.Now()
bake := LoadFirstSeen(FirstSeenPath(optDir))
if cfg.bakeDir != "" {
- bake = LoadFirstSeenMigrating(filepath.Join(cfg.bakeDir, "channel-first-seen.json"), FirstSeenPath(optDir))
+ bake = LoadFirstSeenMigrating(FirstSeenPath(cfg.bakeDir), FirstSeenPath(optDir))
}
// bake.migrated forces this pass's Save even if nothing new is observed
// below: otherwise a changed-gated Save could leave the new path absent
diff --git a/internal/release/bake.go b/internal/release/bake.go
index ad1c8b189..ed87fbc43 100644
--- a/internal/release/bake.go
+++ b/internal/release/bake.go
@@ -55,7 +55,14 @@ func HoldFor(holds map[string]time.Duration, channel string) time.Duration {
return 72 * time.Hour
}
-// FirstSeenPath is where a box persists its bake clock, under its opt dir.
+// FirstSeenPath is the LEGACY bake clock location, under the box's opt dir.
+// It is read-only for every production caller now: BakeDir moved the
+// writable clock to the data dir (issue #13 — the sandboxed edge unit
+// cannot write under optDir, and must not be able to), and
+// LoadFirstSeenMigrating reaches back to this path only to carry an
+// existing box's observations across that move. Nothing in production
+// writes here anymore; a box still on the legacy path only reads it once,
+// on the pass that migrates it.
func FirstSeenPath(optDir string) string {
return filepath.Join(optDir, "channel-first-seen.json")
}
@@ -214,14 +221,44 @@ func (st *FirstSeen) Observe(acct, app, channel, version string, now time.Time)
// directory, which both uids have on /data, so either side can always
// replace the file — and a crash mid-write leaves the old content intact
// rather than a torn, half-written file.
+//
+// The temp name is minted fresh per attempt (os.CreateTemp's "channel-
+// first-seen-*.json"), never a fixed "<path>.tmp": two uids can each crash
+// between write and rename, and a fixed name would leave cross-uid residue —
+// a root-owned 0644 leftover that the carlos-uid loop then fails to
+// O_TRUNC-rewrite forever (EACCES, no repair path short of root's next
+// pass), aborting its whole Pass every tick until then. A unique name per
+// attempt means a crash never leaves a temp file another uid's next Save
+// must contend with. CreateTemp also mints its file 0600, which this
+// explicitly widens to 0644 with Chmod BEFORE the rename: a 0600 root:root
+// clock is unreadable by the carlos-uid loop, and LoadFirstSeen turns that
+// read failure into a silently EMPTY clock — not an error, just every
+// channel head baking from "now" again, freezing stable adoption for a
+// fresh 72h. Any failure path removes the temp file rather than leaving it
+// behind for the next Save to trip over.
func (st *FirstSeen) Save() error {
raw, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
- tmp := st.path + ".tmp"
- if err := os.WriteFile(tmp, raw, 0o644); err != nil {
+ f, err := os.CreateTemp(filepath.Dir(st.path), "channel-first-seen-*.json")
+ if err != nil {
+ return err
+ }
+ tmp := f.Name()
+ _, writeErr := f.Write(raw)
+ closeErr := f.Close()
+ if writeErr != nil {
+ os.Remove(tmp)
+ return writeErr
+ }
+ if closeErr != nil {
+ os.Remove(tmp)
+ return closeErr
+ }
+ if err := os.Chmod(tmp, 0o644); err != nil {
+ os.Remove(tmp)
return err
}
if err := os.Rename(tmp, st.path); err != nil {
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go
index 082f0720f..90077ebf6 100644
--- a/internal/release/bake_test.go
+++ b/internal/release/bake_test.go
@@ -336,8 +336,13 @@ func TestFirstSeenSaveReplacesFileItCannotWriteInPlace(t *testing.T) {
if err := st.Save(); err != nil {
t.Fatalf("Save must replace via rename, not an in-place write: %v", err)
}
- if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
- t.Fatalf("Save left a temp file behind (stat err: %v)", err)
+ // The temp name is minted fresh per attempt (CreateTemp's
+ // "channel-first-seen-*.json"), not a fixed "<path>.tmp" — glob for the
+ // pattern rather than stat-ing one fixed name.
+ if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "channel-first-seen-*.json")); err != nil {
+ t.Fatalf("glob for stray temp files: %v", err)
+ } else if len(matches) != 0 {
+ t.Fatalf("Save left a temp file behind: %v", matches)
}
raw, err := os.ReadFile(path)
if err != nil {