Skip to content

Commit 4359607

Browse files
committed
Adding FileLock Tests
1 parent 6dbb97d commit 4359607

File tree

2 files changed

+237
-12
lines changed

2 files changed

+237
-12
lines changed

Sources/SwiftlyCore/FileLock.swift

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,18 @@ import Foundation
22
import SystemPackage
33

44
enum FileLockError: Error {
5-
case cannotAcquireLock
6-
case timeoutExceeded
5+
case cannotAcquireLock(FilePath)
6+
case lockedByPID(FilePath, String?)
7+
8+
var localizedDescription: String {
9+
switch self {
10+
case let .cannotAcquireLock(path):
11+
return "Cannot acquire lock at \(path). Another process may be holding the lock."
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+
}
717
}
818

919
/// A non-blocking file lock implementation using file creation as locking mechanism.
@@ -19,10 +29,21 @@ public struct FileLock {
1929
self.filePath = path
2030
do {
2131
let fileURL = URL(fileURLWithPath: self.filePath.string)
22-
let contents = Foundation.ProcessInfo.processInfo.processIdentifier.description.data(using: .utf8) ?? Data()
32+
let contents = Foundation.ProcessInfo.processInfo.processIdentifier.description.data(using: .utf8)
33+
?? Data()
2334
try contents.write(to: fileURL, options: .withoutOverwriting)
2435
} catch CocoaError.fileWriteFileExists {
25-
throw FileLockError.cannotAcquireLock
36+
// Read the PID from the existing lock file
37+
let fileURL = URL(fileURLWithPath: self.filePath.string)
38+
let pid: String?
39+
if let data = try? Data(contentsOf: fileURL),
40+
let pidString = String(data: data, encoding: .utf8)?.trimmingCharacters(
41+
in: .whitespacesAndNewlines),
42+
!pidString.isEmpty
43+
{
44+
pid = pidString
45+
}
46+
throw FileLockError.lockedByPID(path, pid)
2647
}
2748
}
2849

@@ -32,14 +53,27 @@ public struct FileLock {
3253
pollingInterval: TimeInterval = FileLock.defaultPollingInterval
3354
) async throws -> FileLock {
3455
let start = Date()
56+
var lockedByPID: String?
3557
while Date().timeIntervalSince(start) < timeout {
36-
if let fileLock = try? FileLock(at: path) {
58+
do {
59+
let fileLock = try FileLock(at: path)
3760
return fileLock
61+
} catch let FileLockError.lockedByPID(_, pid) {
62+
lockedByPID = pid
63+
do {
64+
let isRunning = try isProcessRunning(pidString: pid)
65+
if !isRunning {
66+
// Process is no longer running, remove stale lock file and try again
67+
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path.string))
68+
continue
69+
}
70+
} catch {
71+
throw FileLockError.cannotAcquireLock(path)
72+
}
73+
try? await Task.sleep(for: .seconds(pollingInterval))
3874
}
39-
try? await Task.sleep(for: .seconds(pollingInterval))
75+
throw FileLockError.lockedByPID(path, lockedByPID)
4076
}
41-
42-
throw FileLockError.timeoutExceeded
4377
}
4478

4579
public func unlock() async throws {
@@ -53,14 +87,15 @@ public func withLock<T>(
5387
pollingInterval: TimeInterval = FileLock.defaultPollingInterval,
5488
action: @escaping () async throws -> T
5589
) async throws -> T {
56-
guard
57-
let lock = try? await FileLock.waitForLock(
90+
let lock: FileLock
91+
do {
92+
lock = try await FileLock.waitForLock(
5893
lockFile,
5994
timeout: timeout,
6095
pollingInterval: pollingInterval
6196
)
62-
else {
63-
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile)")
97+
} catch {
98+
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile): \(error.localizedDescription)")
6499
}
65100

66101
do {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import Foundation
2+
import SystemPackage
3+
import Testing
4+
5+
@testable import SwiftlyCore
6+
7+
@Suite struct FileLockTests {
8+
@Test("FileLock creation writes process ID to lock file")
9+
func testFileLockCreation() async throws {
10+
try await SwiftlyTests.withTestHome {
11+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "test.lock"
12+
13+
let lock = try FileLock(at: lockPath)
14+
15+
// Verify lock file exists
16+
#expect(try await fs.exists(atPath: lockPath))
17+
18+
// Verify lock file contains process ID
19+
let lockData = try Data(contentsOf: URL(fileURLWithPath: lockPath.string))
20+
let lockContent = String(data: lockData, encoding: .utf8)
21+
let expectedPID = Foundation.ProcessInfo.processInfo.processIdentifier.description
22+
#expect(lockContent == expectedPID)
23+
24+
try await lock.unlock()
25+
}
26+
}
27+
28+
@Test("FileLock fails when lock file already exists")
29+
func testFileLockConflict() async throws {
30+
try await SwiftlyTests.withTestHome {
31+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "conflict.lock"
32+
33+
// Create first lock
34+
let firstLock = try FileLock(at: lockPath)
35+
36+
// Attempt to create second lock should fail
37+
do {
38+
_ = try FileLock(at: lockPath)
39+
#expect(Bool(false), "Expected FileLockError.lockedByPID to be thrown")
40+
} catch let error as FileLockError {
41+
if case .lockedByPID = error {
42+
} else {
43+
#expect(Bool(false), "Expected FileLockError.lockedByPID but got \(error)")
44+
}
45+
}
46+
47+
try await firstLock.unlock()
48+
}
49+
}
50+
51+
@Test("FileLock unlock removes lock file")
52+
func testFileLockUnlock() async throws {
53+
try await SwiftlyTests.withTestHome {
54+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "unlock.lock"
55+
56+
let lock = try FileLock(at: lockPath)
57+
#expect(try await fs.exists(atPath: lockPath))
58+
59+
try await lock.unlock()
60+
#expect(!(try await fs.exists(atPath: lockPath)))
61+
}
62+
}
63+
64+
@Test("FileLock can be reacquired after unlock")
65+
func testFileLockReacquisition() async throws {
66+
try await SwiftlyTests.withTestHome {
67+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "reacquire.lock"
68+
69+
// First acquisition
70+
let firstLock = try FileLock(at: lockPath)
71+
try await firstLock.unlock()
72+
73+
// Second acquisition should succeed
74+
let secondLock = try FileLock(at: lockPath)
75+
try await secondLock.unlock()
76+
}
77+
}
78+
79+
@Test("waitForLock succeeds immediately when no lock exists")
80+
func testWaitForLockImmediate() async throws {
81+
try await SwiftlyTests.withTestHome {
82+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "immediate.lock"
83+
let time = Date()
84+
let lock = try await FileLock.waitForLock(lockPath, timeout: 1.0, pollingInterval: 0.1)
85+
let duration = Date().timeIntervalSince(time)
86+
#expect(duration < 1.0)
87+
#expect(try await fs.exists(atPath: lockPath))
88+
try await lock.unlock()
89+
}
90+
}
91+
92+
@Test("waitForLock times out when lock cannot be acquired")
93+
func testWaitForLockTimeout() async throws {
94+
try await SwiftlyTests.withTestHome {
95+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "timeout.lock"
96+
97+
// Create existing lock
98+
let existingLock = try FileLock(at: lockPath)
99+
100+
// Attempt to wait for lock should timeout
101+
do {
102+
_ = try await FileLock.waitForLock(lockPath, timeout: 0.5, pollingInterval: 0.1)
103+
#expect(Bool(false), "Expected FileLockError.lockedByPID to be thrown")
104+
} catch let error as FileLockError {
105+
if case .lockedByPID = error {
106+
// Expected error
107+
} else {
108+
#expect(Bool(false), "Expected FileLockError.lockedByPID but got \(error)")
109+
}
110+
}
111+
112+
try await existingLock.unlock()
113+
}
114+
}
115+
116+
@Test("waitForLock succeeds when lock becomes available")
117+
func testWaitForLockEventualSuccess() async throws {
118+
try await SwiftlyTests.withTestHome {
119+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "eventual.lock"
120+
121+
// Create initial lock
122+
let initialLock = try FileLock(at: lockPath)
123+
124+
// Task to release lock after delay
125+
let releaseTask = Task {
126+
try await Task.sleep(for: .seconds(0.3))
127+
try await initialLock.unlock()
128+
}
129+
130+
// Wait for lock - should succeed after release
131+
let waitingLock = try await FileLock.waitForLock(lockPath, timeout: 1.0, pollingInterval: 0.1)
132+
try await waitingLock.unlock()
133+
try await releaseTask.value
134+
}
135+
}
136+
137+
@Test("withLock executes action and automatically unlocks")
138+
func testWithLockSuccess() async throws {
139+
try await SwiftlyTests.withTestHome {
140+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "withlock.lock"
141+
var actionExecuted = false
142+
143+
let result = try await withLock(lockPath, timeout: 1.0, pollingInterval: 0.1) {
144+
actionExecuted = true
145+
return "success"
146+
}
147+
148+
#expect(actionExecuted)
149+
#expect(result == "success")
150+
#expect(!(try await fs.exists(atPath: lockPath)))
151+
}
152+
}
153+
154+
@Test("withLock unlocks even when action throws")
155+
func testWithLockErrorHandling() async throws {
156+
try await SwiftlyTests.withTestHome {
157+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "withlockError.lock"
158+
159+
struct TestError: Error {}
160+
161+
await #expect(throws: TestError.self) {
162+
try await withLock(lockPath, timeout: 1.0, pollingInterval: 0.1) {
163+
throw TestError()
164+
}
165+
}
166+
167+
// Lock should be released even after error
168+
let exists = try await fs.exists(atPath: lockPath)
169+
#expect(!exists)
170+
}
171+
}
172+
173+
@Test("withLock fails when lock cannot be acquired within timeout")
174+
func testWithLockTimeout() async throws {
175+
try await SwiftlyTests.withTestHome {
176+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "withlockTimeout.lock"
177+
178+
// Create existing lock
179+
let existingLock = try FileLock(at: lockPath)
180+
181+
await #expect(throws: SwiftlyError.self) {
182+
try await withLock(lockPath, timeout: 0.5, pollingInterval: 0.1) {
183+
"should not execute"
184+
}
185+
}
186+
187+
try await existingLock.unlock()
188+
}
189+
}
190+
}

0 commit comments

Comments
 (0)