|
| 1 | +import Foundation |
| 2 | +import SystemPackage |
| 3 | + |
| 4 | +enum FileLockError: Error, LocalizedError { |
| 5 | + case cannotAcquireLock(FilePath) |
| 6 | + case lockedByPID(FilePath, String) |
| 7 | + |
| 8 | + var errorDescription: String? { |
| 9 | + switch self { |
| 10 | + case let .cannotAcquireLock(path): |
| 11 | + return "Cannot acquire lock at \(path). Another process may be holding the lock. If you are sure no other processes are running, you can manually remove the lock file at \(path)." |
| 12 | + case let .lockedByPID(path, pid): |
| 13 | + return |
| 14 | + "Lock at \(path) is held by process ID \(pid). Wait for the process to complete or manually remove the lock file if the process is no longer running." |
| 15 | + } |
| 16 | + } |
| 17 | +} |
| 18 | + |
| 19 | +/// A non-blocking file lock implementation using file creation as locking mechanism. |
| 20 | +/// Use case: When installing multiple Swiftly instances on the same machine, |
| 21 | +/// one should acquire the lock while others poll until it becomes available. |
| 22 | +public struct FileLock { |
| 23 | + let filePath: FilePath |
| 24 | + |
| 25 | + public static let defaultPollingInterval: TimeInterval = 1 |
| 26 | + public static let defaultTimeout: TimeInterval = 300.0 |
| 27 | + |
| 28 | + public init(at path: FilePath) throws { |
| 29 | + self.filePath = path |
| 30 | + do { |
| 31 | + let fileURL = URL(fileURLWithPath: self.filePath.string) |
| 32 | + let contents = Foundation.ProcessInfo.processInfo.processIdentifier.description.data(using: .utf8) |
| 33 | + ?? Data() |
| 34 | + try contents.write(to: fileURL, options: .withoutOverwriting) |
| 35 | + } catch CocoaError.fileWriteFileExists { |
| 36 | + // Read the PID from the existing lock file |
| 37 | + let fileURL = URL(fileURLWithPath: self.filePath.string) |
| 38 | + if let data = try? Data(contentsOf: fileURL), |
| 39 | + let pidString = String(data: data, encoding: .utf8)?.trimmingCharacters( |
| 40 | + in: .whitespacesAndNewlines), |
| 41 | + !pidString.isEmpty |
| 42 | + { |
| 43 | + throw FileLockError.lockedByPID(self.filePath, pidString) |
| 44 | + } else { |
| 45 | + throw FileLockError.cannotAcquireLock(self.filePath) |
| 46 | + } |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + public static func waitForLock( |
| 51 | + _ path: FilePath, |
| 52 | + timeout: TimeInterval = FileLock.defaultTimeout, |
| 53 | + pollingInterval: TimeInterval = FileLock.defaultPollingInterval |
| 54 | + ) async throws -> FileLock { |
| 55 | + let start = Date() |
| 56 | + var lastError: Error? |
| 57 | + |
| 58 | + while Date().timeIntervalSince(start) < timeout { |
| 59 | + let result = Result { try FileLock(at: path) } |
| 60 | + |
| 61 | + switch result { |
| 62 | + case let .success(lock): |
| 63 | + return lock |
| 64 | + case let .failure(error): |
| 65 | + lastError = error |
| 66 | + try? await Task.sleep(for: .seconds(pollingInterval) + .milliseconds(Int.random(in: 0...200))) |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + // Timeout reached, throw the last error from the loop |
| 71 | + if let lastError = lastError { |
| 72 | + throw lastError |
| 73 | + } else { |
| 74 | + throw FileLockError.cannotAcquireLock(path) |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + public func unlock() async throws { |
| 79 | + try await FileSystem.remove(atPath: self.filePath) |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +public func withLock<T>( |
| 84 | + _ lockFile: FilePath, |
| 85 | + timeout: TimeInterval = FileLock.defaultTimeout, |
| 86 | + pollingInterval: TimeInterval = FileLock.defaultPollingInterval, |
| 87 | + action: @escaping () async throws -> T |
| 88 | +) async throws -> T { |
| 89 | + let lock: FileLock |
| 90 | + do { |
| 91 | + lock = try await FileLock.waitForLock( |
| 92 | + lockFile, |
| 93 | + timeout: timeout, |
| 94 | + pollingInterval: pollingInterval |
| 95 | + ) |
| 96 | + } catch { |
| 97 | + throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile): \(error.localizedDescription)") |
| 98 | + } |
| 99 | + |
| 100 | + do { |
| 101 | + let result = try await action() |
| 102 | + try await lock.unlock() |
| 103 | + return result |
| 104 | + } catch { |
| 105 | + try await lock.unlock() |
| 106 | + throw error |
| 107 | + } |
| 108 | +} |
0 commit comments