diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3f33da78a..4d41d609887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai binary when the config was last written by a newer version, preventing split-brain installs from stopping or rewriting newer gateway services. Fixes #57079. +- Gateway: reserve `/healthz` and `/readyz` ahead of plugin, canvas, and Control UI HTTP stages so liveness/readiness probes still answer when a later route handler stalls. Fixes #69674. Thanks @Xike-Creek. - Agents/groups: treat clean empty assistant stops as silent `NO_REPLY` only for always-on groups where silent replies are allowed, while keeping direct and mention-gated sessions on the incomplete-turn retry path. Thanks @MagnaAI. - macOS/Node: keep native remote app nodes from advertising `browser.proxy`, start browser-capable CLI node services through the restored diff --git a/src/gateway/server-http.probe.test.ts b/src/gateway/server-http.probe.test.ts index aced4ec3ae5..5a42f1fa54a 100644 --- a/src/gateway/server-http.probe.test.ts +++ b/src/gateway/server-http.probe.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { AUTH_TOKEN, AUTH_NONE, @@ -265,6 +265,41 @@ describe("gateway probe endpoints", () => { }); }); + it("serves probes before stalled request stages", async () => { + const handleHooksRequest = vi.fn((): Promise => new Promise(() => {})); + const getReadiness = vi.fn(() => ({ + ready: true, + failing: [], + uptimeMs: 123, + })); + + await withGatewayServer({ + prefix: "probe-before-stalled-stages", + resolvedAuth: AUTH_NONE, + overrides: { getReadiness, handleHooksRequest }, + run: async (server) => { + const healthReq = createRequest({ path: "/healthz" }); + const healthResponse = createResponse(); + await dispatchRequest(server, healthReq, healthResponse.res); + + expect(healthResponse.res.statusCode).toBe(200); + expect(healthResponse.getBody()).toBe(JSON.stringify({ ok: true, status: "live" })); + + const readyReq = createRequest({ path: "/readyz" }); + const readyResponse = createResponse(); + await dispatchRequest(server, readyReq, readyResponse.res); + + expect(readyResponse.res.statusCode).toBe(200); + expect(JSON.parse(readyResponse.getBody())).toEqual({ + ready: true, + failing: [], + uptimeMs: 123, + }); + expect(handleHooksRequest).not.toHaveBeenCalled(); + }, + }); + }); + it("reflects readiness status on HEAD /readyz without a response body", async () => { const getReadiness: ReadinessChecker = () => ({ ready: false, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index df3fdca3123..26f3c6e8edd 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -945,6 +945,19 @@ export function createGatewayHttpServer(opts: { : null; const resolvedAuth = getResolvedAuth(); const requestStages: GatewayHttpRequestStage[] = [ + { + name: "gateway-probes", + run: () => + handleGatewayProbeRequest( + req, + res, + requestPath, + resolvedAuth, + trustedProxies, + allowRealIpFallback, + getReadiness, + ), + }, { name: "hooks", run: () => handleHooksRequest(req, res), @@ -1150,20 +1163,6 @@ export function createGatewayHttpServer(opts: { }); } - requestStages.push({ - name: "gateway-probes", - run: () => - handleGatewayProbeRequest( - req, - res, - requestPath, - resolvedAuth, - trustedProxies, - allowRealIpFallback, - getReadiness, - ), - }); - if (await runGatewayHttpRequestStages(requestStages)) { return; } diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index dd00bc9f4e5..ec311d4a928 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -49,14 +49,14 @@ function createHealthzPluginHandler() { }); } -async function expectHealthzPluginShadow(params: { +async function expectHealthzProbeReserved(params: { server: Parameters[0]; handlePluginRequest: ReturnType; }) { const response = await sendRequest(params.server, { path: "/healthz" }); expect(response.res.statusCode).toBe(200); - expect(response.getBody()).toBe(JSON.stringify({ ok: true, route: "plugin-health" })); - expect(params.handlePluginRequest).toHaveBeenCalledTimes(1); + expect(response.getBody()).toBe(JSON.stringify({ ok: true, status: "live" })); + expect(params.handlePluginRequest).not.toHaveBeenCalled(); } function createMattermostCallbackConfig(callbackPath: string) { @@ -197,7 +197,7 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); - test("does not shadow plugin routes mounted on probe paths", async () => { + test("reserves gateway probe routes ahead of plugin routes", async () => { const handlePluginRequest = createHealthzPluginHandler(); await withGatewayServer({ @@ -205,7 +205,7 @@ describe("gateway plugin HTTP auth boundary", () => { resolvedAuth: AUTH_NONE, overrides: { handlePluginRequest }, run: async (server) => { - await expectHealthzPluginShadow({ server, handlePluginRequest }); + await expectHealthzProbeReserved({ server, handlePluginRequest }); }, }); }); @@ -697,19 +697,19 @@ describe("gateway plugin HTTP auth boundary", () => { handlePluginRequest, run: async (server) => { await expectProbeRoutesHealthy(server); - expect(handlePluginRequest).toHaveBeenCalledTimes(PROBE_CASES.length); + expect(handlePluginRequest).not.toHaveBeenCalled(); }, }); }); - test("root-mounted control ui still lets plugins claim probe paths first", async () => { + test("root-mounted control ui keeps gateway probe routes reserved ahead of plugins", async () => { const handlePluginRequest = createHealthzPluginHandler(); await withRootMountedControlUiServer({ prefix: "openclaw-plugin-http-control-ui-probe-shadow-test-", handlePluginRequest, run: async (server) => { - await expectHealthzPluginShadow({ server, handlePluginRequest }); + await expectHealthzProbeReserved({ server, handlePluginRequest }); }, }); });