Skip to content

Commit cbf7c2c

Browse files
committed
Fix stale proxy connections: disable HTTP agent keepAlive, reduce withPage timeout to 60s
1 parent aa8290e commit cbf7c2c

3 files changed

Lines changed: 76 additions & 25 deletions

File tree

src/browser-session.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,17 @@ import { randomUUID } from "crypto";
2626
import { mkdtempSync, rmSync } from "fs";
2727
import { join } from "path";
2828
import { tmpdir } from "os";
29-
import { anonymizeProxy, closeAnonymizedProxy } from "proxy-chain";
29+
import type { Server as ProxyChainServer } from "proxy-chain";
3030
import { FingerprintGenerator } from "fingerprint-generator";
3131
import { FingerprintInjector } from "fingerprint-injector";
3232
import { createProxyUrl } from "./proxy/config";
3333
import { createLogger } from "./utils/logger";
34-
import { findChromePath, buildChromeArgs, CHROME_LAUNCH_TIMEOUT_MS } from "./browser/shared";
34+
import {
35+
findChromePath,
36+
buildChromeArgs,
37+
createProxyTunnel,
38+
CHROME_LAUNCH_TIMEOUT_MS,
39+
} from "./browser/shared";
3540
import type { BrowserSession, BrowserSessionInternalOptions } from "./browser-types";
3641

3742
const logger = createLogger("browser-session");
@@ -62,17 +67,16 @@ export async function createBrowserSession(
6267
const proxyConfig = options.proxy ?? options.resolveProxy?.(options.proxyTier);
6368
const proxyUrl = proxyConfig ? createProxyUrl(proxyConfig) : undefined;
6469

65-
// proxy-chain handles auth transparently (no TLS breakage, no "Not Secure")
66-
let anonymizedUrl: string | undefined;
70+
// proxy-chain tunnel with keepAlive disabled (see createProxyTunnel)
71+
let proxyChainServer: ProxyChainServer | null = null;
6772
let chromeProxyArg: string | undefined;
6873

6974
if (proxyUrl) {
70-
anonymizedUrl = await anonymizeProxy(proxyUrl);
71-
chromeProxyArg = anonymizedUrl;
75+
const tunnel = await createProxyTunnel(proxyUrl);
76+
proxyChainServer = tunnel.server;
77+
chromeProxyArg = tunnel.url;
7278
if (verbose) {
73-
logger.info(
74-
`Proxy anonymized: ${proxyUrl.replace(/\/\/[^@]+@/, "//***@")} -> ${anonymizedUrl}`
75-
);
79+
logger.info(`Proxy tunnel: ${proxyUrl.replace(/\/\/[^@]+@/, "//***@")} -> ${tunnel.url}`);
7680
}
7781
}
7882

@@ -158,8 +162,8 @@ export async function createBrowserSession(
158162
} catch {
159163
/* ignore */
160164
}
161-
if (anonymizedUrl) {
162-
await closeAnonymizedProxy(anonymizedUrl, true).catch(() => {});
165+
if (proxyChainServer) {
166+
await proxyChainServer.close(true).catch(() => {});
163167
}
164168
try {
165169
rmSync(userDataDir, { recursive: true, force: true });
@@ -272,9 +276,9 @@ export async function createBrowserSession(
272276
internalPwBrowser = null;
273277
}
274278

275-
// Stop proxy-chain anonymized proxy
276-
if (anonymizedUrl) {
277-
await closeAnonymizedProxy(anonymizedUrl, true).catch(() => {});
279+
// Stop proxy-chain server
280+
if (proxyChainServer) {
281+
await proxyChainServer.close(true).catch(() => {});
278282
}
279283

280284
// Clean up temp profile directory (delayed so Chrome can release locks)

src/browser/playwright-pool.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,17 @@ import { mkdtempSync, rmSync } from "fs";
2424
import { join } from "path";
2525
import { tmpdir } from "os";
2626
import pLimit from "p-limit";
27-
import { anonymizeProxy, closeAnonymizedProxy } from "proxy-chain";
27+
import type { Server as ProxyChainServer } from "proxy-chain";
2828
import { FingerprintGenerator, type BrowserFingerprintWithHeaders } from "fingerprint-generator";
2929
import { FingerprintInjector } from "fingerprint-injector";
3030
import { createLogger, type Logger } from "../utils/logger.js";
3131
import type { ProxyHealthTracker } from "../proxy/health-tracker.js";
32-
import { findChromePath, buildChromeArgs, CHROME_LAUNCH_TIMEOUT_MS } from "./shared.js";
32+
import {
33+
findChromePath,
34+
buildChromeArgs,
35+
createProxyTunnel,
36+
CHROME_LAUNCH_TIMEOUT_MS,
37+
} from "./shared.js";
3338

3439
// Lazy-loaded Playwright types — we import dynamically to avoid hard dep at parse time.
3540
type PlaywrightBrowser = import("playwright-core").Browser;
@@ -99,7 +104,7 @@ export class ChromeInstance {
99104
private chromeProcess: ChildProcess | null = null;
100105
private pwBrowser: PlaywrightBrowser | null = null;
101106
private pwContext: PlaywrightBrowserContext | null = null;
102-
private anonymizedProxyUrl: string | null = null;
107+
private proxyChainServer: ProxyChainServer | null = null;
103108
private userDataDir: string | null = null;
104109
private wsEndpoint: string | null = null;
105110
private fingerprint: BrowserFingerprintWithHeaders["fingerprint"] | null = null;
@@ -159,7 +164,7 @@ export class ChromeInstance {
159164
* at most maxTabs calls run concurrently. A hard timeout guarantees the
160165
* slot is always released, even if fn hangs (proxy stall, DNS hang, etc.).
161166
*/
162-
async withPage<T>(fn: (page: PlaywrightPage) => Promise<T>, timeoutMs = 90_000): Promise<T> {
167+
async withPage<T>(fn: (page: PlaywrightPage) => Promise<T>, timeoutMs = 60_000): Promise<T> {
163168
if (this.state === "closed" || this.state === "retired") {
164169
throw new Error(`ChromeInstance: cannot withPage on ${this.state} browser`);
165170
}
@@ -285,12 +290,13 @@ export class ChromeInstance {
285290
try {
286291
this.logger.debug({ proxy: redactProxy(this.proxyUrl) }, "launching Chrome");
287292

288-
// Set up proxy via proxy-chain (handles auth transparently, preserves TLS)
293+
// Set up proxy tunnel with keepAlive disabled (see createProxyTunnel).
289294
let chromeProxyArg: string | undefined;
290295

291296
if (this.proxyUrl) {
292-
this.anonymizedProxyUrl = await anonymizeProxy(this.proxyUrl);
293-
chromeProxyArg = this.anonymizedProxyUrl;
297+
const tunnel = await createProxyTunnel(this.proxyUrl);
298+
this.proxyChainServer = tunnel.server;
299+
chromeProxyArg = tunnel.url;
294300
}
295301

296302
// Generate fingerprint once per ChromeInstance (cached across relaunches)
@@ -461,10 +467,10 @@ export class ChromeInstance {
461467
this.chromeProcess = null;
462468
}
463469

464-
// Stop proxy-chain anonymized proxy
465-
if (this.anonymizedProxyUrl) {
466-
await closeAnonymizedProxy(this.anonymizedProxyUrl, true).catch(() => {});
467-
this.anonymizedProxyUrl = null;
470+
// Stop proxy-chain server
471+
if (this.proxyChainServer) {
472+
await this.proxyChainServer.close(true).catch(() => {});
473+
this.proxyChainServer = null;
468474
}
469475

470476
// Remove temp profile

src/browser/shared.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,44 @@ export function buildChromeArgs(opts: ChromeArgsOptions): string[] {
141141
}
142142

143143
export const CHROME_LAUNCH_TIMEOUT_MS = 15_000;
144+
145+
// ─── Proxy tunnel with no connection pooling ─────────────────────
146+
147+
import http from "http";
148+
import { Server as ProxyChainServer } from "proxy-chain";
149+
150+
/**
151+
* Create a local proxy server that forwards to an upstream proxy
152+
* WITHOUT connection pooling.
153+
*
154+
* Node 19+ changed http.globalAgent to keepAlive: true by default.
155+
* proxy-chain's anonymizeProxy() passes agent: undefined to http.request(),
156+
* which falls back to globalAgent, which pools TCP connections to the
157+
* upstream proxy. Those pooled connections go stale after idle periods
158+
* (upstream drops them via NAT/firewall timeout), causing Chrome requests
159+
* to hang on dead sockets.
160+
*
161+
* This function creates a Server with an explicit http.Agent({ keepAlive: false })
162+
* so every CONNECT to the upstream gets a fresh TCP connection.
163+
*
164+
* @returns { url, close } - the local proxy URL and a cleanup function
165+
*/
166+
export async function createProxyTunnel(
167+
upstreamProxyUrl: string
168+
): Promise<{ url: string; server: ProxyChainServer }> {
169+
const agent = new http.Agent({ keepAlive: false });
170+
const server = new ProxyChainServer({
171+
port: 0,
172+
host: "127.0.0.1",
173+
prepareRequestFunction: () => ({
174+
requestAuthentication: false,
175+
upstreamProxyUrl,
176+
httpAgent: agent,
177+
}),
178+
});
179+
await server.listen();
180+
return {
181+
url: `http://127.0.0.1:${server.port}`,
182+
server,
183+
};
184+
}

0 commit comments

Comments
 (0)