|
1 | 1 | import Foundation |
| 2 | +import SystemPackage |
2 | 3 |
|
3 | 4 | /** |
4 | | - * A non-blocking file lock implementation with polling capability. |
| 5 | + * A non-blocking file lock implementation using file creation as locking mechanism. |
5 | 6 | * Use case: When installing multiple Swiftly instances on the same machine, |
6 | 7 | * one should acquire the lock while others poll until it becomes available. |
7 | 8 | */ |
8 | 9 |
|
9 | | -#if os(macOS) |
10 | | -import Darwin.C |
11 | | -#elseif os(Linux) |
12 | | -import Glibc |
13 | | -#endif |
14 | | - |
15 | | -public struct FileLock { |
16 | | - let filePath: String |
17 | | - |
18 | | - let fileHandle: FileHandle |
19 | | - |
20 | | - public static let defaultPollingInterval: TimeInterval = 1.0 |
| 10 | +public actor FileLock { |
| 11 | + let filePath: FilePath |
| 12 | + private var isLocked = false |
21 | 13 |
|
| 14 | + public static let defaultPollingInterval: TimeInterval = 1 |
22 | 15 | public static let defaultTimeout: TimeInterval = 300.0 |
23 | 16 |
|
24 | | - public init(at filePath: String) throws { |
25 | | - self.filePath = filePath |
26 | | - |
27 | | - if !FileManager.default.fileExists(atPath: filePath) { |
28 | | - FileManager.default.createFile(atPath: filePath, contents: nil) |
29 | | - } |
30 | | - |
31 | | - self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath)) |
| 17 | + public init(at path: FilePath) { |
| 18 | + self.filePath = path |
32 | 19 | } |
33 | 20 |
|
34 | | - public func tryLock() -> Bool { |
35 | | - self.fileHandle.tryLockFile() |
| 21 | + public func tryLock() async -> Bool { |
| 22 | + do { |
| 23 | + guard !self.isLocked else { return true } |
| 24 | + |
| 25 | + guard !(try await FileSystem.exists(atPath: self.filePath)) else { |
| 26 | + return false |
| 27 | + } |
| 28 | + // Create the lock file with exclusive permissions |
| 29 | + try await FileSystem.create(.mode(0o600), file: self.filePath, contents: nil) |
| 30 | + self.isLocked = true |
| 31 | + return true |
| 32 | + } catch { |
| 33 | + return false |
| 34 | + } |
36 | 35 | } |
37 | 36 |
|
38 | 37 | public func waitForLock( |
39 | 38 | timeout: TimeInterval = FileLock.defaultTimeout, |
40 | 39 | pollingInterval: TimeInterval = FileLock.defaultPollingInterval |
41 | | - ) -> Bool { |
42 | | - let startTime = Date() |
| 40 | + ) async -> Bool { |
| 41 | + let start = Date() |
43 | 42 |
|
44 | | - if self.tryLock() { |
45 | | - return true |
46 | | - } |
47 | | - |
48 | | - while Date().timeIntervalSince(startTime) < timeout { |
49 | | - Thread.sleep(forTimeInterval: pollingInterval) |
50 | | - |
51 | | - if self.tryLock() { |
| 43 | + while Date().timeIntervalSince(start) < timeout { |
| 44 | + if await self.tryLock() { |
52 | 45 | return true |
53 | 46 | } |
| 47 | + try? await Task.sleep(for: .seconds(pollingInterval)) |
54 | 48 | } |
55 | 49 |
|
56 | 50 | return false |
57 | 51 | } |
58 | 52 |
|
59 | | - public func unlock() throws { |
60 | | - guard self.fileHandle != nil else { return } |
61 | | - try self.fileHandle.unlockFile() |
62 | | - try self.fileHandle.close() |
| 53 | + public func unlock() async throws { |
| 54 | + guard self.isLocked else { return } |
| 55 | + |
| 56 | + try await FileSystem.remove(atPath: self.filePath) |
| 57 | + self.isLocked = false |
63 | 58 | } |
64 | 59 | } |
65 | 60 |
|
66 | | -extension FileHandle { |
67 | | - func tryLockFile() -> Bool { |
68 | | - let fd = self.fileDescriptor |
69 | | - var flock = flock() |
70 | | - flock.l_type = Int16(F_WRLCK) |
71 | | - flock.l_whence = Int16(SEEK_SET) |
72 | | - flock.l_start = 0 |
73 | | - flock.l_len = 0 |
| 61 | +public func withLock<T>( |
| 62 | + _ lockFile: FilePath, |
| 63 | + timeout: TimeInterval = FileLock.defaultTimeout, |
| 64 | + pollingInterval: TimeInterval = FileLock.defaultPollingInterval, |
| 65 | + action: @escaping () async throws -> T |
| 66 | +) async throws -> T { |
| 67 | + let lock = FileLock(at: lockFile) |
| 68 | + guard await lock.waitForLock(timeout: timeout, pollingInterval: pollingInterval) else { |
| 69 | + throw SwiftlyError(message: "Failed to acquire file lock at \(lock.filePath)") |
| 70 | + } |
74 | 71 |
|
75 | | - if fcntl(fd, F_SETLK, &flock) == -1 { |
76 | | - if errno == EACCES || errno == EAGAIN { |
77 | | - return false |
78 | | - } else { |
79 | | - fputs("Unexpected lock error: \(String(cString: strerror(errno)))\n", stderr) |
80 | | - return false |
| 72 | + defer { |
| 73 | + Task { |
| 74 | + do { |
| 75 | + try await lock.unlock() |
| 76 | + } catch { |
| 77 | + print("WARNING: Failed to unlock file: \(error)") |
81 | 78 | } |
82 | 79 | } |
83 | | - return true |
84 | 80 | } |
85 | 81 |
|
86 | | - func unlockFile() throws { |
87 | | - let fd = self.fileDescriptor |
88 | | - var flock = flock() |
89 | | - flock.l_type = Int16(F_UNLCK) |
90 | | - flock.l_whence = Int16(SEEK_SET) |
91 | | - flock.l_start = 0 |
92 | | - flock.l_len = 0 |
93 | | - |
94 | | - if fcntl(fd, F_SETLK, &flock) == -1 { |
95 | | - throw SwiftlyError( |
96 | | - message: "Failed to unlock file: \(String(cString: strerror(errno)))") |
97 | | - } |
98 | | - } |
| 82 | + return try await action() |
99 | 83 | } |
0 commit comments