返回 DeepSeek-Reasonix
atomicwrite.go
根目录 / internal / fileutil / atomicwrite.go
1 package fileutil
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "time"
8 )
9
10 var (
11 maxReplaceRetries = 12
12 replaceRetryBase = 20 * time.Millisecond
13
14 // renameFile is a test seam: the two rename failure classes ReplaceFile
15 // distinguishes (transient lock vs cross-device) cannot be provoked
16 // portably on a real filesystem.
17 renameFile = os.Rename
18 )
19
20 // AtomicWriteFile writes data to a sibling temporary file, fsyncs it, then
21 // publishes it via ReplaceFile. On filesystems that support replacement rename,
22 // readers see either the old file or the complete new file. ReplaceFile retains
23 // its compatibility copy fallback for Windows filter drivers that reject a
24 // same-directory rename as cross-device; callers that cannot tolerate that
25 // non-atomic fallback must use AtomicWriteFileStrict.
26 func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
27 return atomicWriteFile(path, data, perm, true)
28 }
29
30 // AtomicWriteFileStrict publishes data only through an atomic rename. Unlike
31 // AtomicWriteFile, a cross-device/filter-driver error is returned without ever
32 // truncating path. Use it for commit pointers whose corruption would make the
33 // surrounding state impossible to recover automatically.
34 func AtomicWriteFileStrict(path string, data []byte, perm os.FileMode) error {
35 return atomicWriteFile(path, data, perm, false)
36 }
37
38 func atomicWriteFile(path string, data []byte, perm os.FileMode, allowCrossDeviceCopy bool) error {
39 tmpPath, err := writeAtomicTemp(path, data, perm)
40 if err != nil {
41 return err
42 }
43 if err := replaceFile(tmpPath, path, allowCrossDeviceCopy); err != nil {
44 os.Remove(tmpPath)
45 return err
46 }
47 return nil
48 }
49
50 // AtomicCreateFile publishes a complete file only when path is still absent.
51 // It is the non-overwriting counterpart to AtomicWriteFile: a concurrent writer
52 // that creates path wins, and its file is never replaced.
53 func AtomicCreateFile(path string, data []byte, perm os.FileMode) error {
54 tmpPath, err := writeAtomicTemp(path, data, perm)
55 if err != nil {
56 return err
57 }
58 defer os.Remove(tmpPath)
59 if err := os.Link(tmpPath, path); err != nil {
60 return fmt.Errorf("publish new file %s: %w", path, err)
61 }
62 return nil
63 }
64
65 func writeAtomicTemp(path string, data []byte, perm os.FileMode) (string, error) {
66 dir := filepath.Dir(path)
67 dirPerm := os.FileMode(0o755)
68 if perm&0o077 == 0 {
69 dirPerm = 0o700
70 }
71 if err := os.MkdirAll(dir, dirPerm); err != nil {
72 return "", fmt.Errorf("create dir for %s: %w", path, err)
73 }
74 tmp, err := os.CreateTemp(dir, ".atomic-*.tmp")
75 if err != nil {
76 return "", fmt.Errorf("create tmp for %s: %w", path, err)
77 }
78 tmpPath := tmp.Name()
79 if _, err := tmp.Write(data); err != nil {
80 tmp.Close()
81 os.Remove(tmpPath)
82 return "", fmt.Errorf("write tmp for %s: %w", path, err)
83 }
84 if err := tmp.Sync(); err != nil {
85 tmp.Close()
86 os.Remove(tmpPath)
87 return "", fmt.Errorf("fsync tmp for %s: %w", path, err)
88 }
89 // Chmod the still-open handle, before Close, so there is no window between
90 // close and a path-based chmod for another process (Windows AV / search
91 // indexer) to grab or move the tmp and make the chmod fail with "file not
92 // found". CreateTemp makes a 0600 file, so this only widens when perm asks.
93 if err := tmp.Chmod(perm); err != nil {
94 tmp.Close()
95 os.Remove(tmpPath)
96 return "", fmt.Errorf("chmod tmp for %s: %w", path, err)
97 }
98 if err := tmp.Close(); err != nil {
99 os.Remove(tmpPath)
100 return "", fmt.Errorf("close tmp for %s: %w", path, err)
101 }
102 return tmpPath, nil
103 }
104
105 // ReplaceFile renames tmp onto dest, publishing the new content atomically: a
106 // reader concurrent with the replace sees either the old file or the complete
107 // new one. The rename can fail in two ways, and they are handled differently:
108 //
109 // - A transient lock on dest (antivirus, the search indexer, a concurrent
110 // reader without delete sharing) fails the rename for a few hundred ms.
111 // The rename is retried with backoff, and the last error is returned if
112 // the lock never clears. The failure is loud on purpose: falling back to
113 // an in-place copy here would truncate dest first, letting a racing
114 // reader observe an empty or half-written file — exactly the torn state
115 // AtomicWriteFile promises its callers (session leases, credentials,
116 // plugin state) can never happen.
117 // - Windows encryption-software filter drivers report a cross-device link
118 // (ERROR_NOT_SAME_DEVICE / EXDEV) even for a same-dir rename (#2696), and
119 // every retry fails identically. Only this class falls back to the
120 // non-atomic copy, and immediately — retrying a structurally impossible
121 // rename would only delay it. Torn reads remain possible in that degraded
122 // mode; it is the only way to write at all on such hosts, and
123 // rename-capable filesystems never take it.
124 //
125 // A missing tmp means the write itself failed and no retry can help.
126 func ReplaceFile(tmp, dest string) error {
127 return replaceFile(tmp, dest, true)
128 }
129
130 func replaceFile(tmp, dest string, allowCrossDeviceCopy bool) error {
131 var err error
132 for attempt := 0; ; attempt++ {
133 if err = renameFile(tmp, dest); err == nil {
134 return nil
135 }
136 if renameCrossesDevice(err) {
137 if !allowCrossDeviceCopy {
138 return err
139 }
140 if copyOnto(tmp, dest) == nil {
141 return nil
142 }
143 return err
144 }
145 if attempt >= maxReplaceRetries || !fileExists(tmp) {
146 return err
147 }
148 time.Sleep(time.Duration(attempt+1) * replaceRetryBase)
149 }
150 }
151
152 func fileExists(path string) bool {
153 _, err := os.Stat(path)
154 return err == nil
155 }
156
157 // copyOnto is the non-atomic last resort for hosts whose filesystem cannot
158 // rename tmp onto dest at all (see ReplaceFile). It truncates dest in place,
159 // so a concurrent reader can observe an empty or half-written file — it must
160 // never run for failures a retry could clear.
161 func copyOnto(tmp, dest string) error {
162 info, err := os.Stat(tmp)
163 if err != nil {
164 return err
165 }
166 data, err := os.ReadFile(tmp)
167 if err != nil {
168 return err
169 }
170 if err := os.WriteFile(dest, data, info.Mode().Perm()); err != nil {
171 return err
172 }
173 // WriteFile keeps an existing dest's mode, so re-apply tmp's mode to match
174 // what the rename would have done (a 0600 config tmp must not widen to 0644).
175 _ = os.Chmod(dest, info.Mode().Perm())
176 _ = os.Remove(tmp)
177 return nil
178 }
179
179 lines GO