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>
3 files changed,
+48
−7
internal/release/adopt.go+1 −2internal/release/bake.go+40 −3internal/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 { |