diff --git a/Sources/Basics/Concurrency/PID.swift b/Sources/Basics/Concurrency/PID.swift new file mode 100644 index 00000000000..fe9593b91e4 --- /dev/null +++ b/Sources/Basics/Concurrency/PID.swift @@ -0,0 +1,76 @@ +// +// PID.swift +// SwiftPM +// +// Created by John Bute on 2025-04-24. +// + +import Foundation + +public protocol pidFileManipulator { + var scratchDirectory: AbsolutePath {get set} + + init(scratchDirectory: AbsolutePath) + + func readPID() -> Int32? + func deletePIDFile() throws + func writePID(pid: pid_t) throws + func getCurrentPID() -> Int32 +} + + + +public struct pidFile: pidFileManipulator { + + public var scratchDirectory: AbsolutePath + + public init(scratchDirectory: AbsolutePath) { + self.scratchDirectory = scratchDirectory + } + + /// Return the path of the PackageManager.lock.pid file where the PID is located + private var pidFilePath: AbsolutePath { + return self.scratchDirectory.appending(component: "PackageManager.lock.pid") + } + + /// Read the pid file + public func readPID() -> Int32? { + // Check if the file exists + let filePath = pidFilePath.pathString + guard FileManager.default.fileExists(atPath: filePath) else { + print("File does not exist at path: \(filePath)") + return nil + } + + do { + // Read the contents of the file + let pidString = try String(contentsOf: pidFilePath.asURL, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if the PID string can be converted to an Int32 + if let pid = Int32(pidString) { + return pid + } else { + return nil + } + } catch { + // Catch any errors and print them + return nil + } + } + + /// Get the current PID of the process + public func getCurrentPID() -> Int32 { + return getpid() + } + + /// Write .pid file containing PID of process currently using .build directory + public func writePID(pid: pid_t) throws { + try "\(pid)".write(to: pidFilePath.asURL, atomically: true, encoding: .utf8) + } + + /// Delete PID file at URL + public func deletePIDFile() throws { + try FileManager.default.removeItem(at: pidFilePath.asURL) + } + +} diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index 12e193d838f..8428e4a0c93 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -14,7 +14,7 @@ import _Concurrency import ArgumentParser import Basics import Dispatch -import class Foundation.NSLock +import class Foundation.NSDistributedLock import class Foundation.ProcessInfo import PackageGraph import PackageLoading @@ -22,6 +22,7 @@ import PackageLoading import PackageModel import SPMBuildCore import Workspace +import Foundation.NSFileManager #if USE_IMPL_ONLY_IMPORTS @_implementationOnly @@ -287,6 +288,7 @@ public final class SwiftCommandState { private let hostTriple: Basics.Triple? + private let pidManipulator: pidFileManipulator package var preferredBuildConfiguration = BuildConfiguration.debug /// Create an instance of this tool. @@ -324,7 +326,8 @@ public final class SwiftCommandState { createPackagePath: Bool, hostTriple: Basics.Triple? = nil, fileSystem: any FileSystem = localFileSystem, - environment: Environment = .current + environment: Environment = .current, + pidManipulator: pidFileManipulator? = nil ) throws { self.hostTriple = hostTriple self.fileSystem = fileSystem @@ -407,6 +410,8 @@ public final class SwiftCommandState { self.sharedSwiftSDKsDirectory = try fileSystem.getSharedSwiftSDKsDirectory( explicitDirectory: options.locations.swiftSDKsDirectory ?? options.locations.deprecatedSwiftSDKsDirectory ) + + self.pidManipulator = pidManipulator ?? pidFile(scratchDirectory: self.scratchDirectory) // set global process logging handler AsyncProcess.loggingHandler = { self.observabilityScope.emit(debug: $0) } @@ -1056,33 +1061,43 @@ public final class SwiftCommandState { let workspaceLock = try FileLock.prepareLock(fileToLock: self.scratchDirectory) + var lockAcquired = false + // Try a non-blocking lock first so that we can inform the user about an already running SwiftPM. do { try workspaceLock.lock(type: .exclusive, blocking: false) + lockAcquired = true } catch ProcessLockError.unableToAquireLock(let errno) { if errno == EWOULDBLOCK { + let existingPID = self.pidManipulator.readPID() + let pidInfo = existingPID.map { "(PID: \($0)) " } ?? "" if self.options.locations.ignoreLock { self.outputStream .write( - "Another instance of SwiftPM is already running using '\(self.scratchDirectory)', but this will be ignored since `--ignore-lock` has been passed" + "Another instance of SwiftPM (pid \(pidInfo) is already running using '\(self.scratchDirectory)', but this will be ignored since `--ignore-lock` has been passed" .utf8 ) self.outputStream.flush() } else { self.outputStream .write( - "Another instance of SwiftPM is already running using '\(self.scratchDirectory)', waiting until that process has finished execution..." + "Another instance of SwiftPM (pid \(pidInfo) is already running using '\(self.scratchDirectory)', waiting until that process has finished execution..." .utf8 ) self.outputStream.flush() // Only if we fail because there's an existing lock we need to acquire again as blocking. try workspaceLock.lock(type: .exclusive, blocking: true) + lockAcquired = true } } } self.workspaceLock = workspaceLock + + if lockAcquired || self.options.locations.ignoreLock { + try self.pidManipulator.writePID(pid: self.pidManipulator.getCurrentPID()) + } } fileprivate func releaseLockIfNeeded() { @@ -1094,6 +1109,12 @@ public final class SwiftCommandState { self.workspaceLockState = .unlocked self.workspaceLock?.unlock() + + do { + try self.pidManipulator.deletePIDFile() + } catch { + self.observabilityScope.emit(warning: "Failed to delete PID file: \(error)") + } } }