diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index 317d27c9..73388d28 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -3994,7 +3994,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1845; + CURRENT_PROJECT_VERSION = 1850; DEAD_CODE_STRIPPING = YES; DEBUG = YES; ENABLE_APP_SANDBOX = NO; @@ -4013,7 +4013,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 26.01; + MARKETING_VERSION = 26.02; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon; PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4038,7 +4038,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1845; + CURRENT_PROJECT_VERSION = 1850; DEAD_CODE_STRIPPING = YES; DEBUG = NO; ENABLE_APP_SANDBOX = NO; @@ -4057,7 +4057,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 26.01; + MARKETING_VERSION = 26.02; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon; PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4220,7 +4220,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1845; + CURRENT_PROJECT_VERSION = 1850; DEAD_CODE_STRIPPING = YES; DEBUG = YES; ENABLE_APP_SANDBOX = NO; @@ -4239,7 +4239,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 26.01; + MARKETING_VERSION = 26.02; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap; PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_NAME = "$(TARGET_NAME) EAP"; @@ -4413,7 +4413,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1845; + CURRENT_PROJECT_VERSION = 1850; DEAD_CODE_STRIPPING = YES; DEBUG = NO; ENABLE_APP_SANDBOX = NO; @@ -4432,7 +4432,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 26.01; + MARKETING_VERSION = 26.02; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap; PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_NAME = "$(TARGET_NAME) EAP"; diff --git a/phpmon/Common/Shell/RealShell.swift b/phpmon/Common/Shell/RealShell.swift index d92a7ee8..2d164494 100644 --- a/phpmon/Common/Shell/RealShell.swift +++ b/phpmon/Common/Shell/RealShell.swift @@ -111,6 +111,36 @@ class RealShell: ShellProtocol, @unchecked Sendable { return result } + /** + Verbose logging for when executing a shell command. + */ + private func log(process: Process, stdOut: String, stdErr: String) { + var args = process.arguments ?? [] + let last = "\"" + (args.popLast() ?? "") + "\"" + var log = """ + + <~~~~~~~~~~~~~~~~~~~~~~~ + $ \(([self.launchPath] + args + [last]).joined(separator: " ")) + + [OUT]: + \(stdOut) + """ + + if !stdErr.isEmpty { + log.append(""" + [ERR]: + \(stdErr) + """) + } + + log.append(""" + ~~~~~~~~~~~~~~~~~~~~~~~~> + + """) + + Log.info(log) + } + // MARK: - Public API /** @@ -190,31 +220,69 @@ class RealShell: ShellProtocol, @unchecked Sendable { } } - private func log(process: Process, stdOut: String, stdErr: String) { - var args = process.arguments ?? [] - let last = "\"" + (args.popLast() ?? "") + "\"" - var log = """ + func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput { + let process = getShellProcess(for: command) - <~~~~~~~~~~~~~~~~~~~~~~~ - $ \(([self.launchPath] + args + [last]).joined(separator: " ")) + let outputPipe = Pipe() + let errorPipe = Pipe() - [OUT]: - \(stdOut) - """ - - if !stdErr.isEmpty { - log.append(""" - [ERR]: - \(stdErr) - """) + if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil { + Log.info("[SLOW SHELL] \(command)") + await delay(seconds: 3.0) } - log.append(""" - ~~~~~~~~~~~~~~~~~~~~~~~~> + process.standardOutput = outputPipe + process.standardError = errorPipe - """) + let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.pipe_timeout_queue") - Log.info(log) + return await withCheckedContinuation { continuation in + var resumed = false + + let timeoutWorkItem = DispatchWorkItem { + guard process.isRunning else { return } + + Log.warn("Command timed out after \(timeout)s: \(command)") + process.terminationHandler = nil + process.terminate() + + serialQueue.async { + if !resumed { + resumed = true + continuation.resume(returning: .out("", "")) + } + } + } + + serialQueue.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) + + process.terminationHandler = { [weak self] _ in + timeoutWorkItem.cancel() + + serialQueue.async { + if resumed { return } + + if process.terminationReason == .uncaughtSignal { + Log.err("The command `\(command)` likely crashed. Returning empty output.") + resumed = true + continuation.resume(returning: .out("", "")) + return + } + + let stdOut = RealShell.getStringOutput(from: outputPipe) + let stdErr = RealShell.getStringOutput(from: errorPipe) + + if Log.shared.verbosity == .cli { + self?.log(process: process, stdOut: stdOut, stdErr: stdErr) + } + + resumed = true + continuation.resume(returning: .out(stdOut, stdErr)) + } + } + + process.launch() + } } func quiet(_ command: String) async { @@ -235,7 +303,7 @@ class RealShell: ShellProtocol, @unchecked Sendable { let output = ShellOutput.empty() // Only access `resumed`, `output` from serialQueue to ensure thread safety - let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_output") + let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.attach_queue") return try await withCheckedThrowingContinuation({ continuation in // Guard against resuming the continuation twice (race between timeout and termination) diff --git a/phpmon/Common/Shell/ShellProtocol.swift b/phpmon/Common/Shell/ShellProtocol.swift index 0fa17f0b..5edd92d5 100644 --- a/phpmon/Common/Shell/ShellProtocol.swift +++ b/phpmon/Common/Shell/ShellProtocol.swift @@ -35,6 +35,16 @@ protocol ShellProtocol { */ func pipe(_ command: String) async -> ShellOutput + /** + Run a command asynchronously with a timeout. + Returns the most relevant output (prefers error output if it exists). + + - Parameter command: The command to execute. + - Parameter timeout: Timeout in seconds. If the command exceeds this, it is terminated. + - Returns: The shell output. If the command times out, returns empty output. + */ + func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput + /** Run a command asynchronously, without returning the output of the command. Returns the most relevant output (prefers error output if it exists). diff --git a/phpmon/Common/Shell/TestableShell.swift b/phpmon/Common/Shell/TestableShell.swift index cd417ee6..e66650c4 100644 --- a/phpmon/Common/Shell/TestableShell.swift +++ b/phpmon/Common/Shell/TestableShell.swift @@ -35,7 +35,11 @@ public class TestableShell: ShellProtocol { } func pipe(_ command: String) async -> ShellOutput { - let (_, output) = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60) + return await pipe(command, timeout: 60) + } + + func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput { + let (_, output) = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: timeout) return output } diff --git a/phpmon/Domain/App/Services/FakeServicesManager.swift b/phpmon/Domain/App/Services/FakeServicesManager.swift index 3b4fccc2..d1ba87a9 100644 --- a/phpmon/Domain/App/Services/FakeServicesManager.swift +++ b/phpmon/Domain/App/Services/FakeServicesManager.swift @@ -19,8 +19,7 @@ class FakeServicesManager: ServicesManager { init( _ container: Container, formulae: [String] = ["php", "nginx", "dnsmasq"], - status: Service.Status = .active, - loading: Bool = false + status: Service.Status = .active ) { super.init(container) @@ -32,14 +31,6 @@ class FakeServicesManager: ServicesManager { self.services = [] self.reapplyServices() - - if loading { - return - } - - Task { @MainActor in - self.firstRunComplete = true - } } private func reapplyServices() { diff --git a/phpmon/Domain/App/Services/ServicesManager.swift b/phpmon/Domain/App/Services/ServicesManager.swift index fe45f1f7..24814f7a 100644 --- a/phpmon/Domain/App/Services/ServicesManager.swift +++ b/phpmon/Domain/App/Services/ServicesManager.swift @@ -17,8 +17,6 @@ class ServicesManager: ObservableObject { @Published var services = [Service]() - @Published var firstRunComplete: Bool = false - init(_ container: Container) { self.container = container @@ -65,7 +63,7 @@ class ServicesManager: ObservableObject { } public var hasError: Bool { - if self.services.isEmpty || !self.firstRunComplete { + if self.services.isEmpty { return false } @@ -75,7 +73,7 @@ class ServicesManager: ObservableObject { } public var statusMessage: String { - if self.services.isEmpty || !self.firstRunComplete { + if self.services.isEmpty { return "phpman.services.loading".localized } @@ -95,7 +93,7 @@ class ServicesManager: ObservableObject { } public var statusColor: Color { - if self.services.isEmpty || !self.firstRunComplete { + if self.services.isEmpty { return Color("StatusColorYellow") } diff --git a/phpmon/Domain/App/Services/ValetServicesDataManager.swift b/phpmon/Domain/App/Services/ValetServicesDataManager.swift index c39c3852..67688aee 100644 --- a/phpmon/Domain/App/Services/ValetServicesDataManager.swift +++ b/phpmon/Domain/App/Services/ValetServicesDataManager.swift @@ -102,7 +102,7 @@ actor ValetServicesDataManager { ? "sudo \(self.container.paths.brew) services info --all --json" : "\(self.container.paths.brew) services info --all --json" - let output = await self.container.shell.pipe(command).out + let output = await self.container.shell.pipe(command, timeout: .seconds(10)).out guard let jsonData = output.data(using: .utf8) else { Log.err("Failed to convert \(elevated ? "root" : "user") services output to UTF-8 data.") diff --git a/phpmon/Domain/App/Services/ValetServicesManager.swift b/phpmon/Domain/App/Services/ValetServicesManager.swift index 44a02ccd..4db5c1a8 100644 --- a/phpmon/Domain/App/Services/ValetServicesManager.swift +++ b/phpmon/Domain/App/Services/ValetServicesManager.swift @@ -16,14 +16,6 @@ class ValetServicesManager: ServicesManager { override init(_ container: Container) { self.data = ValetServicesDataManager(container) super.init(container) - - // Load the initial services state - Task { - await self.reloadServicesStatus() - await MainActor.run { - firstRunComplete = true - } - } } override func reloadServicesStatus() async { diff --git a/phpmon/Domain/App/Startup+Launch.swift b/phpmon/Domain/App/Startup+Launch.swift index ff370d4c..7bfa4397 100644 --- a/phpmon/Domain/App/Startup+Launch.swift +++ b/phpmon/Domain/App/Startup+Launch.swift @@ -100,20 +100,20 @@ extension Startup { // A non-default TLD is not officially supported since Valet 3.2.x Valet.shared.notifyAboutUnsupportedTLD() + + // Determine which services are running + await ServicesManager.shared.reloadServicesStatus() + + // Find out which services are active + Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.") } // Keep track of which PHP versions are currently about to release Log.info("Experimental PHP versions are: \(Constants.ExperimentalPhpVersions)") - // Find out which services are active - Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.") - - // We are ready! + // Internals are ready! container.phpEnvs.isBusy = false - // Finally! - Log.info("PHP Monitor is ready to serve!") - // Avoid showing the "startup timeout" alert Startup.invalidateTimeoutTimer() @@ -122,6 +122,7 @@ extension Startup { // Mark app as having successfully booted passing all checks Startup.hasFinishedBooting = true + Log.info("PHP Monitor is ready to serve!") // Enable the main menu item MainMenu.shared.statusItem.button?.isEnabled = true diff --git a/tests/unit/Testables/Shell/RealShellTest.swift b/tests/unit/Testables/Shell/RealShellTest.swift index 2b38e950..66391457 100644 --- a/tests/unit/Testables/Shell/RealShellTest.swift +++ b/tests/unit/Testables/Shell/RealShellTest.swift @@ -64,6 +64,27 @@ struct RealShellTest { } } + @Test func pipe_can_timeout_and_return_empty_output() async { + let start = ContinuousClock.now + + let output = await container.shell.pipe("php -r \"sleep(30);\"", timeout: 0.5) + + let duration = start.duration(to: .now) + + // Should return empty output on timeout + #expect(output.out.isEmpty) + #expect(output.err.isEmpty) + + // Should have timed out in roughly 0.5 seconds (allow some margin) + #expect(duration < .seconds(2)) + } + + @Test func pipe_without_timeout_completes_normally() async { + let output = await container.shell.pipe("php -v") + + #expect(output.out.contains("Copyright (c) The PHP Group")) + } + @Test func can_run_multiple_shell_commands_in_parallel() async throws { let start = ContinuousClock.now