fix(gateway): reserve health probes before route stages

This commit is contained in:
Peter Steinberger
2026-04-26 06:06:51 +01:00
parent f9146cabfc
commit 15ea0e1f83
4 changed files with 58 additions and 23 deletions

View File

@@ -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

View File

@@ -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<boolean> => 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,

View File

@@ -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;
}

View File

@@ -49,14 +49,14 @@ function createHealthzPluginHandler() {
});
}
async function expectHealthzPluginShadow(params: {
async function expectHealthzProbeReserved(params: {
server: Parameters<typeof sendRequest>[0];
handlePluginRequest: ReturnType<typeof createHealthzPluginHandler>;
}) {
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 });
},
});
});