1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-03-27 22:40: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_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";

View File

@@ -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)

View File

@@ -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).

View File

@@ -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
}

View File

@@ -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() {

View File

@@ -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")
}

View File

@@ -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.")

View File

@@ -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 {

View File

@@ -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

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 {
let start = ContinuousClock.now