Files
nexu-io__open-design/apps/web/tests/sidecar-proxy-keepalive.test.ts
lefarcen ad4f3609ca fix(collab): quell the packaged-client cloud 502 storm (heartbeat backoff, upstream error relay, proxy replay) (#6521)
* test(collab): red specs for the packaged-client presence/proxy 502 storm

Encode the three client-side halves of the 502 storm as failing specs on
main before any fix:

- web: a persistently failing presence upstream is heartbeated every 10s
  forever (62 POSTs in 10 minutes) with no backoff.
- daemon: every vela CLI presence failure is flattened into a 502 whose
  message embeds the spawned command line; upstream 403/404 rejections are
  indistinguishable from infra outages, every beat re-spawns the CLI, and
  presence spawns carry no timeout.
- web sidecar: an idempotent /api request that dies with a connection reset
  on a reused pooled daemon socket surfaces as a synthesized 502 instead of
  being replayed once on a fresh connection.

createDaemonProxyHandler is exported as the proxy spec's seam.

* fix(collab): stop the packaged-client presence/proxy 502 storm

Three client-side halves of one incident (hours of ~2050 DevTools errors:
POST /presence/heartbeat and PUT /workspace/billing/interests 502ing every
few seconds while the collab upstream rejected one project):

- web CollabClient: presence heartbeats move from a fixed 10s interval to a
  self-scheduling chain with exponential backoff on consecutive failures
  (10s -> 20s -> 40s -> ... capped at 5min, +/-10% jitter). One success or a
  new presence identity restores the base cadence, so recovery is untouched.

- daemon presence routes: vela CLI failures are classified via the CLI's
  'API request failed with status NNN' output. Upstream 401/403 relay as
  403, 404 as 404, timeouts as 503; only genuine infrastructure failures
  keep 502. Response bodies no longer embed the spawned command line or
  stderr (daemon log keeps the full detail). Repeated consecutive failures
  for one project open a 20s negative cache that answers without spawning
  another CLI process, and presence spawns now carry a 10s timeoutMs so a
  wedged CLI cannot pile up children.

- web sidecar proxy: proxied requests use an explicit keep-alive agent whose
  pooled idle sockets die well before the daemon's 120s keep-alive window,
  and an idempotent request with a small fully-buffered body that fails with
  ECONNRESET/EPIPE on a reused pooled socket before any response bytes is
  replayed once on a fresh connection instead of surfacing a synthesized 502
  the daemon never sent. Non-idempotent methods and large bodies keep the
  streaming path and are never replayed.
2026-08-06 18:47:07 +08:00

168 lines
6.1 KiB
TypeScript

// Red spec for the packaged-client 502 storm (proxy half).
//
// The web sidecar proxies every /api request to the daemon over pooled
// keep-alive sockets. When the daemon side of one of those sockets goes away
// between requests — its 120s keep-alive window closing while the pooled
// socket is being picked up, a daemon restart, any server-side close racing
// the proxy's write — the proxied request dies with ECONNRESET and the proxy
// synthesizes a 502 the daemon never sent. In the packaged client this
// surfaced as recurring `PUT /api/workspace/billing/interests/:clientId` 502s
// even though that daemon route has no 502 path at all.
//
// The spec: an idempotent request with a small replayable body that fails
// with a connection reset on a REUSED pooled socket, before any response
// bytes arrived, must be retried once on a fresh connection instead of
// surfacing a synthesized 502. Non-idempotent methods must never be replayed.
import { createServer as createNetServer, type Server as NetServer, type Socket } from 'node:net';
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest';
import { createDaemonProxyHandler } from '../sidecar/server';
/**
* A daemon stand-in that serves exactly one request per kept-alive socket on
* its first connection, then hard-closes that socket as soon as the next
* request has been fully written into it — the deterministic equivalent of
* the daemon closing a pooled connection the proxy just reused. Every later
* connection behaves normally, so a single fresh-socket retry succeeds.
*/
function startFlakyDaemon(): Promise<{
port: number;
requestLog: string[];
close: () => Promise<void>;
}> {
const requestLog: string[] = [];
let connectionIndex = 0;
const sockets = new Set<Socket>();
const server: NetServer = createNetServer((socket) => {
connectionIndex += 1;
const connection = connectionIndex;
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
let pending = Buffer.alloc(0);
let served = 0;
socket.on('data', (chunk) => {
pending = Buffer.concat([pending, chunk]);
for (;;) {
const headerEnd = pending.indexOf('\r\n\r\n');
if (headerEnd === -1) return;
const header = pending.subarray(0, headerEnd).toString('utf8');
const contentLength = Number(
/content-length:\s*(\d+)/i.exec(header)?.[1] ?? '0',
);
const total = headerEnd + 4 + contentLength;
if (pending.length < total) return;
pending = pending.subarray(total);
served += 1;
const requestLine = header.split('\r\n')[0] ?? '';
requestLog.push(`conn${connection} ${requestLine}`);
if (connection === 1 && served > 1) {
// The daemon closed this kept-alive socket right after the proxy
// reused it: the request was fully written, no response will come.
socket.destroy();
return;
}
const body = JSON.stringify({ ok: true, connection, served });
socket.write(
'HTTP/1.1 200 OK\r\n'
+ 'content-type: application/json\r\n'
+ `content-length: ${Buffer.byteLength(body)}\r\n`
+ 'connection: keep-alive\r\n'
+ '\r\n'
+ body,
);
}
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
port: (server.address() as AddressInfo).port,
requestLog,
close: async () => {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolveClose) => {
server.close(() => resolveClose());
});
},
});
});
});
}
const cleanups: (() => Promise<void>)[] = [];
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!();
});
async function startProxy(daemonPort: number): Promise<number> {
const proxy: HttpServer = createHttpServer(
createDaemonProxyHandler(`http://127.0.0.1:${daemonPort}`, async (_request, response) => {
response.statusCode = 404;
response.end('fallback');
}),
);
await new Promise<void>((resolve) => {
proxy.listen(0, '127.0.0.1', () => resolve());
});
cleanups.push(async () => {
await new Promise<void>((resolve) => {
proxy.close(() => resolve());
});
proxy.closeAllConnections();
});
return (proxy.address() as AddressInfo).port;
}
describe('sidecar daemon proxy keep-alive resilience', () => {
it('replays an idempotent request once when the daemon resets a reused pooled socket', async () => {
const daemon = await startFlakyDaemon();
cleanups.push(daemon.close);
const proxyPort = await startProxy(daemon.port);
const url = `http://127.0.0.1:${proxyPort}/api/workspace/billing/interests/c1`;
const init = {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ generation: '1', interests: [] }),
} as const;
const first = await fetch(url, init);
expect(first.status).toBe(200);
// Give the proxy time to release the daemon socket back into its pool.
await new Promise((resolve) => setTimeout(resolve, 100));
const second = await fetch(url, init);
expect(second.status).toBe(200);
expect(daemon.requestLog.some((line) => line.startsWith('conn2 '))).toBe(true);
});
it('never replays a non-idempotent request', async () => {
const daemon = await startFlakyDaemon();
cleanups.push(daemon.close);
const proxyPort = await startProxy(daemon.port);
const url = `http://127.0.0.1:${proxyPort}/api/projects/p1/presence/heartbeat`;
const init = {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ memberId: 'm1' }),
} as const;
const first = await fetch(url, init);
expect(first.status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 100));
const second = await fetch(url, init);
expect(second.status).toBe(502);
expect(daemon.requestLog.some((line) => line.startsWith('conn2 '))).toBe(false);
});
});