fix(macos): report named-profile startup failures without a listener (#156441)

A named profile's readiness failure required port-ownership proof, which always fails without a listener PID, so the real startup error was replaced by a phantom port conflict. Check ownership only when something is listening; ready results keep strict ownership proof.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Peter Steinberger 2026-09-23 06:41:18 -07:00 • committed by GitHub
parent c1ead4b71e
commit 31dd34ff19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 59 additions and 1 deletions

View file

@ -1220,7 +1220,14 @@ extension GatewayProcessManager {
case let .failed(terminalFailure):
let instance = await PortGuardian.shared.describe(port: context.port)
guard await self.canPublishGatewayReadiness(instance: instance, context: context) else {
// Ownership only matters when something is listening. With no listener, a named
// profile's startup failure is its own; reporting a port conflict would hide it.
let publishable = if instance == nil {
self.isCurrentGatewayReadiness(context)
} else {
await self.canPublishGatewayReadiness(instance: instance, context: context)
}
guard publishable else {
return false
}
let retainedFailure: LaunchAgentReadinessFailure? = switch terminalFailure {

View file

@ -81,6 +81,57 @@ struct AppStateIsolationTests {
}
}
@Test
func `named profile startup failure without a listener keeps its own reason`() async throws {
try #require(AppProfile.current.isActive)
let configPath = TestIsolation.tempConfigPath()
try Data(#"{"gateway":{"mode":"local"}}"#.utf8).write(to: URL(fileURLWithPath: configPath))
defer { try? FileManager.default.removeItem(atPath: configPath) }
let inspectionError = "launchctl inspection failed"
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath, "OPENCLAW_GATEWAY_PORT": nil],
defaults: [connectionModeKey: "local"])
{
let state = AppStateStore.shared
let previousMode = state.connectionMode
state.connectionMode = .local
let manager = GatewayProcessManager()
let connection = GatewayConnection(testEndpointProvider: { throw CancellationError() })
manager.setTestingConnection(connection)
manager.setTestingSkipControlChannelRefresh(true)
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(true)
GatewayLaunchAgentManager.setTestingDaemonStatusPayload(
#"{"ok":false,"error":"\#(inspectionError)"}"#)
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
let port = GatewayEnvironment.gatewayPort()
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
defer {
manager.setTestingDesiredActive(false)
state.connectionMode = previousMode
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false)
GatewayLaunchAgentManager.setTestingDaemonStatusPayload(nil)
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
}
// An inconclusive inspection defers installation, so readiness fails with nothing
// listening. No other process owns the port; the operator must see the real cause.
manager.setActive(true)
await manager.waitForStartupAttempt()
guard case let .failed(reason) = manager.status else {
Issue.record("expected a terminal startup failure")
return
}
#expect(reason.contains(inspectionError))
#expect(!reason.contains("already owned by another process"))
#expect(manager.lastFailureReason == reason)
#expect(!GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().contains { $0.first == "install" })
manager.setTestingDesiredActive(false)
await connection.shutdown()
await GatewayEndpointStore.shared.setLocalUnavailableReason(nil)
}
}
@Test
func `named profile hosting repair requires restart before activation`() async throws {
try #require(AppProfile.current.isActive)