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:
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user