From 433b64dbe1b2b9c59984c95525b08c0c991bb581 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Fri, 9 Oct 2026 00:15:57 +0800 Subject: [PATCH] test(runtime): prove the proxied HTTP forward path end-to-end Since undici 8 (undici#1247) ProxyAgent forwards plain-HTTP targets to the proxy in absolute form instead of opening a CONNECT tunnel, and that forwarding semantics is the established contract on main: the eval fixture explicitly asserts the absolute-form request line and rejects CONNECT with 403. The reported 8-second hang (apache/maka#5805) does not reproduce on undici 8.11.2 (local scoped-fetch-transport baseline 42/42 green; recent main CI runs green), and cancellation is already covered at the connect level via withSocketCancellation and buildAbortableConnector inside the dispatcher factory. The fake HTTP proxy used by the success-path test answered every request with a fixed 200, so the proxied HTTP path passed without ever exercising a relay. Replace it with a real forwarding proxy that records the absolute-form request line and the proxy-authorization header as the bytes flow through, then relays the raw connection to a live target server over a TCP pipe; the client response can only come from that target. This also pins the credentials-ride-the-forward contract on the HTTP path. Validated the test itself by temporarily self-answering from the proxy (mimicking the old fixture): the relay assertion fails until the pipe is restored. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/scoped-fetch-transport.test.ts | 87 ++++++++++++++++++- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts index 607913f5c58..ceb2ec4be93 100644 --- a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts +++ b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts @@ -43,12 +43,18 @@ import { buildAbortableConnector } from '../abortable-connector.js'; describe('connection effect network transport', () => { for (const type of ['direct', 'http', 'socks5'] as const) { test(`closed successful connections do not accumulate abort listeners (${type})`, async () => { + let targetHits = 0; const server = createServer((_request, response) => { + targetHits += 1; response.writeHead(200, { connection: 'close' }); response.end('proxy-ok'); }); const port = await listen(server); const socks = type === 'socks5' ? await startStalledProxy('socks-http') : undefined; + // Since undici 8 (#1247), plain-HTTP targets are forwarded to the proxy + // in absolute form instead of tunneled via CONNECT. Relay the bytes to a + // real target so the success path is proven end to end. + const forward = type === 'http' ? await startForwardProxy(port) : undefined; const controller = new AbortController(); const dispatcher = type === 'direct' @@ -59,7 +65,8 @@ describe('connection effect network transport', () => { enabled: true, type, host: '127.0.0.1', - port: socks?.port ?? port, + port: socks?.port ?? forward?.port ?? port, + ...(type === 'http' ? { username: 'proxy-user', password: 'proxy-password' } : {}), bypassList: [], }, controller.signal, @@ -67,12 +74,29 @@ describe('connection effect network transport', () => { try { for (let index = 0; index < 25; index++) { const response = await undiciFetch( - type === 'direct' - ? `http://127.0.0.1:${port}/models` - : 'http://provider.invalid/models', + type === 'socks5' + ? 'http://provider.invalid/models' + : `http://127.0.0.1:${port}/models`, { dispatcher }, ); assert.equal(await response.text(), 'proxy-ok'); + if (forward) { + assert.equal( + forward.requestLines[index], + `GET http://127.0.0.1:${port}/models HTTP/1.1`, + 'plain-HTTP targets must reach the proxy as an absolute-form forward', + ); + assert.equal( + forward.requestHeaders[index]?.['proxy-authorization'], + `Basic ${Buffer.from('proxy-user:proxy-password').toString('base64')}`, + 'configured proxy credentials must ride the forward request', + ); + assert.equal( + targetHits, + index + 1, + 'the proxy must relay the forward request to the real target', + ); + } // Socket close is delivered after the body. Wait for I/O, not GC. await waitFor(() => getEventListeners(controller.signal, 'abort').length === 0, { timeoutMs: 1_000, @@ -83,6 +107,7 @@ describe('connection effect network transport', () => { controller.abort(); await dispatcher.destroy(); await socks?.close(); + await forward?.close(); await closeServer(server); } }); @@ -952,6 +977,60 @@ async function startConnectProxy( }; } +interface ForwardProxy { + readonly port: number; + readonly requestLines: string[]; + readonly requestHeaders: Record[]; + close(): Promise; +} + +// A real forwarding proxy for plain-HTTP targets: since undici 8 (#1247) the +// dispatcher sends the request in absolute form, and this helper records the +// request line and headers as the bytes flow through, then relays everything +// to the target over a raw TCP pipe. It never answers on its own, so a +// response can only have come from the target. +async function startForwardProxy(targetPort: number): Promise { + const requestLines: string[] = []; + const requestHeaders: Record[] = []; + let pending = ''; + const server = net.createServer((clientSocket) => { + const upstream = net.connect(targetPort, '127.0.0.1'); + clientSocket.pipe(upstream); + upstream.pipe(clientSocket); + clientSocket.on('data', (chunk) => { + pending += chunk.toString('latin1'); + while (true) { + const headerEnd = pending.indexOf('\r\n\r\n'); + if (headerEnd < 0) return; + const requestHead = pending.slice(0, headerEnd); + pending = pending.slice(headerEnd + 4); + const [line = '', ...headerLines] = requestHead.split('\r\n'); + requestLines.push(line); + const headers: Record = {}; + for (const headerLine of headerLines) { + const separator = headerLine.indexOf(':'); + if (separator !== -1) { + headers[headerLine.slice(0, separator).trim().toLowerCase()] = headerLine + .slice(separator + 1) + .trim(); + } + } + requestHeaders.push(headers); + } + }); + clientSocket.on('error', () => upstream.destroy()); + upstream.on('error', () => clientSocket.destroy()); + clientSocket.on('close', () => upstream.destroy()); + upstream.on('close', () => clientSocket.destroy()); + }); + return { + port: await listen(server), + requestLines, + requestHeaders, + close: () => closeServer(server), + }; +} + function listen(server: net.Server): Promise { return new Promise((resolve, reject) => { server.once('error', reject);