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