diff --git a/desktop/electron/services/systemProxyBridge.test.ts b/desktop/electron/services/systemProxyBridge.test.ts index ecc8e525..dad82237 100644 --- a/desktop/electron/services/systemProxyBridge.test.ts +++ b/desktop/electron/services/systemProxyBridge.test.ts @@ -83,6 +83,28 @@ describe('SystemProxyBridge', () => { .toBe('request-fallback') }) + it('terminates the downstream response when a selected proxy resets after sending headers', async () => { + const resettingProxy = net.createServer(socket => { + socket.once('data', () => { + socket.write( + 'HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\npartial', + () => setTimeout(() => socket.resetAndDestroy(), 25), + ) + }) + }) + servers.push(resettingProxy) + const resettingPort = await listen(resettingProxy) + const bridge = await startBridge(async () => `PROXY 127.0.0.1:${resettingPort}`) + + const outcome = await Promise.race([ + requestOutcomeThroughProxy(bridge, 'http://foreign.example/after-reset'), + new Promise<'timed-out'>(resolve => setTimeout(() => resolve('timed-out'), 500)), + ]) + + expect(outcome).not.toBe('timed-out') + expect(outcome).not.toBe('end') + }) + it('does not replay a non-idempotent request after its selected proxy drops it', async () => { let firstProxyRequests = 0 const droppingProxy = net.createServer(socket => { @@ -314,6 +336,28 @@ function requestThroughProxy( }) } +function requestOutcomeThroughProxy( + proxyUrl: string, + targetUrl: string, +): Promise<'aborted' | 'response-error' | 'request-error' | 'end'> { + const proxy = new URL(proxyUrl) + return new Promise(resolve => { + const request = http.request({ + host: proxy.hostname, + port: Number(proxy.port), + path: targetUrl, + headers: { Host: new URL(targetUrl).host }, + }, response => { + response.resume() + response.once('aborted', () => resolve('aborted')) + response.once('error', () => resolve('response-error')) + response.once('end', () => resolve('end')) + }) + request.once('error', () => resolve('request-error')) + request.end() + }) +} + function connectAndEcho( proxyUrl: string, authority: string, diff --git a/desktop/electron/services/systemProxyBridge.ts b/desktop/electron/services/systemProxyBridge.ts index 889e6750..b0491dfb 100644 --- a/desktop/electron/services/systemProxyBridge.ts +++ b/desktop/electron/services/systemProxyBridge.ts @@ -4,6 +4,7 @@ import net from 'node:net' import tls from 'node:tls' import { lookup } from 'node:dns/promises' import type { Duplex } from 'node:stream' +import { pipeline } from 'node:stream/promises' export const SYSTEM_PROXY_BRIDGE_HOST = '127.0.0.1' const CONNECT_TIMEOUT_MS = 10_000 @@ -163,9 +164,13 @@ export class SystemProxyBridge implements SystemProxyBridgeLike { onSocket, ) response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.statusMessage, upstreamResponse.headers) - upstreamResponse.pipe(response) + await pipeline(upstreamResponse, response) } catch (error) { - if (!response.headersSent) response.writeHead(502, { Connection: 'close' }) + if (response.headersSent) { + response.destroy() + return + } + response.writeHead(502, { Connection: 'close' }) response.end(`System proxy bridge failed: ${error instanceof Error ? error.message : String(error)}`) } }