Skip to content

Commit d38079f

Browse files
committed
internal/dao, state, env: drop JSON envelope, add shared atomic-write helper
High: the etag metadata file's JSON envelope required parsing on every GetPackage, and a torn or short read (concurrent UpdatePackage without this envelope's atomicity) parsed as garbage that UpgradeChannel would treat as a genuine etag change, triggering evictPackage's rm -rf of a package tree that may be actively executing. Drop the envelope entirely: the etag is now the exact raw bytes every Hermit version has always written, stored via the new shared util.AtomicWriteFile; UpdateCheckedAt moves to a separate ".checked" sidecar file so mixed Hermit versions sharing a state directory keep reading and writing the etag identically, with graceful fallback to the etag file's mtime when the sidecar is missing or unparseable. Medium: sweep stale ".tmp-*" scratch files left behind by a killed process (whose deferred cleanup never got to run) out of the metadata directory on DAO Open, bounded by a generous age threshold so a genuinely in-flight write from another process is never touched. Extend the same atomic-write treatment to env.go's SetEnv/DelEnv, and give state.removeRecursive an atomic (rename-aside) removal via the new util.RemoveAllAtomic, matching the reader-visible-window fix util.SwapDir already applies to replacement. Also documents that WritePackageState storing a zero UpdateCheckedAt as "now" (via dao.UpdatePackage) is harmless when UpdateInterval == 0, since EnsureChannelIsUpToDate short-circuits before ever consulting it.
1 parent f8bd678 commit d38079f

7 files changed

Lines changed: 227 additions & 67 deletions

File tree

env.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,7 +1211,7 @@ func (e *Env) SetEnv(key, value string) error {
12111211
if err != nil {
12121212
return errors.WithStack(err)
12131213
}
1214-
return os.WriteFile(e.configFile, data, 0600)
1214+
return errors.WithStack(util.AtomicWriteFile(e.configFile, data, 0600))
12151215
}
12161216

12171217
// DelEnv deletes a custom environment variable.
@@ -1221,7 +1221,7 @@ func (e *Env) DelEnv(key string) error {
12211221
if err != nil {
12221222
return errors.WithStack(err)
12231223
}
1224-
return os.WriteFile(e.configFile, data, 0600)
1224+
return errors.WithStack(util.AtomicWriteFile(e.configFile, data, 0600))
12251225
}
12261226

12271227
// Clean parts of the hermit system.

internal/dao/dao.go

Lines changed: 89 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
package dao
22

33
import (
4-
"encoding/json"
54
"io"
65
"os"
76
"path/filepath"
7+
"strings"
88
"time"
99

1010
"github.com/cashapp/hermit/errors"
11+
"github.com/cashapp/hermit/util"
1112
)
1213

14+
// staleScratchAge is how old a leftover ".tmp-*" file must be before Open
15+
// considers it abandoned rather than an in-flight write from another
16+
// process.
17+
const staleScratchAge = 24 * time.Hour
18+
1319
// DAO abstracts away the database access
1420
type DAO struct {
1521
stateDir string
@@ -28,109 +34,135 @@ func Open(stateDir string) (*DAO, error) {
2834
if err := os.MkdirAll(metadataDir, 0700); err != nil && !os.IsExist(err) {
2935
return nil, errors.WithStack(err)
3036
}
37+
sweepStaleScratchFiles(metadataDir)
3138
return &DAO{stateDir: stateDir, metadataDir: metadataDir}, nil
3239
}
3340

41+
// sweepStaleScratchFiles removes leftover ".tmp-*" files from
42+
// util.AtomicWriteFile calls that were interrupted by a killed process (eg.
43+
// SIGKILL, which the writer's deferred os.Remove cannot run for). Best
44+
// effort: errors are ignored, and a generous age threshold avoids racing a
45+
// concurrent, genuinely in-flight write from another Hermit process.
46+
func sweepStaleScratchFiles(metadataDir string) {
47+
entries, err := os.ReadDir(metadataDir)
48+
if err != nil {
49+
return
50+
}
51+
for _, entry := range entries {
52+
if !strings.Contains(entry.Name(), ".tmp-") {
53+
continue
54+
}
55+
info, err := entry.Info()
56+
if err != nil || time.Since(info.ModTime()) < staleScratchAge {
57+
continue
58+
}
59+
_ = os.Remove(filepath.Join(metadataDir, entry.Name()))
60+
}
61+
}
62+
3463
// Dump content of database to w.
3564
func (d *DAO) Dump(w io.Writer) error {
3665
return nil
3766
}
3867

39-
// metadataFile is the on-disk encoding of Package written by UpdatePackage.
40-
//
41-
// UpdateCheckedAt is stored explicitly, rather than inferred from the file's
42-
// mtime (as earlier versions of Hermit did): mtime can't be trusted to mean
43-
// "the moment this etag was written" -- it's disturbed by anything else that
44-
// touches the file (eg. a backup/restore), and differs in precision across
45-
// filesystems.
46-
type metadataFile struct {
47-
Etag string `json:"etag"`
48-
UpdateCheckedAt time.Time `json:"update_checked_at"`
49-
}
50-
5168
// GetPackage returns information for a specific package.
69+
//
70+
// The etag is stored as the raw, unencoded file content at metadataPath: this
71+
// is the exact on-disk format every Hermit version has ever written, so a
72+
// mixed-version fleet sharing a state directory can always read and write it
73+
// identically. UpdateCheckedAt is stored separately, in the sidecar file at
74+
// checkedAtPath, because mtime can't be trusted to mean "the moment this etag
75+
// was written" -- it's disturbed by anything else that touches the file (eg.
76+
// a backup/restore), and differs in precision across filesystems. An older
77+
// Hermit version, or a first-ever check, has no such sidecar: fall back to
78+
// the etag file's mtime in that case, as GetPackage always did previously.
5279
func (d *DAO) GetPackage(pkgRef string) (*Package, error) {
53-
r, err := os.Open(d.metadataPath(pkgRef))
80+
etag, err := os.ReadFile(d.metadataPath(pkgRef))
5481
if os.IsNotExist(err) {
5582
return nil, nil
5683
}
5784
if err != nil {
5885
return nil, errors.WithStack(err)
5986
}
60-
defer r.Close()
61-
info, err := r.Stat()
87+
checkedAt, err := d.readCheckedAt(pkgRef)
6288
if err != nil {
6389
return nil, errors.WithStack(err)
6490
}
65-
data, err := io.ReadAll(r)
66-
if err != nil {
67-
return nil, errors.WithStack(err)
68-
}
69-
var mf metadataFile
70-
if err := json.Unmarshal(data, &mf); err != nil {
71-
// Metadata file written by a Hermit version prior to the
72-
// introduction of this format: it contains only the raw etag, with
73-
// no recorded check time. Fall back to the file's mtime, as
74-
// GetPackage always did previously.
75-
return &Package{
76-
Etag: string(data),
77-
UpdateCheckedAt: info.ModTime(),
78-
}, nil
91+
if checkedAt.IsZero() {
92+
info, err := os.Stat(d.metadataPath(pkgRef))
93+
if err != nil {
94+
return nil, errors.WithStack(err)
95+
}
96+
checkedAt = info.ModTime()
7997
}
8098
return &Package{
81-
Etag: mf.Etag,
82-
UpdateCheckedAt: mf.UpdateCheckedAt,
99+
Etag: string(etag),
100+
UpdateCheckedAt: checkedAt,
83101
}, nil
84102
}
85103

86-
// UpdatePackage updates the update check time, etag, and the used at time for a package.
104+
func (d *DAO) readCheckedAt(pkgRef string) (time.Time, error) {
105+
data, err := os.ReadFile(d.checkedAtPath(pkgRef))
106+
if os.IsNotExist(err) {
107+
return time.Time{}, nil
108+
}
109+
if err != nil {
110+
return time.Time{}, errors.WithStack(err)
111+
}
112+
checkedAt, err := time.Parse(time.RFC3339Nano, string(data))
113+
if err != nil {
114+
// A torn read of the sidecar (or one written by an incompatible
115+
// future version) is not fatal: fall back to mtime rather than
116+
// failing the whole lookup.
117+
return time.Time{}, nil //nolint:nilerr
118+
}
119+
return checkedAt, nil
120+
}
121+
122+
// UpdatePackage updates the update check time and etag for a package.
87123
//
88-
// The write is atomic: content is written to a temp file in the same
89-
// directory, then renamed into place. os.WriteFile is not atomic -- it
90-
// truncates the existing file before writing the new content -- so a
124+
// Both files are written atomically: content is written to a temp file in
125+
// the same directory, then renamed into place. os.WriteFile is not atomic --
126+
// it truncates the existing file before writing the new content -- so a
91127
// concurrent GetPackage could otherwise observe a torn read (empty or
92128
// partial etag). A torn read here is not merely cosmetic: UpgradeChannel
93129
// treats any etag change, including a corrupted one, as a reason to
94130
// evictPackage (rm -rf) a package tree that another process may be actively
95131
// executing.
132+
//
133+
// The etag is written first: if the process dies between the two writes, a
134+
// concurrent GetPackage falls back to the etag file's mtime for
135+
// UpdateCheckedAt (see above), which is the same degraded-but-safe behaviour
136+
// as running against an older Hermit version that never writes the sidecar
137+
// at all.
96138
func (d *DAO) UpdatePackage(pkgRef string, pkg *Package) error {
97-
path := d.metadataPath(pkgRef)
98139
checkedAt := pkg.UpdateCheckedAt
99140
if checkedAt.IsZero() {
100141
checkedAt = time.Now()
101142
}
102-
data, err := json.Marshal(metadataFile{Etag: pkg.Etag, UpdateCheckedAt: checkedAt})
103-
if err != nil {
143+
if err := util.AtomicWriteFile(d.metadataPath(pkgRef), []byte(pkg.Etag), 0600); err != nil {
104144
return errors.WithStack(err)
105145
}
106-
107-
tmp, err := os.CreateTemp(d.metadataDir, filepath.Base(path)+".tmp-*")
108-
if err != nil {
109-
return errors.WithStack(err)
110-
}
111-
tmpPath := tmp.Name()
112-
// Harmless once the rename below succeeds: nothing left to remove.
113-
defer os.Remove(tmpPath)
114-
115-
_, writeErr := tmp.Write(data)
116-
closeErr := tmp.Close()
117-
if writeErr != nil {
118-
return errors.WithStack(writeErr)
119-
}
120-
if closeErr != nil {
121-
return errors.WithStack(closeErr)
122-
}
123-
return errors.WithStack(os.Rename(tmpPath, path))
146+
return errors.WithStack(util.AtomicWriteFile(d.checkedAtPath(pkgRef), []byte(checkedAt.Format(time.RFC3339Nano)), 0600))
124147
}
125148

126149
// DeletePackage removes a package from the DB
127150
func (d *DAO) DeletePackage(pkgRef string) error {
128151
if err := os.Remove(d.metadataPath(pkgRef)); err != nil {
129152
return errors.WithStack(err)
130153
}
154+
// The checked-at sidecar may not exist (eg. written by an older Hermit
155+
// version); that's not an error.
156+
if err := os.Remove(d.checkedAtPath(pkgRef)); err != nil && !os.IsNotExist(err) {
157+
return errors.WithStack(err)
158+
}
131159
return nil
132160
}
133161

134162
func (d *DAO) metadataPath(pkgRef string) string {
135163
return filepath.Join(d.metadataDir, pkgRef+".etag")
136164
}
165+
166+
func (d *DAO) checkedAtPath(pkgRef string) string {
167+
return filepath.Join(d.metadataDir, pkgRef+".checked")
168+
}

internal/dao/dao_test.go

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,15 @@ func TestUpdateAndGetPackageRoundTrip(t *testing.T) {
3737
assert.True(t, checkedAt.Equal(got.UpdateCheckedAt), "expected %s, got %s", checkedAt, got.UpdateCheckedAt)
3838
}
3939

40-
// TestGetPackageLegacyFormat verifies that a metadata file written by a
41-
// Hermit version prior to the introduction of the JSON envelope (ie. one
42-
// containing only the raw etag, with no recorded check time) is still read
43-
// correctly, falling back to the file's mtime for UpdateCheckedAt exactly as
44-
// GetPackage always did previously.
45-
func TestGetPackageLegacyFormat(t *testing.T) {
40+
// TestGetPackageMissingCheckedAtSidecar verifies that a metadata directory
41+
// containing only the raw etag file, with no ".checked" sidecar, is still
42+
// read correctly, falling back to the etag file's mtime for
43+
// UpdateCheckedAt. This is the on-disk state left by a Hermit version prior
44+
// to the introduction of the sidecar (which wrote only the raw etag, in
45+
// exactly this format) -- the two are indistinguishable, which is the point:
46+
// an older Hermit binary sharing this state directory can still read and
47+
// write the etag file unmodified.
48+
func TestGetPackageMissingCheckedAtSidecar(t *testing.T) {
4649
d, err := Open(t.TempDir())
4750
assert.NoError(t, err)
4851

@@ -112,8 +115,36 @@ func TestUpdatePackageAtomicNoTornRead(t *testing.T) {
112115
assert.NoError(t, <-readerErr)
113116
}
114117

118+
// TestOpenSweepsStaleScratchFiles verifies that Open cleans up an old,
119+
// abandoned ".tmp-*" file left behind by a process killed mid-write, but
120+
// leaves a recent one alone (it may belong to a write still in flight in
121+
// another process).
122+
func TestOpenSweepsStaleScratchFiles(t *testing.T) {
123+
stateDir := t.TempDir()
124+
metadataDir := filepath.Join(stateDir, "metadata")
125+
assert.NoError(t, os.MkdirAll(metadataDir, 0700))
126+
127+
stale := filepath.Join(metadataDir, "pkg@1.0.0.etag.tmp-stale")
128+
assert.NoError(t, os.WriteFile(stale, []byte("abandoned"), 0600))
129+
old := time.Now().Add(-48 * time.Hour)
130+
assert.NoError(t, os.Chtimes(stale, old, old))
131+
132+
fresh := filepath.Join(metadataDir, "pkg@2.0.0.etag.tmp-fresh")
133+
assert.NoError(t, os.WriteFile(fresh, []byte("in-flight"), 0600))
134+
135+
_, err := Open(stateDir)
136+
assert.NoError(t, err)
137+
138+
_, err = os.Stat(stale)
139+
assert.True(t, os.IsNotExist(err), "stale scratch file should have been swept")
140+
_, err = os.Stat(fresh)
141+
assert.NoError(t, err, "recent scratch file should not have been swept")
142+
}
143+
115144
// TestUpdatePackageLeavesNoTempFiles guards against leaking the scratch temp
116-
// file UpdatePackage writes before renaming into place.
145+
// files UpdatePackage writes before renaming into place -- there are two
146+
// atomic writes per call (the ".etag" file and the ".checked" sidecar), each
147+
// with its own temp file.
117148
func TestUpdatePackageLeavesNoTempFiles(t *testing.T) {
118149
stateDir := t.TempDir()
119150
d, err := Open(stateDir)
@@ -123,9 +154,12 @@ func TestUpdatePackageLeavesNoTempFiles(t *testing.T) {
123154

124155
entries, err := os.ReadDir(filepath.Join(stateDir, "metadata"))
125156
assert.NoError(t, err)
157+
var names []string
126158
for _, entry := range entries {
127159
if strings.Contains(entry.Name(), ".tmp-") {
128160
t.Fatalf("leaked temp file: %s", entry.Name())
129161
}
162+
names = append(names, entry.Name())
130163
}
164+
assert.Equal(t, []string{"pkg@1.0.0.checked", "pkg@1.0.0.etag"}, names)
131165
}

state/state.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,13 @@ func (s *State) ReadPackageState(pkg *manifest.Package) {
262262
}
263263

264264
// WritePackageState updates the fields and usage time stamp of the given package
265+
//
266+
// A zero UpdateCheckedAt (when p.UpdateInterval <= 0, ie. this package never
267+
// checks for updates) is stored as "now" by dao.UpdatePackage rather than as
268+
// a literal zero time -- see its docs. That's harmless here specifically:
269+
// EnsureChannelIsUpToDate short-circuits on UpdateInterval == 0 before ever
270+
// consulting UpdatedAt, so the substituted value is never read back for a
271+
// package in this state.
265272
func (s *State) WritePackageState(p *manifest.Package) error {
266273
updatedAt := time.Time{}
267274
if p.UpdateInterval > 0 {
@@ -304,7 +311,7 @@ func (s *State) removeRecursive(b *ui.Task, dest string) error {
304311
return errors.WithStack(err)
305312
})
306313
task.Debugf("rm -rf %s", dest)
307-
return errors.WithStack(os.RemoveAll(dest))
314+
return errors.WithStack(util.RemoveAllAtomic(dest))
308315
}
309316

310317
// CacheAndUnpack downloads a package and extracts it if it is not present.

util/atomicfile.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package util
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
7+
"github.com/cashapp/hermit/errors"
8+
)
9+
10+
// AtomicWriteFile writes data to path atomically: it is written to a temp
11+
// file in the same directory, then renamed into place. Unlike os.WriteFile,
12+
// which truncates the existing file before writing, a concurrent reader can
13+
// never observe an empty or partially-written file.
14+
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
15+
dir := filepath.Dir(path)
16+
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
17+
if err != nil {
18+
return errors.WithStack(err)
19+
}
20+
tmpPath := tmp.Name()
21+
// Harmless once the rename below succeeds: nothing left to remove.
22+
defer os.Remove(tmpPath)
23+
24+
_, writeErr := tmp.Write(data)
25+
closeErr := tmp.Close()
26+
if writeErr != nil {
27+
return errors.WithStack(writeErr)
28+
}
29+
if closeErr != nil {
30+
return errors.WithStack(closeErr)
31+
}
32+
if err := os.Chmod(tmpPath, perm); err != nil {
33+
return errors.WithStack(err)
34+
}
35+
return errors.WithStack(os.Rename(tmpPath, path))
36+
}

0 commit comments

Comments
 (0)