| 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 |