Skip to content

Commit e3cc983

Browse files
committed
Issue #142 Implement non-blocking file lock
1 parent e3c2f0f commit e3cc983

File tree

2 files changed

+146
-0
lines changed

2 files changed

+146
-0
lines changed

Sources/Swiftly/Install.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,61 @@ 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 {
8791
versionUpdateReminder()
8892
}
8993
try await validateLinked(ctx)
9094

95+
let lockFile = self.swiftlyHomeDir(ctx) / "swiftly.lock"
96+
let fileLock: SwiftlyCore.FileLock
97+
98+
do {
99+
fileLock = try SwiftlyCore.FileLock(at: lockFile.string)
100+
} catch {
101+
print("ERROR: Failed to create lock file: \(error)")
102+
Foundation.exit(43)
103+
}
104+
if self.root.verbose {
105+
await ctx.print("Process \(ProcessInfo.processInfo.processIdentifier): Attempting to acquire installation lock at \(lockFile.string)...")
106+
} else {
107+
await ctx.print("Attempting to acquire installation lock...")
108+
}
109+
if fileLock.tryLock() {
110+
if self.root.verbose {
111+
await ctx.print("Process \(ProcessInfo.processInfo.processIdentifier): Lock acquired immediately")
112+
}
113+
} else {
114+
// If immediate acquisition fails, enter the waiting loop
115+
await ctx.print("Lock is held by another process, waiting...")
116+
guard fileLock.waitForLock(timeout: 300, pollingInterval: 2) else {
117+
print("ERROR: Failed to acquire lock on file: \(lockFile.string) after 300 seconds")
118+
Foundation.exit(42)
119+
}
120+
if self.root.verbose {
121+
await ctx.print("Process \(ProcessInfo.processInfo.processIdentifier): Lock acquired after waiting")
122+
}
123+
}
124+
125+
defer {
126+
do {
127+
if self.root.verbose {
128+
print("Process \(ProcessInfo.processInfo.processIdentifier): Releasing installation lock...")
129+
}
130+
try fileLock.unlock()
131+
if self.root.verbose {
132+
print("Process \(ProcessInfo.processInfo.processIdentifier): Installation lock released")
133+
}
134+
} catch {
135+
print("WARNING: Failed to unlock file: \(error)")
136+
}
137+
}
138+
91139
var config = try await Config.load(ctx)
92140
let toolchainVersion = try await Self.determineToolchainVersion(ctx, version: self.version, config: &config)
93141

Sources/SwiftlyCore/FileLock.swift

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Foundation
2+
3+
/**
4+
* A non-blocking file lock implementation with polling capability.
5+
* Use case: When installing multiple Swiftly instances on the same machine,
6+
* one should acquire the lock while others poll until it becomes available.
7+
*/
8+
9+
#if os(macOS)
10+
import Darwin.C
11+
#elseif os(Linux)
12+
import Glibc
13+
#endif
14+
15+
public struct FileLock {
16+
let filePath: String
17+
18+
let fileHandle: FileHandle
19+
20+
public static let defaultPollingInterval: TimeInterval = 1.0
21+
22+
public static let defaultTimeout: TimeInterval = 300.0
23+
24+
public init(at filePath: String) throws {
25+
self.filePath = filePath
26+
27+
if !FileManager.default.fileExists(atPath: filePath) {
28+
FileManager.default.createFile(atPath: filePath, contents: nil)
29+
}
30+
31+
self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath))
32+
}
33+
34+
public func tryLock() -> Bool {
35+
self.fileHandle.tryLockFile()
36+
}
37+
38+
public func waitForLock(
39+
timeout: TimeInterval = FileLock.defaultTimeout,
40+
pollingInterval: TimeInterval = FileLock.defaultPollingInterval
41+
) -> Bool {
42+
let startTime = Date()
43+
44+
if self.tryLock() {
45+
return true
46+
}
47+
48+
while Date().timeIntervalSince(startTime) < timeout {
49+
Thread.sleep(forTimeInterval: pollingInterval)
50+
51+
if self.tryLock() {
52+
return true
53+
}
54+
}
55+
56+
return false
57+
}
58+
59+
public func unlock() throws {
60+
try self.fileHandle.unlockFile()
61+
try self.fileHandle.close()
62+
}
63+
}
64+
65+
extension FileHandle {
66+
func tryLockFile() -> Bool {
67+
let fd = self.fileDescriptor
68+
var flock = flock()
69+
flock.l_type = Int16(F_WRLCK)
70+
flock.l_whence = Int16(SEEK_SET)
71+
flock.l_start = 0
72+
flock.l_len = 0
73+
74+
if fcntl(fd, F_SETLK, &flock) == -1 {
75+
if errno == EACCES || errno == EAGAIN {
76+
return false
77+
} else {
78+
fputs("Unexpected lock error: \(String(cString: strerror(errno)))\n", stderr)
79+
return false
80+
}
81+
}
82+
return true
83+
}
84+
85+
func unlockFile() throws {
86+
let fd = self.fileDescriptor
87+
var flock = flock()
88+
flock.l_type = Int16(F_UNLCK)
89+
flock.l_whence = Int16(SEEK_SET)
90+
flock.l_start = 0
91+
flock.l_len = 0
92+
93+
if fcntl(fd, F_SETLK, &flock) == -1 {
94+
throw SwiftlyError(
95+
message: "Failed to unlock file: \(String(cString: strerror(errno)))")
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)