fix(gateway): skip restart when config.patch has no actual changes (#58502)

config.patch unconditionally writes the config file and sends SIGUSR1
even when diffConfigPaths detects zero changed paths. This causes a
full gateway restart (~10s downtime, all SSE/WebSocket connections
dropped) on every control-plane config.patch call, even when the
config is identical — e.g. a model hot-apply that doesn't change any
gateway.* paths.

Fix: when changedPaths is empty, return early with `noop: true`
without writing the file or scheduling SIGUSR1. The validated config
is still returned so the caller knows the current state.

This lets control-plane clients safely call config.patch for
idempotent updates without triggering unnecessary restarts.
This commit is contained in:
Jalen
2026-03-31 18:09:23 -07:00
committed by GitHub
parent ee42e44d88
commit 915e15c13d
2 changed files with 46 additions and 0 deletions

View File

@@ -476,6 +476,28 @@ export const configHandlers: GatewayRequestHandlers = {
}
const changedPaths = diffConfigPaths(snapshot.config, validated.config);
const actor = resolveControlPlaneActor(client);
// No-op: if the validated config is identical to the current config,
// skip the file write and SIGUSR1 restart entirely. This avoids a full
// gateway restart (and the resulting connection drop) when a control-plane
// client re-sends the same config (e.g. hot-apply with no actual changes).
if (changedPaths.length === 0) {
context?.logGateway?.info(
`config.patch noop ${formatControlPlaneActor(actor)} (no changed paths)`,
);
respond(
true,
{
ok: true,
noop: true,
path: createConfigIO().configPath,
config: redactConfigObject(validated.config, schemaPatch.uiHints),
},
undefined,
);
return;
}
context?.logGateway?.info(
`config.patch write ${formatControlPlaneActor(actor)} changedPaths=${summarizeChangedPaths(changedPaths)} restartReason=config.patch`,
);

View File

@@ -252,6 +252,30 @@ describe("gateway config methods", () => {
expect(res.error?.message).toBe("config schema path not found");
});
it("returns noop for config.patch when config is unchanged", async () => {
const current = await rpcReq<{
config?: Record<string, unknown>;
hash?: string;
}>(requireWs(), "config.get", {});
expect(current.ok).toBe(true);
// Patch with the same config — no actual changes
const res = await rpcReq<{
ok?: boolean;
noop?: boolean;
config?: Record<string, unknown>;
}>(requireWs(), "config.patch", {
raw: JSON.stringify(current.payload?.config ?? {}),
baseHash: current.payload?.hash,
});
expect(res.ok).toBe(true);
expect(res.payload?.noop).toBe(true);
// Config hash should not change (no file write)
const after = await rpcReq<{ hash?: string }>(requireWs(), "config.get", {});
expect(after.payload?.hash).toBe(current.payload?.hash);
});
it("rejects config.patch when raw is null", async () => {
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", {
raw: "null",