Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/proxy/captcha-happy.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { describe, expect, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import {
__captchaMemStats,
createDom,
destroyDom,
installGlobalWindowAlias,
noteRequest,
reclaimCaptchaHeap,
rememberCdnBody,
removeGlobalWindowAlias,
} from "./captcha-happy.js";

Expand Down Expand Up @@ -307,3 +311,52 @@ describe("alias lifecycle (install/remove descriptor contract)", () => {
}
});
});

// Memory guards for long-running serve processes (issue #50: monotonic RSS
// growth, 2.9 days → 9.95GB). Every structure here is module-scoped and shared
// across solves, so without an explicit bound each one grows for the process
// lifetime.
describe("memory guards (issue #50)", () => {
test("guest request log is a ring buffer, not an append-only array", () => {
const cap = __captchaMemStats().requestLogCap;
const beyond = cap + 44;
for (let i = 0; i < beyond; i++) {
noteRequest({ at: Date.now(), method: "GET", url: `https://example.com/${i}` });
}
const stats = __captchaMemStats();
expect(stats.requestLogLength).toBe(cap);
});

test("in-memory CDN cache evicts oldest entries at the cap (pe rotation)", () => {
for (let i = 0; i < 20; i++) {
rememberCdnBody(`https://o.alicdn.com/dynamicJS/v/pe.${1000 + i}.js`, Buffer.from("x"));
}
expect(__captchaMemStats().cdnCacheSize).toBe(__captchaMemStats().cdnCacheCap);
});

test("heap reclaim runs at most once per throttle window", () => {
const saved = process.env.CAPTCHA_GC_MIN_INTERVAL_MS;
try {
const before = __captchaMemStats().gcCalls;
process.env.CAPTCHA_GC_MIN_INTERVAL_MS = "0";
reclaimCaptchaHeap();
reclaimCaptchaHeap();
expect(__captchaMemStats().gcCalls).toBe(before + 2);

process.env.CAPTCHA_GC_MIN_INTERVAL_MS = "3600000";
reclaimCaptchaHeap();
expect(__captchaMemStats().gcCalls).toBe(before + 2);
} finally {
if (saved === undefined) delete process.env.CAPTCHA_GC_MIN_INTERVAL_MS;
else process.env.CAPTCHA_GC_MIN_INTERVAL_MS = saved;
}
});

test("reused-window solve budget defaults to the reduced cap", () => {
// 25 → 8 (issue #50): each solve leaves SDK instance graphs resident in a
// reused window, so the cap bounds a generation's retention peak.
if (process.env.CAPTCHA_REUSE_MAX_SOLVES === undefined) {
expect(__captchaMemStats().reuseMaxSolves).toBe(8);
}
});
});
82 changes: 73 additions & 9 deletions src/proxy/captcha-happy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ function shutdownSyncFetchWorker(): void {

const CDN_CACHE_DIR = path.join(os.homedir(), ".zcode-captcha-cdn-cache");
const _memCdnCache = new Map();
// pe bundles rotate (pe.0xx…); every rotation would otherwise pin a fresh
// multi-hundred-KB body for the process lifetime (issue #50). Insertion-ordered
// FIFO — the oldest rotation ages out first; disk cache still serves re-reads.
const MEM_CDN_CACHE_CAP = 16;
function rememberCdnBody(url, body) {
_memCdnCache.set(url, body);
if (_memCdnCache.size > MEM_CDN_CACHE_CAP) {
for (const key of _memCdnCache.keys()) {
_memCdnCache.delete(key);
if (_memCdnCache.size <= MEM_CDN_CACHE_CAP) break;
}
}
}
let _cookieCache = { cookies: [], ts: 0 };
const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000;
const _DEBUG = /^(1|true|yes)$/i.test(
Expand All @@ -136,8 +149,17 @@ if (proxyUrl) {
}

// ── Globals shared across solves ────────────────────────────────────────────
// Ring buffer: the stall detector reads only the newest entry and failure
// diagnostics the last ~12 entries of the CURRENT solve — anything older is
// dead weight. Unbounded it grew for the process lifetime (issue #50).
const REQUEST_LOG_CAP = 256;
const _requestLog = [];
const solveTimes = [];
function noteRequest(entry) {
_requestLog.push(entry);
if (_requestLog.length > REQUEST_LOG_CAP) {
_requestLog.splice(0, _requestLog.length - REQUEST_LOG_CAP);
}
}
// Consecutive-stall tracker per pe bundle URL: the same cached pe version
// can stall every attempt (bad rotated VM variant / stale cache). After two
// stalls on one URL, evict its memory + disk cache entry so the next init
Expand Down Expand Up @@ -225,7 +247,7 @@ function getCachedBody(url) {
const p = diskPathFor(url);
if (fs.existsSync(p)) {
const body = fs.readFileSync(p);
_memCdnCache.set(url, body);
rememberCdnBody(url, body);
return body;
}
} catch (_) {}
Expand All @@ -237,7 +259,7 @@ async function fetchAndStore(url) {
const res = await fetch(url, { headers: { "user-agent": fp.userAgent } });
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length > 0) {
_memCdnCache.set(url, buf);
rememberCdnBody(url, buf);
try {
const p = diskPathFor(url);
fs.mkdirSync(CDN_CACHE_DIR, { recursive: true });
Expand Down Expand Up @@ -339,7 +361,7 @@ function makeInterceptor(bypassPeCache = false) {
return {
async beforeAsyncRequest({ request, window: w }) {
const url = request.url;
_requestLog.push({ at: Date.now(), method: request.method, url });
noteRequest({ at: Date.now(), method: request.method, url });
injectRequestHeaders(request);
if (/\balicdn\.com/i.test(url)) {
let body = skipPeCache(url) ? null : getCachedBody(url);
Expand Down Expand Up @@ -414,7 +436,7 @@ function makeInterceptor(bypassPeCache = false) {
},
beforeSyncRequest({ request, window: w }) {
const url = request.url;
_requestLog.push({ at: Date.now(), method: request.method, url, sync: true });
noteRequest({ at: Date.now(), method: request.method, url, sync: true });
injectRequestHeaders(request);
let body = null;
if (/\balicdn\.com/i.test(url)) {
Expand Down Expand Up @@ -2174,6 +2196,31 @@ export function removeGlobalWindowAlias(g, w) {
} catch (_) {}
}

// ── Heap reclaim at window-generation turnover ─────────────────────────────
// A destroyed window leaves a large dead object graph behind (SDK instances,
// pe VM, intervals, XHR buffers). JSC only hands pages back to the OS on a
// FULL synchronous collection, so without this the serve process ratchets:
// every window generation's allocation peak becomes the permanent RSS floor
// (issue #50: 2.9 days → 9.95GB resident). Throttled because pe-storm retry
// ladders destroy several windows back-to-back and Bun.gc(true) is a
// stop-the-world pass that gets heavier at large heaps.
const _gcStats = { calls: 0, lastAt: 0 };
function reclaimCaptchaHeap() {
try {
const gc =
(typeof Bun !== "undefined" && typeof Bun.gc === "function" && Bun.gc) ||
(typeof globalThis.gc === "function" && globalThis.gc); // Node --expose-gc (tests)
if (!gc) return; // plain Node runtime (Android bundle): no exposed gc, skip
const raw = Number(process.env.CAPTCHA_GC_MIN_INTERVAL_MS);
const minMs = Number.isFinite(raw) && raw >= 0 ? raw : 5_000;
const now = Date.now();
if (now - _gcStats.lastAt < minMs) return;
_gcStats.lastAt = now;
_gcStats.calls += 1;
gc(true);
} catch (_) {}
}

function destroyDom(win) {
try {
const cap = win.document.getElementById("cap");
Expand All @@ -2192,6 +2239,7 @@ function destroyDom(win) {
// host constructor (harmless: the window registry is already cleared).
removeGuestScope(win);
try { shutdownSyncFetchWorker(); } catch (_) {}
reclaimCaptchaHeap();
}

function extractVerifyParam(param) {
Expand Down Expand Up @@ -2247,10 +2295,13 @@ function handleCaptchaResult(result) {
// 815ms per solve) by amortizing the DOM boot + SDK script load. On by
// default; opt out with CAPTCHA_WINDOW_REUSE=0 (or per-call
// solveTraceless({reuseWindow:false})). The window
// is discarded after `maxSolves` (memory growth), after any stall/failure
// (fresh InitCaptchaV3 rolls a new pe version), or after `maxIdleMs` idle.
// is discarded after `maxSolves` (each solve leaves SDK instance graphs
// resident in the window — issue #50 measured tens of MB per solve), after
// any stall/failure (fresh InitCaptchaV3 rolls a new pe version), or after
// `maxIdleMs` idle. 8 keeps most of the amortization win while capping a
// generation's retention peak at ~1/3 of the old 25-solve default.
const _reusePool = { window: null, browserFrame: null, solves: 0, lastUsedAt: 0 };
const REUSE_MAX_SOLVES = Number(process.env.CAPTCHA_REUSE_MAX_SOLVES || 25);
const REUSE_MAX_SOLVES = Number(process.env.CAPTCHA_REUSE_MAX_SOLVES || 8);
const REUSE_MAX_IDLE_MS = Number(process.env.CAPTCHA_REUSE_MAX_IDLE_MS || 120_000);

function takeReusableWindow() {
Expand Down Expand Up @@ -2448,4 +2499,17 @@ async function solveTraceless(opts) {
}
}

export { solveTraceless, createDom, destroyDom };
/** Test/debug observability into the memory guards (issue #50). */
export function __captchaMemStats() {
return {
requestLogLength: _requestLog.length,
requestLogCap: REQUEST_LOG_CAP,
cdnCacheSize: _memCdnCache.size,
cdnCacheCap: MEM_CDN_CACHE_CAP,
reuseMaxSolves: REUSE_MAX_SOLVES,
gcCalls: _gcStats.calls,
gcLastAt: _gcStats.lastAt,
};
}

export { solveTraceless, createDom, destroyDom, noteRequest, rememberCdnBody, reclaimCaptchaHeap };
Loading