Skip to content

Commit d0b7386

Browse files
committed
fix(daemon): publish status files atomically
1 parent ad34dc8 commit d0b7386

3 files changed

Lines changed: 392 additions & 1 deletion

File tree

‎internal/daemon/server.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ type ServerOptions struct {
4141
Now func() time.Time
4242
Log func(string)
4343
isAlive func(int) bool // test hook for the single-instance lock
44+
// replaceStatusFile and syncStatusParent are test hooks for the status-file
45+
// commit boundary. nil selects the production filesystem operations.
46+
replaceStatusFile func(src, dst string) error
47+
syncStatusParent func(dir string) error
4448
}
4549

4650
// NewServer validates options and builds a Server.
@@ -208,7 +212,12 @@ func (s *Server) writeStatusFile() error {
208212
if err != nil {
209213
return err
210214
}
211-
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
215+
if err := writeStatusFileAtomically(s.opts.Paths.Status, data, 0o600, s.opts.replaceStatusFile, s.opts.syncStatusParent); err != nil {
216+
var committed *statusFileCommittedError
217+
if errors.As(err, &committed) {
218+
s.logf("daemon: %v", committed)
219+
return nil
220+
}
212221
return fmt.Errorf("daemon: write status file: %w", err)
213222
}
214223
return nil

‎internal/daemon/status_file.go‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package daemon
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"runtime"
9+
10+
"github.com/Gitlawb/zero/internal/fsutil"
11+
)
12+
13+
const statusTempPattern = ".daemon-status-*"
14+
15+
// statusFileCommittedError reports a warning that happened after the complete
16+
// status document was already published. Callers must not tear down the daemon
17+
// as though publication failed.
18+
type statusFileCommittedError struct {
19+
cause error
20+
}
21+
22+
func (err *statusFileCommittedError) Error() string {
23+
return fmt.Sprintf("status file publication committed with warning: %v", err.cause)
24+
}
25+
26+
func (err *statusFileCommittedError) Unwrap() error {
27+
return err.cause
28+
}
29+
30+
// writeStatusFileAtomically stages a complete, synced sibling file before
31+
// publishing it over path, so it never truncates the live document in place.
32+
// Unix replacement is observer-atomic; Windows uses fsutil's DACL-preserving
33+
// replacement and may briefly leave the path absent, but never exposes partial
34+
// contents.
35+
func writeStatusFileAtomically(
36+
path string,
37+
data []byte,
38+
perm os.FileMode,
39+
replace func(src, dst string) error,
40+
syncParent func(dir string) error,
41+
) error {
42+
dir := filepath.Dir(path)
43+
temp, err := os.CreateTemp(dir, statusTempPattern)
44+
if err != nil {
45+
return fmt.Errorf("create temporary status file: %w", err)
46+
}
47+
tempPath := temp.Name()
48+
closed := false
49+
defer func() {
50+
if !closed {
51+
_ = temp.Close()
52+
}
53+
_ = os.Remove(tempPath)
54+
}()
55+
56+
if err := temp.Chmod(perm); err != nil {
57+
return fmt.Errorf("set temporary status file permissions: %w", err)
58+
}
59+
if _, err := temp.Write(data); err != nil {
60+
return fmt.Errorf("write temporary status file: %w", err)
61+
}
62+
if err := temp.Sync(); err != nil {
63+
return fmt.Errorf("sync temporary status file: %w", err)
64+
}
65+
if err := temp.Close(); err != nil {
66+
return fmt.Errorf("close temporary status file: %w", err)
67+
}
68+
closed = true
69+
70+
var committedWarning error
71+
if err := fsutil.ReplaceWithRetry(tempPath, path, replace); err != nil {
72+
var committed *fsutil.CommittedReplacementCleanupError
73+
if !errors.As(err, &committed) {
74+
return fmt.Errorf("replace status file: %w", err)
75+
}
76+
committedWarning = fmt.Errorf("clean up replaced status file: %w", err)
77+
}
78+
if syncParent == nil {
79+
syncParent = syncStatusParentDir
80+
}
81+
if err := syncParent(dir); err != nil {
82+
committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err))
83+
}
84+
if committedWarning != nil {
85+
return &statusFileCommittedError{cause: committedWarning}
86+
}
87+
return nil
88+
}
89+
90+
// syncStatusParentDir makes the replacement directory entry durable on
91+
// platforms that support syncing directory handles. Windows replacement is
92+
// best-effort durable because Go cannot fsync a directory there.
93+
func syncStatusParentDir(dir string) error {
94+
if runtime.GOOS == "windows" {
95+
return nil
96+
}
97+
directory, err := os.Open(dir)
98+
if err != nil {
99+
return err
100+
}
101+
syncErr := directory.Sync()
102+
closeErr := directory.Close()
103+
if syncErr != nil {
104+
return syncErr
105+
}
106+
return closeErr
107+
}

0 commit comments

Comments
 (0)