fix(gateway): distinguish reachable gateway from failed status probe (#98183)

* fix(gateway): distinguish reachable gateway from failed status probe

* fix(status): gate owns-port RPC recovery guidance on no stale gateway PIDs

inspectGatewayRestart can set health.healthy from bare reachability after
ownership attribution failed, while still returning a non-empty
staleGatewayPids. Printing owns-port recovery guidance in that case
contradicted the dedicated stale-PID diagnostic below it. Require
staleGatewayPids to be empty before treating healthy as owns-port proof.
This commit is contained in:
Masato Hoshino
2026-07-01 17:04:31 -07:00
committed by GitHub
parent dbf837f574
commit 8e28e88387
2 changed files with 182 additions and 3 deletions
+158
View File
@@ -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");
});
});
+24 -3
View File
@@ -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);