diff --git a/.unacceptablelanguageignore b/.unacceptablelanguageignore new file mode 100644 index 00000000..66fe613d --- /dev/null +++ b/.unacceptablelanguageignore @@ -0,0 +1 @@ +Sources/SwiftlyCore/ProcessInfo.swift \ No newline at end of file diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 0310adad..59cbccb0 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -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 { @@ -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) } diff --git a/Sources/Swiftly/Uninstall.swift b/Sources/Swiftly/Uninstall.swift index a329142a..15b1ac2a 100644 --- a/Sources/Swiftly/Uninstall.swift +++ b/Sources/Swiftly/Uninstall.swift @@ -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") } } diff --git a/Sources/SwiftlyCore/FileLock.swift b/Sources/SwiftlyCore/FileLock.swift new file mode 100644 index 00000000..4d996a1a --- /dev/null +++ b/Sources/SwiftlyCore/FileLock.swift @@ -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)." + 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( + _ 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 + } +} diff --git a/Sources/SwiftlyCore/ProcessInfo.swift b/Sources/SwiftlyCore/ProcessInfo.swift new file mode 100644 index 00000000..137c926a --- /dev/null +++ b/Sources/SwiftlyCore/ProcessInfo.swift @@ -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 +} diff --git a/Tests/SwiftlyTests/FileLockTests.swift b/Tests/SwiftlyTests/FileLockTests.swift new file mode 100644 index 00000000..b91a8b87 --- /dev/null +++ b/Tests/SwiftlyTests/FileLockTests.swift @@ -0,0 +1,196 @@ +import Foundation +import SystemPackage +import Testing + +@testable import SwiftlyCore + +@Suite struct FileLockTests { + @Test("FileLock creation writes process ID to lock file") + func testFileLockCreation() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "test.lock" + + let lock = try FileLock(at: lockPath) + + // Verify lock file exists + #expect(try await fs.exists(atPath: lockPath)) + + // Verify lock file contains process ID + let lockData = try Data(contentsOf: URL(fileURLWithPath: lockPath.string)) + let lockContent = String(data: lockData, encoding: .utf8) + let expectedPID = Foundation.ProcessInfo.processInfo.processIdentifier.description + #expect(lockContent == expectedPID) + + try await lock.unlock() + } + } + + @Test("FileLock fails when lock file already exists") + func testFileLockConflict() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "conflict.lock" + + // Create first lock + let firstLock = try FileLock(at: lockPath) + + // Attempt to create second lock should fail + do { + _ = try FileLock(at: lockPath) + #expect(Bool(false), "Expected FileLockError.lockedByPID to be thrown") + } catch let error as FileLockError { + if case .lockedByPID = error { + } else { + #expect(Bool(false), "Expected FileLockError.lockedByPID but got \(error)") + } + } + + try await firstLock.unlock() + } + } + + @Test("FileLock unlock removes lock file") + func testFileLockUnlock() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "unlock.lock" + + let lock = try FileLock(at: lockPath) + #expect(try await fs.exists(atPath: lockPath)) + + try await lock.unlock() + #expect(!(try await fs.exists(atPath: lockPath))) + } + } + + @Test("FileLock can be reacquired after unlock") + func testFileLockReacquisition() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "reacquire.lock" + + // First acquisition + let firstLock = try FileLock(at: lockPath) + try await firstLock.unlock() + + // Second acquisition should succeed + let secondLock = try FileLock(at: lockPath) + try await secondLock.unlock() + } + } + + @Test("waitForLock succeeds immediately when no lock exists") + func testWaitForLockImmediate() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "immediate.lock" + let time = Date() + let lock = try await FileLock.waitForLock(lockPath, timeout: 1.0, pollingInterval: 0.1) + let duration = Date().timeIntervalSince(time) + #expect(duration < 1.0) + #expect(try await fs.exists(atPath: lockPath)) + try await lock.unlock() + } + } + + @Test("waitForLock times out when lock cannot be acquired") + func testWaitForLockTimeout() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "timeout.lock" + + // Create existing lock + let existingLock = try FileLock(at: lockPath) + + // Attempt to wait for lock should timeout + do { + _ = try await FileLock.waitForLock(lockPath, timeout: 0.5, pollingInterval: 0.1) + #expect(Bool(false), "Expected FileLockError.lockedByPID to be thrown") + } catch let error as FileLockError { + if case .lockedByPID = error { + // Expected error + } else { + #expect(Bool(false), "Expected FileLockError.lockedByPID but got \(error)") + } + } + + try await existingLock.unlock() + } + } + + @Test("waitForLock succeeds when lock becomes available") + func testWaitForLockEventualSuccess() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "eventual.lock" + + // Create initial lock + let initialLock = try FileLock(at: lockPath) + // Start waiting for lock in background task + let waitTask = Task { + try await Task.sleep(for: .seconds(0.1)) + let waitingLock = try await FileLock.waitForLock( + lockPath, + timeout: 2.0, + pollingInterval: 0.1 + ) + try await waitingLock.unlock() + return true + } + // Release initial lock after delay + try await Task.sleep(for: .seconds(0.3)) + try await initialLock.unlock() + // Wait for the waiting task to complete + let result = try await waitTask.value + #expect(result, "Lock wait operation should succeed") + } + } + + @Test("withLock executes action and automatically unlocks") + func testWithLockSuccess() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "withlock.lock" + var actionExecuted = false + + let result = try await withLock(lockPath, timeout: 1.0, pollingInterval: 0.1) { + actionExecuted = true + return "success" + } + + #expect(actionExecuted) + #expect(result == "success") + #expect(!(try await fs.exists(atPath: lockPath))) + } + } + + @Test("withLock unlocks even when action throws") + func testWithLockErrorHandling() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "withlockError.lock" + + struct TestError: Error {} + + await #expect(throws: TestError.self) { + try await withLock(lockPath, timeout: 1.0, pollingInterval: 0.1) { + throw TestError() + } + } + + // Lock should be released even after error + let exists = try await fs.exists(atPath: lockPath) + #expect(!exists) + } + } + + @Test("withLock fails when lock cannot be acquired within timeout") + func testWithLockTimeout() async throws { + try await SwiftlyTests.withTestHome { + let lockPath = SwiftlyTests.ctx.mockedHomeDir! / "withlockTimeout.lock" + + // Create existing lock + let existingLock = try FileLock(at: lockPath) + + await #expect(throws: SwiftlyError.self) { + try await withLock(lockPath, timeout: 0.5, pollingInterval: 0.1) { + "should not execute" + } + } + + try await existingLock.unlock() + } + } +}