Skip to content

Adding FileLock #361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .unacceptablelanguageignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Sources/SwiftlyCore/ProcessInfo.swift
62 changes: 42 additions & 20 deletions Sources/Swiftly/Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ struct Install: SwiftlyCommand {
try await self.run(Swiftly.createDefaultContext())
}

private func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> FilePath {
Swiftly.currentPlatform.swiftlyHomeDir(ctx)
}

mutating func run(_ ctx: SwiftlyCoreContext) async throws {
let versionUpdateReminder = try await validateSwiftly(ctx)
defer {
Expand Down Expand Up @@ -346,33 +350,51 @@ struct Install: SwiftlyCommand {
)
}

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

let pathChanged = try await Self.setupProxies(
ctx,
version: version,
verbose: verbose,
assumeYes: assumeYes
)
let (pathChanged, newConfig) = try await withLock(lockFile) {
if verbose {
await ctx.print("Acquired installation lock")
}

config.installedToolchains.insert(version)
var config = try await Config.load(ctx)

try config.save(ctx)
try await Swiftly.currentPlatform.install(
ctx, from: tmpFile,
version: version,
verbose: verbose
)

// If this is the first installed toolchain, mark it as in-use regardless of whether the
// --use argument was provided.
if useInstalledToolchain {
try await Use.execute(ctx, version, globalDefault: false, &config)
}
let pathChanged = try await Self.setupProxies(
ctx,
version: version,
verbose: verbose,
assumeYes: assumeYes
)

config.installedToolchains.insert(version)

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

// If this is the first installed toolchain, mark it as in-use regardless of whether the
// --use argument was provided.
if useInstalledToolchain {
try await Use.execute(ctx, version, globalDefault: false, &config)
}

// We always update the global default toolchain if there is none set. This could
// be the only toolchain that is installed, which makes it the only choice.
if config.inUse == nil {
config.inUse = version
try config.save(ctx)
await ctx.print("The global default toolchain has been set to `\(version)`")
}
return (pathChanged, config)
}
config = newConfig
await ctx.print("\(version) installed successfully!")
return (postInstallScript, pathChanged)
}
Expand Down
26 changes: 19 additions & 7 deletions Sources/Swiftly/Uninstall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,28 @@ struct Uninstall: SwiftlyCommand {
await ctx.print("\(toolchains.count) toolchain(s) successfully uninstalled")
}

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

try await Swiftly.currentPlatform.uninstall(ctx, toolchain, verbose: verbose)
config = try await withLock(lockFile) {
var config = try await Config.load(ctx)
config.installedToolchains.remove(toolchain)
// This is here to prevent the inUse from referencing a toolchain that is not installed
if config.inUse == toolchain {
config.inUse = nil
}
try config.save(ctx)

try await Swiftly.currentPlatform.uninstall(ctx, toolchain, verbose: verbose)
return config
}
await ctx.print("done")
}
}
108 changes: 108 additions & 0 deletions Sources/SwiftlyCore/FileLock.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Foundation
import SystemPackage

enum FileLockError: Error, LocalizedError {
case cannotAcquireLock(FilePath)
case lockedByPID(FilePath, String)

var errorDescription: String? {
switch self {
case let .cannotAcquireLock(path):
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)."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: The path is mentioned twice in the error message when it is probably only needed once.

case let .lockedByPID(path, pid):
return
"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."
}
}
}

/// A non-blocking file lock implementation using file creation as locking mechanism.
/// Use case: When installing multiple Swiftly instances on the same machine,
/// one should acquire the lock while others poll until it becomes available.
public struct FileLock {
let filePath: FilePath

public static let defaultPollingInterval: TimeInterval = 1
public static let defaultTimeout: TimeInterval = 300.0

public init(at path: FilePath) throws {
self.filePath = path
do {
let fileURL = URL(fileURLWithPath: self.filePath.string)
let contents = Foundation.ProcessInfo.processInfo.processIdentifier.description.data(using: .utf8)
?? Data()
try contents.write(to: fileURL, options: .withoutOverwriting)
} catch CocoaError.fileWriteFileExists {
// Read the PID from the existing lock file
let fileURL = URL(fileURLWithPath: self.filePath.string)
if let data = try? Data(contentsOf: fileURL),
let pidString = String(data: data, encoding: .utf8)?.trimmingCharacters(
in: .whitespacesAndNewlines),
!pidString.isEmpty
{
throw FileLockError.lockedByPID(self.filePath, pidString)
} else {
throw FileLockError.cannotAcquireLock(self.filePath)
}
}
}

public static func waitForLock(
_ path: FilePath,
timeout: TimeInterval = FileLock.defaultTimeout,
pollingInterval: TimeInterval = FileLock.defaultPollingInterval
) async throws -> FileLock {
let start = Date()
var lastError: Error?

while Date().timeIntervalSince(start) < timeout {
let result = Result { try FileLock(at: path) }

switch result {
case let .success(lock):
return lock
case let .failure(error):
lastError = error
try? await Task.sleep(for: .seconds(pollingInterval) + .milliseconds(Int.random(in: 0...200)))
}
}

// Timeout reached, throw the last error from the loop
if let lastError = lastError {
throw lastError
} else {
throw FileLockError.cannotAcquireLock(path)
}
}

public func unlock() async throws {
try await FileSystem.remove(atPath: self.filePath)
}
}

public func withLock<T>(
_ lockFile: FilePath,
timeout: TimeInterval = FileLock.defaultTimeout,
pollingInterval: TimeInterval = FileLock.defaultPollingInterval,
action: @escaping () async throws -> T
) async throws -> T {
let lock: FileLock
do {
lock = try await FileLock.waitForLock(
lockFile,
timeout: timeout,
pollingInterval: pollingInterval
)
} catch {
throw SwiftlyError(message: "Failed to acquire file lock at \(lockFile): \(error.localizedDescription)")
}

do {
let result = try await action()
try await lock.unlock()
return result
} catch {
try await lock.unlock()
throw error
}
}
55 changes: 55 additions & 0 deletions Sources/SwiftlyCore/ProcessInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

#if os(Windows)
import WinSDK
#endif

enum ProcessCheckError: Error {
case invalidPID
case checkFailed
}

/// Checks if a process is still running by process ID
/// - Parameter pidString: The process ID
/// - Returns: true if the process is running, false if it's not running or doesn't exist
/// - Throws: ProcessCheckError if the check fails or PID is invalid
public func isProcessRunning(pidString: String) throws -> Bool {
guard let pid = Int32(pidString.trimmingCharacters(in: .whitespaces)) else {
throw ProcessCheckError.invalidPID
}

return try isProcessRunning(pid: pid)
}

public func isProcessRunning(pid: Int32) throws -> Bool {
#if os(macOS) || os(Linux)
let result = kill(pid, 0)
if result == 0 {
return true
} else if errno == ESRCH { // No such process
return false
} else if errno == EPERM { // Operation not permitted, but process exists
return true
} else {
throw ProcessCheckError.checkFailed
}

#elseif os(Windows)
// On Windows, use OpenProcess to check if process exists
let handle = OpenProcess(DWORD(PROCESS_QUERY_LIMITED_INFORMATION), false, DWORD(pid))
if handle != nil {
CloseHandle(handle)
return true
} else {
let error = GetLastError()
if error == ERROR_INVALID_PARAMETER || error == ERROR_NOT_FOUND {
return false // Process not found
} else {
throw ProcessCheckError.checkFailed
}
}

#else
#error("Platform is not supported")
#endif
}
Loading