diff --git a/Sources/Basics/Concurrency/PID.swift b/Sources/Basics/Concurrency/PID.swift new file mode 100644 index 00000000000..38ab613cfd0 --- /dev/null +++ b/Sources/Basics/Concurrency/PID.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +public protocol PIDFileHandler { + var scratchDirectory: AbsolutePath { get set } + + init(scratchDirectory: AbsolutePath) + + func readPID() throws -> Int32 + func deletePIDFile() throws + func writePID(pid: Int32) throws + func getCurrentPID() -> Int32 +} + +public struct PIDFile: PIDFileHandler { + 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 lockFilePath: AbsolutePath { + self.scratchDirectory.appending(component: "PackageManager.lock.pid") + } + + /// Read the pid file + public func readPID() throws -> Int32 { + // Check if the file exists + let filePath = self.lockFilePath.pathString + guard FileManager.default.fileExists(atPath: filePath) else { + throw PIDError.noSuchPiDFile + } + + let pidString = try String(contentsOf: lockFilePath.asURL, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + + // Try to convert to Int32, or throw an error + guard let pid = Int32(pidString) else { + throw PIDError.invalidPIDFormat + } + + return pid + } + + /// Get the current PID of the proces + public func getCurrentPID() -> Int32 { + ProcessInfo.processInfo.processIdentifier + } + + /// Write .pid file containing PID of process currently using .build directory + public func writePID(pid: Int32) throws { + let parent = self.lockFilePath.parentDirectory + try FileManager.default.createDirectory( + at: parent.asURL, + withIntermediateDirectories: true, + attributes: nil + ) + + try "\(pid)".write(to: self.lockFilePath.asURL, atomically: true, encoding: .utf8) + } + + /// Delete PID file at URL + public func deletePIDFile() throws { + do { + try FileManager.default.removeItem(at: self.lockFilePath.asURL) + } catch { + throw PIDError.noSuchPiDFile + } + } + + public enum PIDError: Error { + case invalidPIDFormat + case noSuchPiDFile + } +} diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index 386ba5381ca..d8f46cc5584 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -287,6 +287,8 @@ public final class SwiftCommandState { private let hostTriple: Basics.Triple? + private let pidManipulator: PIDFileHandler + 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: PIDFileHandler? = nil ) throws { self.hostTriple = hostTriple self.fileSystem = fileSystem @@ -408,6 +411,8 @@ public final class SwiftCommandState { 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) } } @@ -1059,16 +1064,24 @@ public final class SwiftCommandState { let workspaceLock = try FileLock.prepareLock(fileToLock: self.scratchDirectory) let lockFile = self.scratchDirectory.appending(".lock").pathString + 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) - let pid = ProcessInfo.processInfo.processIdentifier - try? String(pid).write(toFile: lockFile, atomically: true, encoding: .utf8) + lockAcquired = true } catch ProcessLockError.unableToAquireLock(let errno) { if errno == EWOULDBLOCK { - let lockingPID = try? String(contentsOfFile: lockFile, encoding: .utf8) - let pidInfo = lockingPID.map { "(PID: \($0)) " } ?? "" - + var existingProcessPID: Int32? = nil + + // Attempt to read the PID + do { + existingProcessPID = try self.pidManipulator.readPID() + } catch { + self.observabilityScope.emit(debug: "Cannot read PID file: \(error)") + } + + let pidInfo = existingProcessPID.map { "(PID: \($0)) " } ?? "" if self.options.locations.ignoreLock { self.outputStream .write( @@ -1087,13 +1100,20 @@ public final class SwiftCommandState { // Only if we fail because there's an existing lock we need to acquire again as blocking. try workspaceLock.lock(type: .exclusive, blocking: true) - let pid = ProcessInfo.processInfo.processIdentifier - try? String(pid).write(toFile: lockFile, atomically: true, encoding: .utf8) + lockAcquired = true } } } self.workspaceLock = workspaceLock + + if lockAcquired || self.options.locations.ignoreLock { + do { + try self.pidManipulator.writePID(pid: self.pidManipulator.getCurrentPID()) + } catch { + self.observabilityScope.emit(debug: "Failed to write to PID file: \(error)") + } + } } fileprivate func releaseLockIfNeeded() { @@ -1105,6 +1125,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)") + } } } diff --git a/Tests/BasicsTests/Concurrency/PIDTests.swift b/Tests/BasicsTests/Concurrency/PIDTests.swift new file mode 100644 index 00000000000..4c4aaded27d --- /dev/null +++ b/Tests/BasicsTests/Concurrency/PIDTests.swift @@ -0,0 +1,168 @@ +// +// PIDTests.swift +// SwiftPM +// +// Created by John Bute on 2025-05-14. +// + +import Basics +import Foundation +import Testing + +struct PIDTests { + @Test + func testWritePIDMultipleCalls() throws { + try withTemporaryDirectory { tmpDir in + let scratchPath = tmpDir.appending(component: "scratch") + try localFileSystem.createDirectory(scratchPath) + + let pidHandler = PIDFile(scratchDirectory: scratchPath) + + let pid1: Int32 = 1234 + let pid2: Int32 = 5678 + let pid3: Int32 = 9012 + + try pidHandler.writePID(pid: pid1) + try pidHandler.writePID(pid: pid2) + try pidHandler.writePID(pid: pid3) + + #expect(throws: Never.self) { + try pidHandler.readPID() + } + + let pid = try pidHandler.readPID() + + #expect(pid == pid3) + } + } + + @Test + func testDeleteExistingPIDFile() async throws { + try withTemporaryDirectory { tmpDir in + let scratchPath = tmpDir.appending(component: "scratch") + try localFileSystem.createDirectory(scratchPath) + + let pidHandler = PIDFile(scratchDirectory: scratchPath) + let currentPID = pidHandler.getCurrentPID() + try pidHandler.writePID(pid: currentPID) + + #expect(throws: Never.self) { + try pidHandler.deletePIDFile() + } + } + } + + @Test + func testDeleteNonExistingPIDFile() async throws { + try withTemporaryDirectory { tmpDir in + let filePath = tmpDir.appending(component: "scratch") + + let handler = PIDFile(scratchDirectory: filePath) + + #expect(throws: PIDFile.PIDError.noSuchPiDFile) { + try handler.deletePIDFile() + } + } + } + + @Test + func testFileDoesNotExist() throws { + // Create a temporary directory + try withTemporaryDirectory { tmpDir in + let filePath = tmpDir.appending(component: "scratch") + let handler = PIDFile(scratchDirectory: filePath) + + #expect(throws: PIDFile.PIDError.noSuchPiDFile) { + try handler.readPID() + } + } + } + + @Test + func testInvalidPIDFormat() async throws { + // Create a temporary directory + try withTemporaryDirectory { tmpDir in + let scratchPath = tmpDir.appending(component: "scratch") + try localFileSystem.createDirectory(scratchPath) + + let pidFilePath = scratchPath.appending(component: "PackageManager.lock.pid") + let handler = PIDFile(scratchDirectory: scratchPath) + + // Write invalid content (non-numeric PID) + let invalidPIDContent = "invalidPID" + try localFileSystem.writeFileContents(pidFilePath, bytes: .init(encodingAsUTF8: invalidPIDContent)) + + #expect(throws: PIDFile.PIDError.invalidPIDFormat) { + try handler.readPID() + } + } + } + + // Test case to check if the function works when a valid PID is in the file + @Test + func testValidPIDFormat() throws { + // Create a temporary directory + try withTemporaryDirectory { tmpDir in + let scratchPath = tmpDir.appending(component: "scratch") + try localFileSystem.createDirectory(scratchPath) + + let pidFilePath = scratchPath.appending(component: "PackageManager.lock.pid") + + let handler = PIDFile(scratchDirectory: scratchPath) + + // Write a valid PID content + let validPIDContent = "12345" + try localFileSystem.writeFileContents(pidFilePath, bytes: .init(encodingAsUTF8: validPIDContent)) + + let pid = try handler.readPID() + #expect(pid == 12345) + } + } + + @Test + func testPIDFileHandlerLifecycle() throws { + try withTemporaryDirectory { tmpDir in + let scratchPath = tmpDir.appending(component: "scratch") + try localFileSystem.createDirectory(scratchPath) + + let pidHandler = PIDFile(scratchDirectory: scratchPath) + + // Ensure no PID file exists initially + #expect(throws: PIDFile.PIDError.noSuchPiDFile) { + try pidHandler.readPID() + } + + // Write current PID + let currentPID = pidHandler.getCurrentPID() + try pidHandler.writePID(pid: currentPID) + + // Read PID back + let readPID = try pidHandler.readPID() + #expect(readPID == currentPID, "PID read should match written PID") + + // Delete the file + try pidHandler.deletePIDFile() + + // Ensure file is gone + #expect(throws: PIDFile.PIDError.noSuchPiDFile) { + try pidHandler.readPID() + } + } + } + + @Test + func testMalformedPIDFile() throws { + try withTemporaryDirectory { tmpDir in + let scratchPath = tmpDir.appending(component: "scratch") + try localFileSystem.createDirectory(scratchPath) + + let pidPath = scratchPath.appending(component: "PackageManager.lock.pid") + try localFileSystem.writeFileContents(pidPath, bytes: "notanumber") + + let pidHandler = PIDFile(scratchDirectory: scratchPath) + #expect(throws: PIDFile.PIDError.invalidPIDFormat) { + try pidHandler.readPID() + } + } + } +}