Skip to content

Commit 1ad88a3

Browse files
committed
Reduced critical section under lock
1 parent e55967c commit 1ad88a3

File tree

2 files changed

+86
-110
lines changed

2 files changed

+86
-110
lines changed

Sources/Swiftly/Install.swift

Lines changed: 37 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -92,32 +92,6 @@ struct Install: SwiftlyCommand {
9292
}
9393
try await validateLinked(ctx)
9494

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(42)
103-
}
104-
105-
defer {
106-
do {
107-
try fileLock.unlock()
108-
} catch {
109-
print("WARNING: Failed to unlock file: \(error)")
110-
}
111-
}
112-
113-
if self.root.verbose {
114-
await ctx.print("Attempting to acquire installation lock...")
115-
}
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-
12195
var config = try await Config.load(ctx)
12296
let toolchainVersion = try await Self.determineToolchainVersion(ctx, version: self.version, config: &config)
12397

@@ -376,31 +350,49 @@ struct Install: SwiftlyCommand {
376350
)
377351
}
378352

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

381-
let pathChanged = try await Self.setupProxies(
382-
ctx,
383-
version: version,
384-
verbose: verbose,
385-
assumeYes: assumeYes
386-
)
358+
let pathChanged = try await withLock(lockFile) {
359+
if verbose {
360+
await ctx.print("Acquired installation lock.")
361+
}
387362

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

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

392-
// If this is the first installed toolchain, mark it as in-use regardless of whether the
393-
// --use argument was provided.
394-
if useInstalledToolchain {
395-
try await Use.execute(ctx, version, globalDefault: false, &config)
396-
}
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)
397379

398-
// We always update the global default toolchain if there is none set. This could
399-
// be the only toolchain that is installed, which makes it the only choice.
400-
if config.inUse == nil {
401-
config.inUse = version
402380
try config.save(ctx)
403-
await ctx.print("The global default toolchain has been set to `\(version)`")
381+
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
404396
}
405397

406398
await ctx.print("\(version) installed successfully!")

Sources/SwiftlyCore/FileLock.swift

Lines changed: 49 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,83 @@
11
import Foundation
2+
import SystemPackage
23

34
/**
4-
* A non-blocking file lock implementation with polling capability.
5+
* A non-blocking file lock implementation using file creation as locking mechanism.
56
* Use case: When installing multiple Swiftly instances on the same machine,
67
* one should acquire the lock while others poll until it becomes available.
78
*/
89

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
10+
public actor FileLock {
11+
let filePath: FilePath
12+
private var isLocked = false
2113

14+
public static let defaultPollingInterval: TimeInterval = 1
2215
public static let defaultTimeout: TimeInterval = 300.0
2316

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))
17+
public init(at path: FilePath) {
18+
self.filePath = path
3219
}
3320

34-
public func tryLock() -> Bool {
35-
self.fileHandle.tryLockFile()
21+
public func tryLock() async -> Bool {
22+
do {
23+
guard !self.isLocked else { return true }
24+
25+
guard !(try await FileSystem.exists(atPath: self.filePath)) else {
26+
return false
27+
}
28+
// Create the lock file with exclusive permissions
29+
try await FileSystem.create(.mode(0o600), file: self.filePath, contents: nil)
30+
self.isLocked = true
31+
return true
32+
} catch {
33+
return false
34+
}
3635
}
3736

3837
public func waitForLock(
3938
timeout: TimeInterval = FileLock.defaultTimeout,
4039
pollingInterval: TimeInterval = FileLock.defaultPollingInterval
41-
) -> Bool {
42-
let startTime = Date()
40+
) async -> Bool {
41+
let start = Date()
4342

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() {
43+
while Date().timeIntervalSince(start) < timeout {
44+
if await self.tryLock() {
5245
return true
5346
}
47+
try? await Task.sleep(for: .seconds(pollingInterval))
5448
}
5549

5650
return false
5751
}
5852

59-
public func unlock() throws {
60-
guard self.fileHandle != nil else { return }
61-
try self.fileHandle.unlockFile()
62-
try self.fileHandle.close()
53+
public func unlock() async throws {
54+
guard self.isLocked else { return }
55+
56+
try await FileSystem.remove(atPath: self.filePath)
57+
self.isLocked = false
6358
}
6459
}
6560

66-
extension FileHandle {
67-
func tryLockFile() -> Bool {
68-
let fd = self.fileDescriptor
69-
var flock = flock()
70-
flock.l_type = Int16(F_WRLCK)
71-
flock.l_whence = Int16(SEEK_SET)
72-
flock.l_start = 0
73-
flock.l_len = 0
61+
public func withLock<T>(
62+
_ lockFile: FilePath,
63+
timeout: TimeInterval = FileLock.defaultTimeout,
64+
pollingInterval: TimeInterval = FileLock.defaultPollingInterval,
65+
action: @escaping () async throws -> T
66+
) async throws -> T {
67+
let lock = FileLock(at: lockFile)
68+
guard await lock.waitForLock(timeout: timeout, pollingInterval: pollingInterval) else {
69+
throw SwiftlyError(message: "Failed to acquire file lock at \(lock.filePath)")
70+
}
7471

75-
if fcntl(fd, F_SETLK, &flock) == -1 {
76-
if errno == EACCES || errno == EAGAIN {
77-
return false
78-
} else {
79-
fputs("Unexpected lock error: \(String(cString: strerror(errno)))\n", stderr)
80-
return false
72+
defer {
73+
Task {
74+
do {
75+
try await lock.unlock()
76+
} catch {
77+
print("WARNING: Failed to unlock file: \(error)")
8178
}
8279
}
83-
return true
8480
}
8581

86-
func unlockFile() throws {
87-
let fd = self.fileDescriptor
88-
var flock = flock()
89-
flock.l_type = Int16(F_UNLCK)
90-
flock.l_whence = Int16(SEEK_SET)
91-
flock.l_start = 0
92-
flock.l_len = 0
93-
94-
if fcntl(fd, F_SETLK, &flock) == -1 {
95-
throw SwiftlyError(
96-
message: "Failed to unlock file: \(String(cString: strerror(errno)))")
97-
}
98-
}
82+
return try await action()
9983
}

0 commit comments

Comments
 (0)