Skip to content

Commit 20d1898

Browse files
committed
Adding FileLock Tests
1 parent 1fd0dd2 commit 20d1898

File tree

3 files changed

+298
-13
lines changed

3 files changed

+298
-13
lines changed

Sources/SwiftlyCore/FileLock.swift

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
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. 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+
}
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+
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+
}
2647
}
2748
}
2849

@@ -32,14 +53,26 @@ public struct FileLock {
3253
pollingInterval: TimeInterval = FileLock.defaultPollingInterval
3354
) async throws -> FileLock {
3455
let start = Date()
56+
var lastError: Error?
57+
3558
while Date().timeIntervalSince(start) < timeout {
36-
if let fileLock = try? FileLock(at: path) {
37-
return fileLock
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)))
3867
}
39-
try? await Task.sleep(for: .seconds(pollingInterval))
4068
}
4169

42-
throw FileLockError.timeoutExceeded
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+
}
4376
}
4477

4578
public func unlock() async throws {
@@ -53,14 +86,15 @@ public func withLock<T>(
5386
pollingInterval: TimeInterval = FileLock.defaultPollingInterval,
5487
action: @escaping () async throws -> T
5588
) async throws -> T {
56-
guard
57-
let lock = try? await FileLock.waitForLock(
89+
let lock: FileLock
90+
do {
91+
lock = try await FileLock.waitForLock(
5892
lockFile,
5993
timeout: timeout,
6094
pollingInterval: pollingInterval
6195
)
62-
else {
63-
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile)")
96+
} catch {
97+
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile): \(error.localizedDescription)")
6498
}
6599

66100
do {

Sources/SwiftlyCore/ProcessInfo.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
}
11+
12+
/// Checks if a process is still running by process ID
13+
/// - Parameter pidString: The process ID
14+
/// - Returns: true if the process is running, false if it's not running or doesn't exist
15+
/// - Throws: ProcessCheckError if the check fails or PID is invalid
16+
public func isProcessRunning(pidString: String) throws -> Bool {
17+
guard let pid = Int32(pidString.trimmingCharacters(in: .whitespaces)) else {
18+
throw ProcessCheckError.invalidPID
19+
}
20+
21+
return try isProcessRunning(pid: pid)
22+
}
23+
24+
public func isProcessRunning(pid: Int32) throws -> Bool {
25+
#if os(macOS) || os(Linux)
26+
let result = kill(pid, 0)
27+
if result == 0 {
28+
return true
29+
} else if errno == ESRCH { // No such process
30+
return false
31+
} else if errno == EPERM { // Operation not permitted, but process exists
32+
return true
33+
} else {
34+
throw ProcessCheckError.checkFailed
35+
}
36+
37+
#elseif os(Windows)
38+
// On Windows, use OpenProcess to check if process exists
39+
let handle = OpenProcess(DWORD(PROCESS_QUERY_LIMITED_INFORMATION), false, DWORD(pid))
40+
if handle != nil {
41+
CloseHandle(handle)
42+
return true
43+
} else {
44+
let error = GetLastError()
45+
if error == ERROR_INVALID_PARAMETER || error == ERROR_NOT_FOUND {
46+
return false // Process not found
47+
} else {
48+
throw ProcessCheckError.checkFailed
49+
}
50+
}
51+
52+
#else
53+
#error("Platform is not supported")
54+
#endif
55+
}
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)