1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-03-27 14:30:08 +01:00

♻️ Ensure services status is known at launch

- Added shell pipe timeout (w/ new tests)
- Reworked ServicesManager to be blocking for startup (10s)
- Renamed queues to be more consistent
This commit is contained in:
2026-01-30 13:26:04 +01:00
parent 4fc4737434
commit 2fe58bcb5b
10 changed files with 145 additions and 60 deletions

View File

@@ -3994,7 +3994,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1845; CURRENT_PROJECT_VERSION = 1850;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG = YES; DEBUG = YES;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -4013,7 +4013,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.5; MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 26.01; MARKETING_VERSION = 26.02;
PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon;
PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_MODULE_NAME = PHP_Monitor;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -4038,7 +4038,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1845; CURRENT_PROJECT_VERSION = 1850;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG = NO; DEBUG = NO;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -4057,7 +4057,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.5; MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 26.01; MARKETING_VERSION = 26.02;
PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon;
PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_MODULE_NAME = PHP_Monitor;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -4220,7 +4220,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1845; CURRENT_PROJECT_VERSION = 1850;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG = YES; DEBUG = YES;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -4239,7 +4239,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.5; MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 26.01; MARKETING_VERSION = 26.02;
PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap;
PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_MODULE_NAME = PHP_Monitor;
PRODUCT_NAME = "$(TARGET_NAME) EAP"; PRODUCT_NAME = "$(TARGET_NAME) EAP";
@@ -4413,7 +4413,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1845; CURRENT_PROJECT_VERSION = 1850;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG = NO; DEBUG = NO;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -4432,7 +4432,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.5; MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 26.01; MARKETING_VERSION = 26.02;
PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon.eap;
PRODUCT_MODULE_NAME = PHP_Monitor; PRODUCT_MODULE_NAME = PHP_Monitor;
PRODUCT_NAME = "$(TARGET_NAME) EAP"; PRODUCT_NAME = "$(TARGET_NAME) EAP";

View File

@@ -111,6 +111,36 @@ class RealShell: ShellProtocol, @unchecked Sendable {
return result 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 // MARK: - Public API
/** /**
@@ -190,31 +220,69 @@ class RealShell: ShellProtocol, @unchecked Sendable {
} }
} }
private func log(process: Process, stdOut: String, stdErr: String) { func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput {
var args = process.arguments ?? [] let process = getShellProcess(for: command)
let last = "\"" + (args.popLast() ?? "") + "\""
var log = """
<~~~~~~~~~~~~~~~~~~~~~~~ let outputPipe = Pipe()
$ \(([self.launchPath] + args + [last]).joined(separator: " ")) let errorPipe = Pipe()
[OUT]: if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
\(stdOut) Log.info("[SLOW SHELL] \(command)")
""" await delay(seconds: 3.0)
if !stdErr.isEmpty {
log.append("""
[ERR]:
\(stdErr)
""")
} }
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 { func quiet(_ command: String) async {
@@ -235,7 +303,7 @@ class RealShell: ShellProtocol, @unchecked Sendable {
let output = ShellOutput.empty() let output = ShellOutput.empty()
// Only access `resumed`, `output` from serialQueue to ensure thread safety // 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 return try await withCheckedThrowingContinuation({ continuation in
// Guard against resuming the continuation twice (race between timeout and termination) // Guard against resuming the continuation twice (race between timeout and termination)

View File

@@ -35,6 +35,16 @@ protocol ShellProtocol {
*/ */
func pipe(_ command: String) async -> ShellOutput 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. Run a command asynchronously, without returning the output of the command.
Returns the most relevant output (prefers error output if it exists). Returns the most relevant output (prefers error output if it exists).

View File

@@ -35,7 +35,11 @@ public class TestableShell: ShellProtocol {
} }
func pipe(_ command: String) async -> ShellOutput { 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 return output
} }

View File

@@ -19,8 +19,7 @@ class FakeServicesManager: ServicesManager {
init( init(
_ container: Container, _ container: Container,
formulae: [String] = ["php", "nginx", "dnsmasq"], formulae: [String] = ["php", "nginx", "dnsmasq"],
status: Service.Status = .active, status: Service.Status = .active
loading: Bool = false
) { ) {
super.init(container) super.init(container)
@@ -32,14 +31,6 @@ class FakeServicesManager: ServicesManager {
self.services = [] self.services = []
self.reapplyServices() self.reapplyServices()
if loading {
return
}
Task { @MainActor in
self.firstRunComplete = true
}
} }
private func reapplyServices() { private func reapplyServices() {

View File

@@ -17,8 +17,6 @@ class ServicesManager: ObservableObject {
@Published var services = [Service]() @Published var services = [Service]()
@Published var firstRunComplete: Bool = false
init(_ container: Container) { init(_ container: Container) {
self.container = container self.container = container
@@ -65,7 +63,7 @@ class ServicesManager: ObservableObject {
} }
public var hasError: Bool { public var hasError: Bool {
if self.services.isEmpty || !self.firstRunComplete { if self.services.isEmpty {
return false return false
} }
@@ -75,7 +73,7 @@ class ServicesManager: ObservableObject {
} }
public var statusMessage: String { public var statusMessage: String {
if self.services.isEmpty || !self.firstRunComplete { if self.services.isEmpty {
return "phpman.services.loading".localized return "phpman.services.loading".localized
} }
@@ -95,7 +93,7 @@ class ServicesManager: ObservableObject {
} }
public var statusColor: Color { public var statusColor: Color {
if self.services.isEmpty || !self.firstRunComplete { if self.services.isEmpty {
return Color("StatusColorYellow") return Color("StatusColorYellow")
} }

View File

@@ -102,7 +102,7 @@ actor ValetServicesDataManager {
? "sudo \(self.container.paths.brew) services info --all --json" ? "sudo \(self.container.paths.brew) services info --all --json"
: "\(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 { guard let jsonData = output.data(using: .utf8) else {
Log.err("Failed to convert \(elevated ? "root" : "user") services output to UTF-8 data.") Log.err("Failed to convert \(elevated ? "root" : "user") services output to UTF-8 data.")

View File

@@ -16,14 +16,6 @@ class ValetServicesManager: ServicesManager {
override init(_ container: Container) { override init(_ container: Container) {
self.data = ValetServicesDataManager(container) self.data = ValetServicesDataManager(container)
super.init(container) super.init(container)
// Load the initial services state
Task {
await self.reloadServicesStatus()
await MainActor.run {
firstRunComplete = true
}
}
} }
override func reloadServicesStatus() async { override func reloadServicesStatus() async {

View File

@@ -100,20 +100,20 @@ extension Startup {
// A non-default TLD is not officially supported since Valet 3.2.x // A non-default TLD is not officially supported since Valet 3.2.x
Valet.shared.notifyAboutUnsupportedTLD() 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 // Keep track of which PHP versions are currently about to release
Log.info("Experimental PHP versions are: \(Constants.ExperimentalPhpVersions)") Log.info("Experimental PHP versions are: \(Constants.ExperimentalPhpVersions)")
// Find out which services are active // Internals are ready!
Log.info("The services manager knows about \(ServicesManager.shared.services.count) services.")
// We are ready!
container.phpEnvs.isBusy = false container.phpEnvs.isBusy = false
// Finally!
Log.info("PHP Monitor is ready to serve!")
// Avoid showing the "startup timeout" alert // Avoid showing the "startup timeout" alert
Startup.invalidateTimeoutTimer() Startup.invalidateTimeoutTimer()
@@ -122,6 +122,7 @@ extension Startup {
// Mark app as having successfully booted passing all checks // Mark app as having successfully booted passing all checks
Startup.hasFinishedBooting = true Startup.hasFinishedBooting = true
Log.info("PHP Monitor is ready to serve!")
// Enable the main menu item // Enable the main menu item
MainMenu.shared.statusItem.button?.isEnabled = true MainMenu.shared.statusItem.button?.isEnabled = true

View File

@@ -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 { @Test func can_run_multiple_shell_commands_in_parallel() async throws {
let start = ContinuousClock.now let start = ContinuousClock.now