diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index cad4b41efad..c0c3632ea0e 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -866,4 +866,162 @@ describe("printDaemonStatus", () => { expect(errors).toContain("Service is loaded but not running (likely exited immediately)."); expect(errors).not.toContain("systemd stopped restarting the gateway"); }); + + it("steers a failed RPC probe to credentials/config when the gateway process owns the port", () => { + printDaemonStatus( + { + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + runtime: { status: "running", pid: 8000 }, + }, + gateway: { + bindMode: "loopback", + bindHost: "127.0.0.1", + port: 18789, + portSource: "env/config", + probeUrl: "ws://127.0.0.1:18789", + }, + rpc: { + ok: false, + error: "gateway closed (1008 policy violation: invalid token)", + url: "ws://127.0.0.1:18789", + }, + health: { + healthy: true, + staleGatewayPids: [], + }, + extraServices: [], + }, + { json: false }, + ); + + expectMockLineContains( + runtime.log, + "Gateway process is running and owns the gateway port, so this is not a warm-up delay", + ); + expectMockLineContains(runtime.log, "Check the probe credentials/config"); + const logged = runtime.log.mock.calls.map(([line]) => line).join("\n"); + expect(logged).not.toContain("Warm-up: launch agents"); + }); + + it("keeps the warm-up hint (not owns-port guidance) when healthy is reachability-only and a stale gateway PID is still held", () => { + // inspectGatewayRestart can set healthy from reachability after ownership failed, + // while still returning non-empty staleGatewayPids. That must not be treated as + // owns-port proof, or this message would contradict the stale-PID diagnostic below. + printDaemonStatus( + { + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + runtime: { status: "running", pid: 8000 }, + }, + gateway: { + bindMode: "loopback", + bindHost: "127.0.0.1", + port: 18789, + portSource: "env/config", + probeUrl: "ws://127.0.0.1:18789", + }, + rpc: { + ok: false, + error: "gateway closed (1008 policy violation: invalid token)", + url: "ws://127.0.0.1:18789", + }, + health: { + healthy: true, + staleGatewayPids: [9000], + }, + extraServices: [], + }, + { json: false }, + ); + + const logged = runtime.log.mock.calls.map(([line]) => line).join("\n"); + expect(logged).toContain("Warm-up: launch agents can take a few seconds"); + expect(logged).not.toContain("Gateway process is running and owns the gateway port"); + const errors = runtime.error.mock.calls.map(([line]) => line).join("\n"); + expect(errors).toContain("Gateway runtime PID does not own the listening port"); + }); + + it("keeps the warm-up hint for an unhealthy gateway, even with the port held", () => { + printDaemonStatus( + { + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + runtime: { status: "running", pid: 8000 }, + }, + gateway: { + bindMode: "loopback", + bindHost: "127.0.0.1", + port: 18789, + portSource: "env/config", + probeUrl: "ws://127.0.0.1:18789", + }, + rpc: { + ok: false, + error: "gateway closed (1006 abnormal closure (no close frame))", + url: "ws://127.0.0.1:18789", + }, + port: { + port: 18789, + status: "busy", + listeners: [], + hints: [], + }, + health: { + healthy: false, + staleGatewayPids: [], + }, + extraServices: [], + }, + { json: false }, + ); + + // health.healthy === false is ambiguous (not-yet-bound / foreign port conflict / stale + // PID), so it keeps the warm-up hint rather than steering to a restart; the dedicated + // stale-PID / port-not-listening / port-conflict blocks own those cases. + expectMockLineContains(runtime.log, "Warm-up: launch agents can take a few seconds"); + const logged = runtime.log.mock.calls.map(([line]) => line).join("\n"); + expect(logged).not.toContain("Gateway process is"); + }); + + it("keeps the warm-up hint when gateway health is unknown", () => { + printDaemonStatus( + { + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + runtime: { status: "running", pid: 8000 }, + }, + gateway: { + bindMode: "loopback", + bindHost: "127.0.0.1", + port: 18789, + portSource: "env/config", + probeUrl: "ws://127.0.0.1:18789", + }, + rpc: { + ok: false, + error: "gateway closed (1006 abnormal closure (no close frame))", + url: "ws://127.0.0.1:18789", + }, + extraServices: [], + }, + { json: false }, + ); + + expectMockLineContains(runtime.log, "Warm-up: launch agents can take a few seconds"); + const logged = runtime.log.mock.calls.map(([line]) => line).join("\n"); + expect(logged).not.toContain("Gateway process is"); + }); }); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 4e6b63add03..237b05e285b 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -264,9 +264,30 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean; d } if (rpc && !rpc.ok && service.loaded && service.runtime?.status === "running") { - defaultRuntime.log( - warnText("Warm-up: launch agents can take a few seconds. Try again shortly."), - ); + // The RPC probe failed while the service is loaded and running. Only the case where + // the gateway process is up and owns the listening port (health.healthy === true with + // no stale gateway PIDs, deep status only) is an unambiguous "not warm-up" signal, so it + // gets recovery guidance. `healthy` can also be set from bare reachability after + // ownership failed (see restart-health.ts), which can coexist with a non-empty + // staleGatewayPids; treat that combination as ambiguous rather than owns-port so it + // doesn't contradict the dedicated stale-PID diagnostic below. Every other + // health.healthy === false sub-case — a just-started gateway that has not bound the port + // yet, a foreign process holding the port, or a stale gateway PID — is either a normal + // warm-up window or is already covered by the dedicated stale-PID / port-not-listening / + // port-conflict diagnostics below, so it keeps the warm-up hint (as does unknown health + // from shallow status). A wedged gateway that owns the port is reported as healthy === + // true with no stale gateway PIDs, so it is steered by the first branch. + if (status.health?.healthy === true && status.health.staleGatewayPids.length === 0) { + defaultRuntime.log( + warnText( + "Gateway process is running and owns the gateway port, so this is not a warm-up delay. Check the probe credentials/config, or restart the gateway and inspect its logs if it stays unresponsive.", + ), + ); + } else { + defaultRuntime.log( + warnText("Warm-up: launch agents can take a few seconds. Try again shortly."), + ); + } } if (rpc) { const probeLabel = formatProbeKindLabel(rpc.kind);