mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Reported by @DodgyBadger. #237: the proxy wrapped each upstream fetch in a 30s `AbortSignal.timeout`, which bounded the ENTIRE exchange rather than the wait for response headers. A dashboard endpoint doing model inference, and any actively streaming response, both died at 30s as a generic 502 that Codeman never logged, so it read as an intermittent network error. The timeout now bounds time-to-headers only and is cleared the moment headers arrive, so a slow endpoint and a long stream both survive. The default moves to 300s because "the app is thinking" is normal for the dashboards people proxy; abandoned upstreams are reclaimed by the client-hangup abort rather than by this value. A browser that navigates away mid-request now aborts the upstream fetch, guarded by `writableFinished` for the same reason as `abortOnClientHangUp` in session-routes: `close` also fires after a completed response and must not abort anything. Header timeouts are logged as a warning with a sanitized identity (method plus origin plus path, never the query string, which can carry the dashboard's tokens), and a client hangup is deliberately not warned since nobody is listening and it would read as the dashboard being broken. The WebSocket handshake keeps its own 30s budget (`CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS`), decoupled from the request timeout: a handshake is connection establishment, and waiting minutes on one only delays the browser's reconnect logic. #238: the web-tab guide covered sandboxed dashboards having no cookies, but not cookie authentication in front of Codeman itself (Cloudflare Access and similar), where a sandboxed frame's asset and API requests carry no auth cookie, bounce to the login provider, and leave the embedded app looking unstyled or broken while trusted mode works. Documented, and the Test button's result now says it probes server-to-upstream reachability only, not how the page behaves in a sandboxed frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
/**
|
|
* @fileoverview Issue #237: the webview proxy's upstream timeout bounds
|
|
* TIME-TO-HEADERS only, is logged when it fires, and never kills a response that
|
|
* is actively streaming.
|
|
*
|
|
* Uses app.inject() against the real registerWebviewRoutes with a real local
|
|
* upstream http server on an ephemeral port (inject fakes only the inbound
|
|
* request; the proxy's outbound fetch is real). Port: ephemeral (server.listen(0)).
|
|
*
|
|
* The timeout is shrunk via CODEMAN_WEBVIEW_TIMEOUT_MS inside vi.hoisted(), which
|
|
* runs before the module graph loads (webview-limits reads the env at import).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
import { createServer, type Server } from 'node:http';
|
|
|
|
const TIMEOUT_MS = vi.hoisted(() => {
|
|
process.env.CODEMAN_WEBVIEW_TIMEOUT_MS = '400';
|
|
return 400;
|
|
});
|
|
|
|
import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js';
|
|
import { webviewCapabilities } from '../src/webview-capabilities.js';
|
|
import { writeWebviews } from '../src/webview-store.js';
|
|
import { getDataDir } from '../src/config/instance.js';
|
|
import type { EventPort } from '../src/web/ports/index.js';
|
|
|
|
const WEBVIEW_ID = 'wv-timeout-test';
|
|
|
|
let upstream: Server;
|
|
let upstreamPort: number;
|
|
let app: FastifyInstance;
|
|
let capability: string;
|
|
|
|
const eventPortStub: EventPort = {
|
|
broadcast: vi.fn(),
|
|
sendPushNotifications: vi.fn(),
|
|
batchTerminalData: vi.fn(),
|
|
broadcastSessionStateDebounced: vi.fn(),
|
|
batchTaskUpdate: vi.fn(),
|
|
getSseClientCount: vi.fn(() => 0),
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
upstream = createServer((req, res) => {
|
|
if (req.url === '/fast') {
|
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
res.end('quick');
|
|
return;
|
|
}
|
|
if (req.url === '/slow-headers') {
|
|
// Headers arrive AFTER the proxy's limit: this is the #237 repro shape
|
|
// (an API endpoint thinking for longer than the timeout).
|
|
setTimeout(() => {
|
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
res.end('finally');
|
|
}, TIMEOUT_MS + 700);
|
|
return;
|
|
}
|
|
if (req.url === '/stream') {
|
|
// Headers immediately, then a body that takes ~3x the limit to finish.
|
|
// Under the old whole-fetch AbortSignal.timeout this died mid-stream.
|
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
res.write('start;');
|
|
let chunks = 0;
|
|
const timer = setInterval(() => {
|
|
chunks++;
|
|
res.write(`chunk${chunks};`);
|
|
if (chunks >= 4) {
|
|
clearInterval(timer);
|
|
res.end('done');
|
|
}
|
|
}, TIMEOUT_MS * 0.75);
|
|
return;
|
|
}
|
|
res.writeHead(404).end();
|
|
});
|
|
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
|
const address = upstream.address();
|
|
if (typeof address === 'object' && address) upstreamPort = address.port;
|
|
|
|
await writeWebviews(getDataDir(), [
|
|
{
|
|
id: WEBVIEW_ID,
|
|
name: 'timeout-under-test',
|
|
url: `http://127.0.0.1:${upstreamPort}/`,
|
|
embedMode: 'proxy',
|
|
trusted: false,
|
|
createdAt: Date.now(),
|
|
},
|
|
]);
|
|
capability = webviewCapabilities.mint(WEBVIEW_ID, undefined);
|
|
|
|
app = Fastify({ logger: false });
|
|
registerWebviewRoutes(app, eventPortStub);
|
|
await app.ready();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
webviewCapabilities.revokeWebview(WEBVIEW_ID);
|
|
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
|
});
|
|
|
|
describe('webview proxy upstream timeout (#237)', () => {
|
|
it('proxies a fast request untouched', async () => {
|
|
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/fast` });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toBe('quick');
|
|
});
|
|
|
|
it('502s with an actionable, logged message when headers never arrive in time', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/slow-headers` });
|
|
expect(res.statusCode).toBe(502);
|
|
// The body names the limit and the env var that raises it; the old message
|
|
// was an opaque "The operation was aborted due to timeout".
|
|
expect(res.body).toContain(`no response headers within ${TIMEOUT_MS}ms`);
|
|
expect(res.body).toContain('CODEMAN_WEBVIEW_TIMEOUT_MS');
|
|
// And it is logged server-side (the old path was completely silent), with
|
|
// the sanitized target and the webview's name.
|
|
const logged = warn.mock.calls.map((args) => args.join(' ')).join('\n');
|
|
expect(logged).toContain('no response headers');
|
|
expect(logged).toContain(`/slow-headers`);
|
|
expect(logged).toContain('timeout-under-test');
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('never aborts a response that is actively streaming past the timeout', async () => {
|
|
const started = Date.now();
|
|
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/stream` });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toBe('start;chunk1;chunk2;chunk3;chunk4;done');
|
|
// Sanity: the exchange really did outlive the header timeout.
|
|
expect(Date.now() - started).toBeGreaterThan(TIMEOUT_MS);
|
|
});
|
|
});
|