返回 DeepSeek-Reasonix
lock.go
1 package bootstrap
2
3 import (
4 "context"
5 "fmt"
6 "strconv"
7 "strings"
8 "time"
9
10 "reasonix/internal/remote/sftpfs"
11 )
12
13 const (
14 serveLockPoll = 100 * time.Millisecond
15 serveLockStaleAfter = 60 * time.Second
16 )
17
18 type serveLock struct {
19 fs *sftpfs.FS
20 paths StatePaths
21 owner string
22 }
23
24 // acquireServeLock serializes the short launch/publish critical section across
25 // CLI processes, desktop windows, and reconnect generations. The expensive
26 // locate/install phase stays outside the lock. A crashed owner's directory is
27 // reclaimed only after a minute; the guarded health check itself is bounded to
28 // 20 seconds, so a live owner cannot legitimately age past that threshold.
29 func acquireServeLock(ctx context.Context, fs *sftpfs.FS, paths StatePaths, clock func() time.Time) (*serveLock, error) {
30 if err := fs.MkdirAll(ctx, paths.Dir); err != nil {
31 return nil, err
32 }
33 token, err := generateToken()
34 if err != nil {
35 return nil, err
36 }
37 owner := strconv.FormatInt(clock().Unix(), 10) + ":" + token
38 for {
39 mkdirErr := fs.MkdirExclusive(ctx, paths.LockDir)
40 if mkdirErr == nil {
41 if err := fs.WriteFileAtomic(ctx, paths.LockOwner, []byte(owner+"\n"), 0o600); err != nil {
42 _ = fs.Remove(context.Background(), paths.LockDir, true)
43 return nil, fmt.Errorf("bootstrap: write serve lock owner: %w", err)
44 }
45 return &serveLock{fs: fs, paths: paths, owner: owner}, nil
46 }
47
48 lockInfo, statErr := fs.Stat(ctx, paths.LockDir)
49 if statErr != nil || !lockInfo.IsDir {
50 return nil, fmt.Errorf("bootstrap: create serve lock: %w", mkdirErr)
51 }
52 data, _, _, readErr := fs.ReadFile(ctx, paths.LockOwner, 512)
53 if readErr == nil {
54 observed := strings.TrimSpace(string(data))
55 parts := strings.SplitN(observed, ":", 2)
56 created, parseErr := strconv.ParseInt(parts[0], 10, 64)
57 if parseErr == nil && len(parts) == 2 && clock().Sub(time.Unix(created, 0)) > serveLockStaleAfter {
58 // Compare the owner again immediately before removal. A new owner never
59 // inherits the old random token, so we cannot delete a replacement lock.
60 current, _, _, currentErr := fs.ReadFile(ctx, paths.LockOwner, 512)
61 if currentErr == nil && strings.TrimSpace(string(current)) == observed {
62 _ = fs.Remove(ctx, paths.LockDir, true)
63 continue
64 }
65 }
66 } else if clock().Sub(time.Unix(lockInfo.ModTime, 0)) > serveLockStaleAfter {
67 // The creator may have crashed between mkdir and writing owner. The
68 // critical section cannot legitimately leave an owner-less directory
69 // this old, so reclaim it.
70 if _, _, _, currentErr := fs.ReadFile(ctx, paths.LockOwner, 512); currentErr != nil {
71 _ = fs.Remove(ctx, paths.LockDir, true)
72 continue
73 }
74 }
75
76 select {
77 case <-ctx.Done():
78 return nil, fmt.Errorf("bootstrap: wait for serve lock: %w", ctx.Err())
79 case <-time.After(serveLockPoll):
80 }
81 }
82 }
83
84 func (l *serveLock) release() {
85 if l == nil {
86 return
87 }
88 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
89 defer cancel()
90 data, _, _, err := l.fs.ReadFile(ctx, l.paths.LockOwner, 512)
91 if err == nil && strings.TrimSpace(string(data)) == l.owner {
92 _ = l.fs.Remove(ctx, l.paths.LockDir, true)
93 }
94 }
95
95 lines GO