Skip to content

Commit fcb074e

Browse files
authored
Adding FileLock (#361)
* Issue #142 Implement non-blocking file lock * Add lock in uninstall * Use atomic create for lock * Adding FileLock Tests
1 parent f5bd75a commit fcb074e

File tree

6 files changed

+421
-27
lines changed

6 files changed

+421
-27
lines changed

.unacceptablelanguageignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Sources/SwiftlyCore/ProcessInfo.swift

Sources/Swiftly/Install.swift

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ struct Install: SwiftlyCommand {
8181
try await self.run(Swiftly.createDefaultContext())
8282
}
8383

84+
private func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> FilePath {
85+
Swiftly.currentPlatform.swiftlyHomeDir(ctx)
86+
}
87+
8488
mutating func run(_ ctx: SwiftlyCoreContext) async throws {
8589
let versionUpdateReminder = try await validateSwiftly(ctx)
8690
defer {
@@ -346,33 +350,51 @@ struct Install: SwiftlyCommand {
346350
)
347351
}
348352

349-
try await Swiftly.currentPlatform.install(ctx, from: tmpFile, version: version, verbose: verbose)
353+
let lockFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "swiftly.lock"
354+
if verbose {
355+
await ctx.print("Attempting to acquire installation lock at \(lockFile) ...")
356+
}
350357

351-
let pathChanged = try await Self.setupProxies(
352-
ctx,
353-
version: version,
354-
verbose: verbose,
355-
assumeYes: assumeYes
356-
)
358+
let (pathChanged, newConfig) = try await withLock(lockFile) {
359+
if verbose {
360+
await ctx.print("Acquired installation lock")
361+
}
357362

358-
config.installedToolchains.insert(version)
363+
var config = try await Config.load(ctx)
359364

360-
try config.save(ctx)
365+
try await Swiftly.currentPlatform.install(
366+
ctx, from: tmpFile,
367+
version: version,
368+
verbose: verbose
369+
)
361370

362-
// If this is the first installed toolchain, mark it as in-use regardless of whether the
363-
// --use argument was provided.
364-
if useInstalledToolchain {
365-
try await Use.execute(ctx, version, globalDefault: false, &config)
366-
}
371+
let pathChanged = try await Self.setupProxies(
372+
ctx,
373+
version: version,
374+
verbose: verbose,
375+
assumeYes: assumeYes
376+
)
377+
378+
config.installedToolchains.insert(version)
367379

368-
// We always update the global default toolchain if there is none set. This could
369-
// be the only toolchain that is installed, which makes it the only choice.
370-
if config.inUse == nil {
371-
config.inUse = version
372380
try config.save(ctx)
373-
await ctx.print("The global default toolchain has been set to `\(version)`")
374-
}
375381

382+
// If this is the first installed toolchain, mark it as in-use regardless of whether the
383+
// --use argument was provided.
384+
if useInstalledToolchain {
385+
try await Use.execute(ctx, version, globalDefault: false, &config)
386+
}
387+
388+
// We always update the global default toolchain if there is none set. This could
389+
// be the only toolchain that is installed, which makes it the only choice.
390+
if config.inUse == nil {
391+
config.inUse = version
392+
try config.save(ctx)
393+
await ctx.print("The global default toolchain has been set to `\(version)`")
394+
}
395+
return (pathChanged, config)
396+
}
397+
config = newConfig
376398
await ctx.print("\(version) installed successfully!")
377399
return (postInstallScript, pathChanged)
378400
}

Sources/Swiftly/Uninstall.swift

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,28 @@ struct Uninstall: SwiftlyCommand {
128128
await ctx.print("\(toolchains.count) toolchain(s) successfully uninstalled")
129129
}
130130

131-
static func execute(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ config: inout Config, verbose: Bool) async throws {
131+
static func execute(
132+
_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ config: inout Config,
133+
verbose: Bool
134+
) async throws {
132135
await ctx.print("Uninstalling \(toolchain)... ", terminator: "")
133-
config.installedToolchains.remove(toolchain)
134-
// This is here to prevent the inUse from referencing a toolchain that is not installed
135-
if config.inUse == toolchain {
136-
config.inUse = nil
136+
let lockFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "swiftly.lock"
137+
if verbose {
138+
await ctx.print("Attempting to acquire installation lock at \(lockFile) ...")
137139
}
138-
try config.save(ctx)
139140

140-
try await Swiftly.currentPlatform.uninstall(ctx, toolchain, verbose: verbose)
141+
config = try await withLock(lockFile) {
142+
var config = try await Config.load(ctx)
143+
config.installedToolchains.remove(toolchain)
144+
// This is here to prevent the inUse from referencing a toolchain that is not installed
145+
if config.inUse == toolchain {
146+
config.inUse = nil
147+
}
148+
try config.save(ctx)
149+
150+
try await Swiftly.currentPlatform.uninstall(ctx, toolchain, verbose: verbose)
151+
return config
152+
}
141153
await ctx.print("done")
142154
}
143155
}

Sources/SwiftlyCore/FileLock.swift

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import Foundation
2+
import SystemPackage
3+
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+
}
17+
}
18+
19+
/// A non-blocking file lock implementation using file creation as locking mechanism.
20+
/// Use case: When installing multiple Swiftly instances on the same machine,
21+
/// one should acquire the lock while others poll until it becomes available.
22+
public struct FileLock {
23+
let filePath: FilePath
24+
25+
public static let defaultPollingInterval: TimeInterval = 1
26+
public static let defaultTimeout: TimeInterval = 300.0
27+
28+
public init(at path: FilePath) throws {
29+
self.filePath = path
30+
do {
31+
let fileURL = URL(fileURLWithPath: self.filePath.string)
32+
let contents = Foundation.ProcessInfo.processInfo.processIdentifier.description.data(using: .utf8)
33+
?? Data()
34+
try contents.write(to: fileURL, options: .withoutOverwriting)
35+
} catch CocoaError.fileWriteFileExists {
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+
}
47+
}
48+
}
49+
50+
public static func waitForLock(
51+
_ path: FilePath,
52+
timeout: TimeInterval = FileLock.defaultTimeout,
53+
pollingInterval: TimeInterval = FileLock.defaultPollingInterval
54+
) async throws -> FileLock {
55+
let start = Date()
56+
var lastError: Error?
57+
58+
while Date().timeIntervalSince(start) < timeout {
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)))
67+
}
68+
}
69+
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+
}
76+
}
77+
78+
public func unlock() async throws {
79+
try await FileSystem.remove(atPath: self.filePath)
80+
}
81+
}
82+
83+
public func withLock<T>(
84+
_ lockFile: FilePath,
85+
timeout: TimeInterval = FileLock.defaultTimeout,
86+
pollingInterval: TimeInterval = FileLock.defaultPollingInterval,
87+
action: @escaping () async throws -> T
88+
) async throws -> T {
89+
let lock: FileLock
90+
do {
91+
lock = try await FileLock.waitForLock(
92+
lockFile,
93+
timeout: timeout,
94+
pollingInterval: pollingInterval
95+
)
96+
} catch {
97+
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile): \(error.localizedDescription)")
98+
}
99+
100+
do {
101+
let result = try await action()
102+
try await lock.unlock()
103+
return result
104+
} catch {
105+
try await lock.unlock()
106+
throw error
107+
}
108+
}

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+
}

0 commit comments

Comments
 (0)