返回 DeepSeek-Reasonix
mutation_lock.go
根目录 / internal / repair / mutation_lock.go
1 package repair
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "sort"
12 "strings"
13 "sync"
14 "time"
15
16 "reasonix/internal/config"
17 "reasonix/internal/filelock"
18 )
19
20 const repairMutationLockTimeout = 5 * time.Second
21
22 // repairMutationBeforeLock is a test seam for forcing competing repair
23 // operations to overlap before one waits on the shared file lock.
24 var repairMutationBeforeLock = func([]string) {}
25
26 // repairMutationBeforeRename is a test seam for changing a target in the
27 // narrow interval between its final state check and quarantine rename.
28 var repairMutationBeforeRename = func(string) {}
29
30 // repairMutationAfterRename is a test seam for forcing an uncooperative writer
31 // to create a new target after the confirmed node has been quarantined.
32 var repairMutationAfterRename = func(string) {}
33
34 // repairMutationAfterPrepare is a test seam for simulating process exit after
35 // the write-ahead repair intent is durable but before the filesystem rename.
36 var repairMutationAfterPrepare = func(string) {}
37
38 var repairPathCaseInsensitive = platformRepairPathCaseInsensitive
39
40 func lockRepairTransaction() (func(), error) {
41 expectedPendingState := repairPlanReleaseNodeState(pendingRepairTransactionPath())
42 unlock, err := lockRepairMutations(repairTransactionPath())
43 if err != nil {
44 return nil, fmt.Errorf("lock repair transaction: %w", err)
45 }
46 if actual := repairPlanReleaseNodeState(pendingRepairTransactionPath()); actual != expectedPendingState {
47 unlock()
48 return nil, fmt.Errorf("lock repair transaction: pending repair transaction changed while waiting")
49 }
50 return unlock, nil
51 }
52
53 func restoreRepairNodeIfAbsent(backup, target string) error {
54 // Every backup passed here was produced by renaming the target to a sibling
55 // or to a same-filesystem repair directory. A no-replace rename restores the
56 // exact node and consumes the backup in one operation. Recreating a link/file
57 // and then removing backup would let another writer replace backup between
58 // those syscalls and have its node deleted.
59 if err := renameRepairNodeNoReplace(backup, target); err != nil {
60 return fmt.Errorf("restore repair target: %w", err)
61 }
62 return nil
63 }
64
65 // removeRepairNodeIfMatching first displaces a transaction-owned backup to a
66 // unique sibling, then verifies the moved node against its original target
67 // identity. A path replacement between verification and cleanup is restored or
68 // retained, never unlinked as if it were transaction-owned.
69 func removeRepairNodeIfMatching(path, identityPath, expectedStateID string) error {
70 expectedStateID = strings.TrimSpace(expectedStateID)
71 if expectedStateID == "" {
72 // Legacy transactions did not persist backup identity. Leaving a stale
73 // backup is safer than deleting a path whose ownership cannot be proven.
74 return nil
75 }
76 cleanup, err := moveRepairNodeToUniqueCleanup(path)
77 if err != nil {
78 return err
79 }
80 if cleanup == "" {
81 return nil
82 }
83 if err := verifyRepairPlanReleaseNodeStateFor(cleanup, identityPath, expectedStateID); err != nil {
84 if restoreErr := renameRepairNodeNoReplace(cleanup, path); restoreErr != nil {
85 return errors.Join(err, fmt.Errorf("preserve changed repair backup at %s: %w", cleanup, restoreErr))
86 }
87 return err
88 }
89 info, err := os.Lstat(cleanup)
90 if err != nil {
91 return err
92 }
93 if info.IsDir() {
94 if restoreErr := renameRepairNodeNoReplace(cleanup, path); restoreErr != nil {
95 return errors.Join(
96 fmt.Errorf("remove repair backup: directories are unsupported"),
97 fmt.Errorf("preserve repair backup at %s: %w", cleanup, restoreErr),
98 )
99 }
100 return fmt.Errorf("remove repair backup: directories are unsupported")
101 }
102 return os.Remove(cleanup)
103 }
104
105 func moveRepairNodeToUniqueCleanup(path string) (string, error) {
106 for attempt := 0; attempt < 16; attempt++ {
107 cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
108 err := renameRepairNodeNoReplace(path, cleanup)
109 if err == nil {
110 return cleanup, nil
111 }
112 if os.IsNotExist(err) {
113 return "", nil
114 }
115 if os.IsExist(err) {
116 continue
117 }
118 return "", err
119 }
120 return "", fmt.Errorf("remove repair node: cannot allocate cleanup path")
121 }
122
123 // canonicalRepairPath resolves a repair target to a stable key shared by
124 // mutation locks and preview identity. Parent-directory symlinks are followed
125 // so alias paths converge, but the leaf name is never resolved: repair mutates
126 // the leaf node itself via Lstat/Rename (including when the leaf is a symlink).
127 // Case-insensitive filesystems fold case so /Project and /project cannot take
128 // different locks. The decision is made from the target's actual parent
129 // directory: macOS and Windows can both host case-sensitive directories.
130 func canonicalRepairPath(path string) string {
131 path = strings.TrimSpace(path)
132 if path == "" {
133 return ""
134 }
135 absolute, err := filepath.Abs(filepath.Clean(path))
136 if err != nil {
137 absolute = filepath.Clean(path)
138 }
139 absolute = resolveParentSymlinkPath(absolute)
140 absolute = filepath.Clean(absolute)
141 caseInsensitive := repairPathCaseInsensitive(absolute)
142 absolute = platformRepairPathUnicodeNormalized(absolute)
143 if caseInsensitive {
144 return strings.ToLower(filepath.ToSlash(absolute))
145 }
146 return absolute
147 }
148
149 // resolveParentSymlinkPath resolves symlink parents of path and re-attaches the
150 // original leaf base name. The leaf is intentionally not EvalSymlinks'd: two
151 // different symlink leaves that share a referent must stay distinct targets.
152 func resolveParentSymlinkPath(path string) string {
153 if path == "" {
154 return ""
155 }
156 parent := filepath.Dir(path)
157 base := filepath.Base(path)
158 if parent == path {
159 // Root or volume path: nothing to resolve above the leaf.
160 return path
161 }
162 if resolved, err := filepath.EvalSymlinks(parent); err == nil {
163 return filepath.Join(resolved, base)
164 }
165 // Parent may not exist yet (create-only targets). Resolve the longest
166 // existing ancestor and rejoin the missing components including the leaf.
167 var missing []string
168 dir := parent
169 missing = append(missing, base)
170 for {
171 if resolved, err := filepath.EvalSymlinks(dir); err == nil {
172 parts := make([]string, 0, 1+len(missing))
173 parts = append(parts, resolved)
174 for i := len(missing) - 1; i >= 0; i-- {
175 parts = append(parts, missing[i])
176 }
177 return filepath.Join(parts...)
178 }
179 next := filepath.Dir(dir)
180 if next == dir {
181 return path
182 }
183 missing = append(missing, filepath.Base(dir))
184 dir = next
185 }
186 }
187
188 // LockRepairMutations is the exported form of lockRepairMutations for desktop
189 // handoff helpers that replace release-unit paths outside ApplyRepairPlan.
190 func LockRepairMutations(paths ...string) (func(), error) {
191 return lockRepairMutations(paths...)
192 }
193
194 // LockRepairMutationsTimeout is like LockRepairMutations but waits up to
195 // timeout for competing repair or update holders.
196 func LockRepairMutationsTimeout(timeout time.Duration, paths ...string) (func(), error) {
197 if timeout <= 0 {
198 timeout = repairMutationLockTimeout
199 }
200 return lockRepairMutationsTimeout(timeout, paths...)
201 }
202
203 // repairPlanTargetIdentity is a non-reversible identity for a filesystem
204 // target. It is embedded in preview state IDs so confirmation cannot be
205 // reused against a different real path that happens to have the same content.
206 func repairPlanTargetIdentity(path string) string {
207 key := canonicalRepairPath(path)
208 if key == "" {
209 return ""
210 }
211 sum := sha256.Sum256([]byte(key))
212 return hex.EncodeToString(sum[:])
213 }
214
215 // lockRepairMutations serializes repair read-check-write cycles by canonical
216 // target path. Lock files live in Reasonix state rather than beside project or
217 // configuration files, and paths are sorted so multi-target actions cannot
218 // deadlock each other.
219 func lockRepairMutations(paths ...string) (func(), error) {
220 return lockRepairMutationsTimeout(repairMutationLockTimeout, paths...)
221 }
222
223 func lockRepairMutationsTimeout(timeout time.Duration, paths ...string) (func(), error) {
224 lockDir := config.RepairMutationLockDir()
225 if lockDir == "" {
226 return nil, fmt.Errorf("lock repair mutations: OS user cache directory is unavailable")
227 }
228 if err := os.MkdirAll(lockDir, 0o700); err != nil {
229 return nil, fmt.Errorf("lock repair mutations: create lock directory: %w", err)
230 }
231
232 unique := map[string]struct{}{}
233 keys := make([]string, 0, len(paths))
234 for _, path := range paths {
235 path = strings.TrimSpace(path)
236 if path == "" {
237 continue
238 }
239 key := canonicalRepairPath(path)
240 if key == "" {
241 return nil, fmt.Errorf("lock repair mutations: resolve target: empty path")
242 }
243 if _, ok := unique[key]; ok {
244 continue
245 }
246 unique[key] = struct{}{}
247 keys = append(keys, key)
248 }
249 if len(keys) == 0 {
250 return func() {}, nil
251 }
252 sort.Strings(keys)
253 repairMutationBeforeLock(append([]string(nil), keys...))
254
255 if timeout <= 0 {
256 timeout = repairMutationLockTimeout
257 }
258 ctx, cancel := context.WithTimeout(context.Background(), timeout)
259 defer cancel()
260 releases := make([]func(), 0, len(keys))
261 for _, key := range keys {
262 digest := sha256.Sum256([]byte(key))
263 lockPath := filepath.Join(lockDir, fmt.Sprintf("%x.lock", digest))
264 release, err := filelock.Acquire(ctx, lockPath)
265 if err != nil {
266 for i := len(releases) - 1; i >= 0; i-- {
267 releases[i]()
268 }
269 return nil, fmt.Errorf("lock repair mutations: %w", err)
270 }
271 releases = append(releases, release)
272 }
273
274 var once sync.Once
275 return func() {
276 once.Do(func() {
277 for i := len(releases) - 1; i >= 0; i-- {
278 releases[i]()
279 }
280 })
281 }, nil
282 }
283
283 lines GO