Skip to content

Commit f4c28a4

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

File tree

3 files changed

+305
-13
lines changed

3 files changed

+305
-13
lines changed

Sources/SwiftlyCore/FileLock.swift

Lines changed: 54 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,33 @@ public struct FileLock {
3253
pollingInterval: TimeInterval = FileLock.defaultPollingInterval
3354
) async throws -> FileLock {
3455
let start = Date()
56+
var lockedByPID: String?
57+
3558
while Date().timeIntervalSince(start) < timeout {
36-
if let fileLock = try? FileLock(at: path) {
37-
return fileLock
59+
do {
60+
return try FileLock(at: path)
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) + .milliseconds(Int.random(in: 0...200)))
3874
}
39-
try? await Task.sleep(for: .seconds(pollingInterval))
4075
}
4176

42-
throw FileLockError.timeoutExceeded
77+
// Timeout reached, throw an error
78+
if let lockedByPID = lockedByPID {
79+
throw FileLockError.lockedByPID(path, lockedByPID)
80+
} else {
81+
throw FileLockError.cannotAcquireLock(path)
82+
}
4383
}
4484

4585
public func unlock() async throws {
@@ -53,14 +93,15 @@ public func withLock<T>(
5393
pollingInterval: TimeInterval = FileLock.defaultPollingInterval,
5494
action: @escaping () async throws -> T
5595
) async throws -> T {
56-
guard
57-
let lock = try? await FileLock.waitForLock(
96+
let lock: FileLock
97+
do {
98+
lock = try await FileLock.waitForLock(
5899
lockFile,
59100
timeout: timeout,
60101
pollingInterval: pollingInterval
61102
)
62-
else {
63-
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile)")
103+
} catch {
104+
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile): \(error.localizedDescription)")
64105
}
65106

66107
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)