| 1 | package bootstrap |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | const ( |
| 13 | serveLockPoll = 100 * time.Millisecond |
| 14 | serveLockStaleAfter = 60 * time.Second |
| 15 | ) |
| 16 | |
| 17 | type serveLock struct { |
| 18 | fs serveLockFS |
| 19 | paths StatePaths |
| 20 | owner string |
| 21 | } |
| 22 | |
| 23 | // acquireServeLock serializes the short launch/publish critical section across |
| 24 | // CLI processes, desktop windows, and reconnect generations. The expensive |
| 25 | // locate/install phase stays outside the lock. A crashed owner's directory is |
| 26 | // reclaimed only after a minute; the guarded health check itself is bounded to |
| 27 | // 20 seconds, so a live owner cannot legitimately age past that threshold. |
| 28 | func acquireServeLock(ctx context.Context, fs serveLockFS, paths StatePaths, clock func() time.Time) (*serveLock, error) { |
| 29 | if err := fs.MkdirAll(ctx, paths.Dir); err != nil { |
| 30 | return nil, err |
| 31 | } |
| 32 | token, err := generateToken() |
| 33 | if err != nil { |
| 34 | return nil, err |
| 35 | } |
| 36 | owner := strconv.FormatInt(clock().Unix(), 10) + ":" + token |
| 37 | retriedMissing := false |
| 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 | if err := ctx.Err(); err != nil { |
| 48 | return nil, fmt.Errorf("bootstrap: wait for serve lock: %w", err) |
| 49 | } |
| 50 | if !lockCreationMayContend(mkdirErr) { |
| 51 | return nil, fmt.Errorf("bootstrap: create serve lock: %w", mkdirErr) |
| 52 | } |
| 53 | |
| 54 | lockInfo, statErr := fs.Stat(ctx, paths.LockDir) |
| 55 | // The owner may release between mkdir and Stat. Recompete once per |
| 56 | // observed lock: SFTP v3 generic failures cannot prove contention, so |
| 57 | // repeated missing observations must not spin on permanent failures. |
| 58 | if os.IsNotExist(statErr) && !retriedMissing { |
| 59 | retriedMissing = true |
| 60 | continue |
| 61 | } |
| 62 | if statErr != nil || !lockInfo.IsDir { |
| 63 | return nil, fmt.Errorf("bootstrap: create serve lock: %w", mkdirErr) |
| 64 | } |
| 65 | retriedMissing = false |
| 66 | data, _, _, readErr := fs.ReadFile(ctx, paths.LockOwner, 512) |
| 67 | if readErr == nil { |
| 68 | observed := strings.TrimSpace(string(data)) |
| 69 | parts := strings.SplitN(observed, ":", 2) |
| 70 | created, parseErr := strconv.ParseInt(parts[0], 10, 64) |
| 71 | if parseErr == nil && len(parts) == 2 && clock().Sub(time.Unix(created, 0)) > serveLockStaleAfter { |
| 72 | // Compare the owner again immediately before removal. A new owner never |
| 73 | // inherits the old random token, so we cannot delete a replacement lock. |
| 74 | current, _, _, currentErr := fs.ReadFile(ctx, paths.LockOwner, 512) |
| 75 | if currentErr == nil && strings.TrimSpace(string(current)) == observed { |
| 76 | _ = fs.Remove(ctx, paths.LockDir, true) |
| 77 | continue |
| 78 | } |
| 79 | } |
| 80 | } else if clock().Sub(time.Unix(lockInfo.ModTime, 0)) > serveLockStaleAfter { |
| 81 | // The creator may have crashed between mkdir and writing owner. The |
| 82 | // critical section cannot legitimately leave an owner-less directory |
| 83 | // this old, so reclaim it. |
| 84 | if _, _, _, currentErr := fs.ReadFile(ctx, paths.LockOwner, 512); currentErr != nil { |
| 85 | _ = fs.Remove(ctx, paths.LockDir, true) |
| 86 | continue |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | select { |
| 91 | case <-ctx.Done(): |
| 92 | return nil, fmt.Errorf("bootstrap: wait for serve lock: %w", ctx.Err()) |
| 93 | case <-time.After(serveLockPoll): |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func (l *serveLock) release() { |
| 99 | if l == nil { |
| 100 | return |
| 101 | } |
| 102 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 103 | defer cancel() |
| 104 | data, _, _, err := l.fs.ReadFile(ctx, l.paths.LockOwner, 512) |
| 105 | if err == nil && strings.TrimSpace(string(data)) == l.owner { |
| 106 | _ = l.fs.Remove(ctx, l.paths.LockDir, true) |
| 107 | } |
| 108 | } |
| 109 |