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