Skip to content

Commit acb796a

Browse files
committed
Adding FileLock Tests
1 parent 1fd0dd2 commit acb796a

File tree

3 files changed

+303
-13
lines changed

3 files changed

+303
-13
lines changed

Sources/SwiftlyCore/FileLock.swift

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import Foundation
22
import SystemPackage
33

4-
enum FileLockError: Error {
5-
case cannotAcquireLock
6-
case timeoutExceeded
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."
12+
case let .lockedByPID(path, pid):
13+
let pidDescription = pid ?? "unknown"
14+
return
15+
"Lock at \(path) is held by process ID \(pidDescription). Wait for the process to complete or manually remove the lock file if the process is no longer running."
16+
}
17+
}
718
}
819

920
/// A non-blocking file lock implementation using file creation as locking mechanism.
@@ -19,10 +30,21 @@ public struct FileLock {
1930
self.filePath = path
2031
do {
2132
let fileURL = URL(fileURLWithPath: self.filePath.string)
22-
let contents = Foundation.ProcessInfo.processInfo.processIdentifier.description.data(using: .utf8) ?? Data()
33+
let contents = Foundation.ProcessInfo.processInfo.processIdentifier.description.data(using: .utf8)
34+
?? Data()
2335
try contents.write(to: fileURL, options: .withoutOverwriting)
2436
} catch CocoaError.fileWriteFileExists {
25-
throw FileLockError.cannotAcquireLock
37+
// Read the PID from the existing lock file
38+
let fileURL = URL(fileURLWithPath: self.filePath.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+
throw FileLockError.lockedByPID(self.filePath, pidString)
45+
} else {
46+
throw FileLockError.cannotAcquireLock(self.filePath)
47+
}
2648
}
2749
}
2850

@@ -32,14 +54,29 @@ public struct FileLock {
3254
pollingInterval: TimeInterval = FileLock.defaultPollingInterval
3355
) async throws -> FileLock {
3456
let start = Date()
57+
var lockedByPID: String?
3558
while Date().timeIntervalSince(start) < timeout {
36-
if let fileLock = try? FileLock(at: path) {
59+
do {
60+
let fileLock = try FileLock(at: path)
3761
return fileLock
62+
} catch let FileLockError.lockedByPID(_, pid) {
63+
lockedByPID = pid
64+
if let pidString = pid {
65+
do {
66+
let isRunning = try isProcessRunning(pidString: pidString)
67+
if !isRunning {
68+
// Process is no longer running, remove stale lock file and try again
69+
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path.string))
70+
continue
71+
}
72+
} catch {
73+
throw FileLockError.cannotAcquireLock(path)
74+
}
75+
}
76+
try? await Task.sleep(for: .seconds(pollingInterval) + .milliseconds(Int.random(in: 0...200)))
3877
}
39-
try? await Task.sleep(for: .seconds(pollingInterval))
4078
}
41-
42-
throw FileLockError.timeoutExceeded
79+
throw FileLockError.lockedByPID(path, lockedByPID)
4380
}
4481

4582
public func unlock() async throws {
@@ -53,14 +90,15 @@ public func withLock<T>(
5390
pollingInterval: TimeInterval = FileLock.defaultPollingInterval,
5491
action: @escaping () async throws -> T
5592
) async throws -> T {
56-
guard
57-
let lock = try? await FileLock.waitForLock(
93+
let lock: FileLock
94+
do {
95+
lock = try await FileLock.waitForLock(
5896
lockFile,
5997
timeout: timeout,
6098
pollingInterval: pollingInterval
6199
)
62-
else {
63-
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile)")
100+
} catch {
101+
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile): \(error.localizedDescription)")
64102
}
65103

66104
do {

Sources/SwiftlyCore/ProcessInfo.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Foundation
2+
3+
#if os(Windows)
4+
import WinSDK
5+
#endif
6+
7+
enum ProcessCheckError: Error {
8+
case invalidPID
9+
case checkFailed
10+
case platformNotSupported
11+
}
12+
13+
/// Checks if a process is still running by process ID
14+
/// - Parameter pidString: The process ID
15+
/// - Returns: true if the process is running, false if it's not running or doesn't exist
16+
/// - Throws: ProcessCheckError if the check fails or PID is invalid
17+
public func isProcessRunning(pidString: String) throws -> Bool {
18+
guard let pid = Int32(pidString.trimmingCharacters(in: .whitespaces)) else {
19+
throw ProcessCheckError.invalidPID
20+
}
21+
22+
return try isProcessRunning(pid: pid)
23+
}
24+
25+
public func isProcessRunning(pid: Int32) throws -> Bool {
26+
#if os(macOS) || os(Linux)
27+
let result = kill(pid, 0)
28+
if result == 0 {
29+
return true
30+
} else if errno == ESRCH { // No such process
31+
return false
32+
} else if errno == EPERM { // Operation not permitted, but process exists
33+
return true
34+
} else {
35+
throw ProcessCheckError.checkFailed
36+
}
37+
38+
#elseif os(Windows)
39+
// On Windows, use OpenProcess to check if process exists
40+
let handle = OpenProcess(DWORD(PROCESS_QUERY_LIMITED_INFORMATION), false, DWORD(pid))
41+
if handle != nil {
42+
CloseHandle(handle)
43+
return true
44+
} else {
45+
let error = GetLastError()
46+
if error == ERROR_INVALID_PARAMETER || error == ERROR_NOT_FOUND {
47+
return false // Process not found
48+
} else {
49+
throw ProcessCheckError.checkFailed
50+
}
51+
}
52+
53+
#else
54+
throw ProcessCheckError.platformNotSupported
55+
#endif
56+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
// Start waiting for lock in background task
124+
let waitTask = Task {
125+
try await Task.sleep(for: .seconds(0.1))
126+
let waitingLock = try await FileLock.waitForLock(
127+
lockPath,
128+
timeout: 2.0,
129+
pollingInterval: 0.1
130+
)
131+
try await waitingLock.unlock()
132+
return true
133+
}
134+
// Release initial lock after delay
135+
try await Task.sleep(for: .seconds(0.3))
136+
try await initialLock.unlock()
137+
// Wait for the waiting task to complete
138+
let result = try await waitTask.value
139+
#expect(result, "Lock wait operation should succeed")
140+
}
141+
}
142+
143+
@Test("withLock executes action and automatically unlocks")
144+
func testWithLockSuccess() async throws {
145+
try await SwiftlyTests.withTestHome {
146+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "withlock.lock"
147+
var actionExecuted = false
148+
149+
let result = try await withLock(lockPath, timeout: 1.0, pollingInterval: 0.1) {
150+
actionExecuted = true
151+
return "success"
152+
}
153+
154+
#expect(actionExecuted)
155+
#expect(result == "success")
156+
#expect(!(try await fs.exists(atPath: lockPath)))
157+
}
158+
}
159+
160+
@Test("withLock unlocks even when action throws")
161+
func testWithLockErrorHandling() async throws {
162+
try await SwiftlyTests.withTestHome {
163+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "withlockError.lock"
164+
165+
struct TestError: Error {}
166+
167+
await #expect(throws: TestError.self) {
168+
try await withLock(lockPath, timeout: 1.0, pollingInterval: 0.1) {
169+
throw TestError()
170+
}
171+
}
172+
173+
// Lock should be released even after error
174+
let exists = try await fs.exists(atPath: lockPath)
175+
#expect(!exists)
176+
}
177+
}
178+
179+
@Test("withLock fails when lock cannot be acquired within timeout")
180+
func testWithLockTimeout() async throws {
181+
try await SwiftlyTests.withTestHome {
182+
let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "withlockTimeout.lock"
183+
184+
// Create existing lock
185+
let existingLock = try FileLock(at: lockPath)
186+
187+
await #expect(throws: SwiftlyError.self) {
188+
try await withLock(lockPath, timeout: 0.5, pollingInterval: 0.1) {
189+
"should not execute"
190+
}
191+
}
192+
193+
try await existingLock.unlock()
194+
}
195+
}
196+
}

0 commit comments

Comments
 (0)