返回 DeepSeek-Reasonix
atomicwrite.go
根目录 / internal / fileutil / atomicwrite.go
1 package fileutil
2
3 import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "runtime"
9 "syscall"
10 "time"
11 )
12
13 var (
14 maxReplaceRetries = 12
15 replaceRetryBase = 20 * time.Millisecond
16
17 // renameFile is a test seam: the two rename failure classes ReplaceFile
18 // distinguishes (transient lock vs cross-device) cannot be provoked
19 // portably on a real filesystem.
20 renameFile = os.Rename
21 )
22
23 // CrashPoint, when non-nil, runs before every durable write/replace. Tests use
24 // it to inject a process-crash panic at persistence boundaries; production
25 // leaves it nil.
26 var CrashPoint func(op, path string)
27
28 // Crash invokes the optional crash-consistency fault-injection hook.
29 func Crash(op, path string) {
30 if CrashPoint != nil {
31 CrashPoint(op, path)
32 }
33 }
34
35 // AtomicWriteFile writes via temp + fsync + ReplaceFile. On rename-capable
36 // filesystems readers see only the old or complete new file. ReplaceFile may
37 // copy on Windows filter-driver EXDEV; callers that cannot tolerate that must
38 // use AtomicWriteFileStrict.
39 func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
40 return atomicWriteFile(path, data, perm, true)
41 }
42
43 // AtomicWriteFileStrict publishes only via atomic rename (no EXDEV copy).
44 // After a successful rename it best-effort fsyncs the parent directory so the
45 // directory entry can survive power loss. A returned error always means the
46 // destination was not published; post-rename dir-sync problems are not errors
47 // (callers that roll back in-memory state on error would otherwise fork from
48 // the on-disk pointer).
49 func AtomicWriteFileStrict(path string, data []byte, perm os.FileMode) error {
50 return atomicWriteFile(path, data, perm, false)
51 }
52
53 // syncParentDirFn is the post-publish parent-dir fsync implementation.
54 // Tests replace it via SetSyncParentDirForTest.
55 var syncParentDirFn = syncParentDir
56
57 // SetSyncParentDirForTest replaces post-rename parent-dir fsync. Restore with
58 // the returned function. Production must leave the default in place.
59 func SetSyncParentDirForTest(fn func(path string) error) (restore func()) {
60 prev := syncParentDirFn
61 if fn == nil {
62 syncParentDirFn = syncParentDir
63 } else {
64 syncParentDirFn = fn
65 }
66 return func() { syncParentDirFn = prev }
67 }
68
69 func atomicWriteFile(path string, data []byte, perm os.FileMode, allowCrossDeviceCopy bool) error {
70 Crash("atomic-write", path)
71 tmpPath, err := writeAtomicTemp(path, data, perm)
72 if err != nil {
73 return err
74 }
75 if err := replaceFile(tmpPath, path, allowCrossDeviceCopy); err != nil {
76 os.Remove(tmpPath)
77 return err
78 }
79 // Strict only: parent-dir fsync is power-loss durability after publish.
80 // Never surface failures here — rename already committed the new file.
81 if !allowCrossDeviceCopy {
82 _ = syncParentDirFn(path)
83 }
84 return nil
85 }
86
87 // syncParentDir fsyncs path's parent after rename (including "."). Unsupported
88 // dir sync on Windows / some network FS is ignored.
89 func syncParentDir(path string) error {
90 dirPath := filepath.Dir(path)
91 if dirPath == "" {
92 dirPath = "."
93 }
94 f, err := os.Open(dirPath)
95 if err != nil {
96 return fmt.Errorf("open parent dir for fsync %s: %w", path, err)
97 }
98 defer f.Close()
99 if err := f.Sync(); err != nil {
100 if runtime.GOOS == "windows" || isDirSyncUnsupported(err) {
101 return nil
102 }
103 return fmt.Errorf("fsync parent dir for %s: %w", path, err)
104 }
105 return nil
106 }
107
108 func isDirSyncUnsupported(err error) bool {
109 return errors.Is(err, syscall.EINVAL) ||
110 errors.Is(err, syscall.ENOTSUP) ||
111 errors.Is(err, syscall.ENOSYS)
112 }
113
114 // AtomicCreateFile publishes a complete file only when path is still absent.
115 // It is the non-overwriting counterpart to AtomicWriteFile: a concurrent writer
116 // that creates path wins, and its file is never replaced.
117 func AtomicCreateFile(path string, data []byte, perm os.FileMode) error {
118 tmpPath, err := writeAtomicTemp(path, data, perm)
119 if err != nil {
120 return err
121 }
122 defer os.Remove(tmpPath)
123 if err := os.Link(tmpPath, path); err != nil {
124 return fmt.Errorf("publish new file %s: %w", path, err)
125 }
126 return nil
127 }
128
129 // AtomicOverwriteFile replaces an existing file's contents atomically while
130 // keeping the two properties a bare rename drops: the file's current permission
131 // bits (an executable script must not come back 0644) and the symlink target
132 // (a link must be written through, not replaced by a regular file). defaultPerm
133 // applies only when path does not exist yet.
134 func AtomicOverwriteFile(path string, data []byte, defaultPerm os.FileMode) error {
135 return atomicOverwriteFile(path, data, defaultPerm, true)
136 }
137
138 // AtomicOverwriteFileStrict preserves encoding callers' mode and symlink
139 // semantics without a non-atomic copy fallback on Windows filter drivers.
140 func AtomicOverwriteFileStrict(path string, data []byte, defaultPerm os.FileMode) error {
141 return atomicOverwriteFile(path, data, defaultPerm, false)
142 }
143
144 func atomicOverwriteFile(path string, data []byte, defaultPerm os.FileMode, allowCopy bool) error {
145 target := path
146 if resolved, err := filepath.EvalSymlinks(path); err == nil {
147 target = resolved
148 }
149 perm := defaultPerm
150 if info, err := os.Stat(target); err == nil {
151 perm = info.Mode().Perm()
152 }
153 return atomicWriteFile(target, data, perm, allowCopy)
154 }
155
156 func writeAtomicTemp(path string, data []byte, perm os.FileMode) (string, error) {
157 dir := filepath.Dir(path)
158 dirPerm := os.FileMode(0o755)
159 if perm&0o077 == 0 {
160 dirPerm = 0o700
161 }
162 if err := os.MkdirAll(dir, dirPerm); err != nil {
163 return "", fmt.Errorf("create dir for %s: %w", path, err)
164 }
165 tmp, err := os.CreateTemp(dir, ".atomic-*.tmp")
166 if err != nil {
167 return "", fmt.Errorf("create tmp for %s: %w", path, err)
168 }
169 tmpPath := tmp.Name()
170 closed := false
171 closeTmp := func() error {
172 if closed {
173 return nil
174 }
175 closed = true
176 return tmp.Close()
177 }
178 keep := false
179 defer func() {
180 _ = closeTmp()
181 if !keep {
182 _ = os.Remove(tmpPath)
183 }
184 }()
185 if _, err := tmp.Write(data); err != nil {
186 return "", fmt.Errorf("write tmp for %s: %w", path, err)
187 }
188 if err := tmp.Sync(); err != nil {
189 return "", fmt.Errorf("fsync tmp for %s: %w", path, err)
190 }
191 // Chmod the still-open handle, before Close, so there is no window between
192 // close and a path-based chmod for another process (Windows AV / search
193 // indexer) to grab or move the tmp and make the chmod fail with "file not
194 // found". CreateTemp makes a 0600 file, so this only widens when perm asks.
195 if err := tmp.Chmod(perm); err != nil {
196 return "", fmt.Errorf("chmod tmp for %s: %w", path, err)
197 }
198 if err := closeTmp(); err != nil {
199 return "", fmt.Errorf("close tmp for %s: %w", path, err)
200 }
201 keep = true
202 return tmpPath, nil
203 }
204
205 // ReplaceFile renames tmp onto dest, publishing the new content atomically: a
206 // reader concurrent with the replace sees either the old file or the complete
207 // new one. The rename can fail in two ways, and they are handled differently:
208 //
209 // - A transient lock on dest (antivirus, the search indexer, a concurrent
210 // reader without delete sharing) fails the rename for a few hundred ms.
211 // The rename is retried with backoff, and the last error is returned if
212 // the lock never clears. The failure is loud on purpose: falling back to
213 // an in-place copy here would truncate dest first, letting a racing
214 // reader observe an empty or half-written file — exactly the torn state
215 // AtomicWriteFile promises its callers (session leases, credentials,
216 // plugin state) can never happen.
217 // - Windows encryption-software filter drivers report a cross-device link
218 // (ERROR_NOT_SAME_DEVICE / EXDEV) even for a same-dir rename (#2696), and
219 // every retry fails identically. Only this class falls back to the
220 // non-atomic copy, and immediately — retrying a structurally impossible
221 // rename would only delay it. Torn reads remain possible in that degraded
222 // mode; it is the only way to write at all on such hosts, and
223 // rename-capable filesystems never take it.
224 //
225 // A missing tmp means the write itself failed and no retry can help.
226 func ReplaceFile(tmp, dest string) error {
227 Crash("replace", dest)
228 return replaceFile(tmp, dest, true)
229 }
230
231 // ClaimRename renames src to dst for callers that use the rename itself as a
232 // claim: it retries the same transient locks ReplaceFile does, but never falls
233 // back to a copy, because a copy would let two claimants both succeed. A src
234 // that has disappeared ends the retries at once — that is the loser of a race,
235 // not a fault.
236 func ClaimRename(src, dst string) error {
237 return replaceFile(src, dst, false)
238 }
239
240 func replaceFile(tmp, dest string, allowCrossDeviceCopy bool) error {
241 var err error
242 for attempt := 0; ; attempt++ {
243 if err = renameFile(tmp, dest); err == nil {
244 return nil
245 }
246 if renameCrossesDevice(err) {
247 if !allowCrossDeviceCopy {
248 return err
249 }
250 if copyOnto(tmp, dest) == nil {
251 return nil
252 }
253 return err
254 }
255 if attempt >= maxReplaceRetries || !fileExists(tmp) {
256 return err
257 }
258 time.Sleep(time.Duration(attempt+1) * replaceRetryBase)
259 }
260 }
261
262 func fileExists(path string) bool {
263 _, err := os.Stat(path)
264 return err == nil
265 }
266
267 // copyOnto is the non-atomic last resort for hosts whose filesystem cannot
268 // rename tmp onto dest at all (see ReplaceFile). It truncates dest in place,
269 // so a concurrent reader can observe an empty or half-written file — it must
270 // never run for failures a retry could clear.
271 func copyOnto(tmp, dest string) error {
272 info, err := os.Stat(tmp)
273 if err != nil {
274 return err
275 }
276 data, err := os.ReadFile(tmp)
277 if err != nil {
278 return err
279 }
280 if err := os.WriteFile(dest, data, info.Mode().Perm()); err != nil {
281 return err
282 }
283 // WriteFile keeps an existing dest's mode, so re-apply tmp's mode to match
284 // what the rename would have done (a 0600 config tmp must not widen to 0644).
285 _ = os.Chmod(dest, info.Mode().Perm())
286 _ = os.Remove(tmp)
287 return nil
288 }
289
289 lines GO