Skip to content

Commit cab929d

Browse files
authored
Merge pull request #9 from ArchiveBox/fix/chrome-lifecycle-isolation
Fix chrome lifecycle consistency and test isolation
2 parents 52febe8 + bcb1b83 commit cab929d

22 files changed

Lines changed: 689 additions & 658 deletions

abx_plugins/plugins/accessibility/on_Snapshot__39_accessibility.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const {
2727
ensureNodeModuleResolution(module);
2828
const puppeteer = require('puppeteer-core');
2929
const {
30-
readCdpUrl,
30+
waitForChromeSession,
3131
connectToPage,
3232
waitForPageLoaded,
3333
} = require('../chrome/chrome_utils.js');
@@ -52,7 +52,7 @@ async function extractAccessibility(url, timeoutMs) {
5252
let browser = null;
5353

5454
try {
55-
if (!readCdpUrl(CHROME_SESSION_DIR)) {
55+
if (!(await waitForChromeSession(CHROME_SESSION_DIR, Math.min(timeoutMs, 1000), true))) {
5656
return { success: false, error: 'No Chrome session found (chrome plugin must run first)' };
5757
}
5858

abx_plugins/plugins/chrome/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ This directory does **not** own a separate browser. It stores the per-snapshot t
9595

9696
The snapshot-level `cdp_url.txt` and `chrome.pid` are copies of the crawl session values. The snapshot-level `target_id.txt` is unique per snapshot.
9797

98+
Snapshot hooks should treat `SNAP_DIR/chrome/` as their entire Chrome state surface. They should not reach back into `CRAWL_DIR/chrome/` directly; if they need a tab, target, navigation record, or copied extension metadata, they should consume the snapshot-level markers and let the core Chrome hooks handle any crawl-to-snapshot propagation.
99+
98100
## Readiness Lifecycle
99101

100102
### 1. Extension install hooks run before Chrome launch

abx_plugins/plugins/chrome/chrome_utils.js

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -524,14 +524,19 @@ async function launchChromium(options = {}) {
524524
// Write command script for debugging
525525
writeCmdScript(path.join(outputDir, 'cmd.sh'), binary, chromiumArgs);
526526

527+
let chromiumProcess = null;
528+
let chromePid = null;
529+
let recentStderr = '';
530+
let recentStdout = '';
531+
527532
try {
528533
console.error(`[*] Spawning Chromium (headless=${headless})...`);
529-
const chromiumProcess = spawn(binary, chromiumArgs, {
534+
chromiumProcess = spawn(binary, chromiumArgs, {
530535
stdio: ['ignore', 'pipe', 'pipe'],
531536
detached: true,
532537
});
533538

534-
const chromePid = chromiumProcess.pid;
539+
chromePid = chromiumProcess.pid;
535540
const chromeStartTime = Date.now() / 1000;
536541

537542
if (chromePid) {
@@ -541,16 +546,35 @@ async function launchChromium(options = {}) {
541546

542547
// Pipe Chrome output to stderr
543548
chromiumProcess.stdout.on('data', (data) => {
549+
recentStdout = `${recentStdout}${String(data)}`.slice(-4000);
544550
process.stderr.write(`[chromium:stdout] ${data}`);
545551
});
546552
chromiumProcess.stderr.on('data', (data) => {
553+
recentStderr = `${recentStderr}${String(data)}`.slice(-4000);
547554
process.stderr.write(`[chromium:stderr] ${data}`);
548555
});
549556

557+
const chromiumExit = new Promise((_, reject) => {
558+
chromiumProcess.once('error', (error) => {
559+
reject(new Error(`Chromium process failed to start: ${error.message}`));
560+
});
561+
chromiumProcess.once('exit', (code, signal) => {
562+
reject(new Error(
563+
`Chromium exited before opening the debug port (code=${code ?? 'null'}, signal=${signal || 'none'})`
564+
));
565+
});
566+
});
567+
// Suppress unhandled rejection if chromiumExit loses the race but
568+
// fires later when the browser eventually shuts down.
569+
chromiumExit.catch(() => {});
570+
550571
// Wait for debug port
551572
console.error(`[*] Waiting for debug port ${debugPort}...`);
552573
const debugProbeTimeoutMs = getEnvInt('CHROME_DEBUG_PORT_TIMEOUT_MS', 30000);
553-
const versionInfo = await waitForDebugPort(debugPort, debugProbeTimeoutMs);
574+
const versionInfo = await Promise.race([
575+
waitForDebugPort(debugPort, debugProbeTimeoutMs),
576+
chromiumExit,
577+
]);
554578
const wsUrl = versionInfo.webSocketDebuggerUrl;
555579

556580
console.error(`[+] Chromium ready: ${wsUrl}`);
@@ -584,7 +608,19 @@ async function launchChromium(options = {}) {
584608

585609
return result;
586610
} catch (e) {
587-
return { success: false, error: `${e.name}: ${e.message}` };
611+
if (chromePid) {
612+
await cleanupLaunchArtifacts(outputDir, chromePid);
613+
}
614+
const extraOutput = [
615+
recentStdout ? `stdout=${recentStdout.trim()}` : '',
616+
recentStderr ? `stderr=${recentStderr.trim()}` : '',
617+
].filter(Boolean).join(' ');
618+
return {
619+
success: false,
620+
error: extraOutput
621+
? `${e.name}: ${e.message} (${extraOutput})`
622+
: `${e.name}: ${e.message}`,
623+
};
588624
}
589625
}
590626

@@ -1591,10 +1627,15 @@ function findChromium() {
15911627
if (validateBinary(c)) return c;
15921628
}
15931629
// Also search puppeteer cache under LIB_DIR
1594-
const libPuppeteerDir = path.join(libDir, 'puppeteer', 'chrome');
1595-
const libPuppeteerBinary = findInPuppeteerDir(libPuppeteerDir);
1596-
if (libPuppeteerBinary && validateBinary(libPuppeteerBinary)) {
1597-
return libPuppeteerBinary;
1630+
const libPuppeteerDirs = [
1631+
path.join(libDir, 'puppeteer', 'chromium'),
1632+
path.join(libDir, 'puppeteer', 'chrome'),
1633+
];
1634+
for (const libPuppeteerDir of libPuppeteerDirs) {
1635+
const libPuppeteerBinary = findInPuppeteerDir(libPuppeteerDir);
1636+
if (libPuppeteerBinary && validateBinary(libPuppeteerBinary)) {
1637+
return libPuppeteerBinary;
1638+
}
15981639
}
15991640
}
16001641

@@ -2212,14 +2253,24 @@ async function withConnectedBrowser(options, operation) {
22122253
async function setBrowserDownloadBehavior(options = {}) {
22132254
const {
22142255
browser,
2256+
page,
22152257
downloadPath,
22162258
} = options;
22172259

2218-
if (!browser || !downloadPath) return false;
2260+
if (!browser && !page) {
2261+
throw new Error('setBrowserDownloadBehavior requires a browser or page');
2262+
}
2263+
if (!downloadPath) {
2264+
throw new Error('setBrowserDownloadBehavior requires downloadPath');
2265+
}
22192266

22202267
await fs.promises.mkdir(downloadPath, { recursive: true });
2221-
const session = await browser.target().createCDPSession();
2268+
const sessionTarget = page ? page.target() : browser.target();
2269+
const session = await sessionTarget.createCDPSession();
22222270

2271+
// Keep the CDP session alive for the lifetime of the caller's browser/page
2272+
// connection. Extension-driven downloads regress if we detach immediately
2273+
// after configuring download behavior.
22232274
try {
22242275
await session.send('Browser.setDownloadBehavior', {
22252276
behavior: 'allow',
@@ -2242,7 +2293,7 @@ async function setBrowserDownloadBehavior(options = {}) {
22422293
}
22432294
}
22442295

2245-
function fetchDevtoolsTargets(cdpUrl) {
2296+
function fetchDevtoolsTargets(cdpUrl, timeoutMs = 5000) {
22462297
const port = getChromeDebugPortFromCdpUrl(cdpUrl);
22472298
if (!port) {
22482299
return Promise.resolve([]);
@@ -2264,11 +2315,14 @@ function fetchDevtoolsTargets(cdpUrl) {
22642315
});
22652316
}
22662317
);
2318+
req.setTimeout(timeoutMs, () => {
2319+
req.destroy(new Error(`Timed out fetching DevTools targets after ${timeoutMs}ms`));
2320+
});
22672321
req.on('error', reject);
22682322
});
22692323
}
22702324

2271-
function devtoolsHttpRequest(cdpUrl, requestPath, method = 'GET') {
2325+
function devtoolsHttpRequest(cdpUrl, requestPath, method = 'GET', timeoutMs = 5000) {
22722326
const port = getChromeDebugPortFromCdpUrl(cdpUrl);
22732327
if (!port) {
22742328
return Promise.reject(new Error(`Invalid CDP URL: ${cdpUrl}`));
@@ -2289,14 +2343,18 @@ function devtoolsHttpRequest(cdpUrl, requestPath, method = 'GET') {
22892343
});
22902344
}
22912345
);
2346+
req.setTimeout(timeoutMs, () => {
2347+
req.destroy(new Error(`Timed out waiting for DevTools ${method} ${requestPath} after ${timeoutMs}ms`));
2348+
});
22922349
req.on('error', reject);
22932350
req.end();
22942351
});
22952352
}
22962353

2297-
async function createDevtoolsPageTarget(cdpUrl, initialUrl = 'about:blank') {
2354+
async function createDevtoolsPageTarget(cdpUrl, initialUrl = 'about:blank', options = {}) {
2355+
const timeoutMs = options.timeoutMs || 5000;
22982356
const encodedUrl = encodeURIComponent(initialUrl);
2299-
const response = await devtoolsHttpRequest(cdpUrl, `/json/new?${encodedUrl}`, 'PUT');
2357+
const response = await devtoolsHttpRequest(cdpUrl, `/json/new?${encodedUrl}`, 'PUT', timeoutMs);
23002358
const target = JSON.parse(response || '{}');
23012359
if (!target?.id) {
23022360
throw new Error('Failed to create DevTools page target');
@@ -2648,15 +2706,38 @@ async function getBrowserServerUrl(chromeSessionDir = '../chrome', options = {})
26482706
* @returns {Promise<{targetId: string}>}
26492707
*/
26502708
async function openTabInChromeSession(options = {}) {
2651-
const { cdpUrl, puppeteer } = options;
2709+
const {
2710+
cdpUrl,
2711+
puppeteer,
2712+
timeoutMs = 10000,
2713+
intervalMs = 250,
2714+
} = options;
26522715
if (!cdpUrl) {
26532716
throw new Error(CHROME_SESSION_REQUIRED_ERROR);
26542717
}
26552718
if (puppeteer) {
26562719
requirePuppeteerModule(puppeteer, 'openTabInChromeSession');
26572720
}
2658-
const target = await createDevtoolsPageTarget(cdpUrl, 'about:blank');
2659-
return { targetId: target.id };
2721+
const deadline = Date.now() + Math.max(timeoutMs, 0);
2722+
let lastError = null;
2723+
2724+
while (Date.now() <= deadline) {
2725+
try {
2726+
const requestTimeoutMs = Math.max(1000, Math.min(5000, deadline - Date.now()));
2727+
const target = await createDevtoolsPageTarget(cdpUrl, 'about:blank', {
2728+
timeoutMs: requestTimeoutMs,
2729+
});
2730+
return { targetId: target.id };
2731+
} catch (error) {
2732+
lastError = error;
2733+
if (Date.now() >= deadline) {
2734+
break;
2735+
}
2736+
await sleep(intervalMs);
2737+
}
2738+
}
2739+
2740+
throw lastError || new Error('Failed to create DevTools page target');
26602741
}
26612742

26622743
/**

abx_plugins/plugins/chrome/on_Snapshot__10_chrome_tab.daemon.bg.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ async function main() {
246246
const opened = await openTabInChromeSession({
247247
cdpUrl: crawlSession.cdpUrl,
248248
puppeteer,
249+
timeoutMs: timeoutSeconds * 1000,
249250
});
250251
targetId = opened.targetId;
251252
if (!targetId) {

abx_plugins/plugins/chrome/tests/chrome_test_helpers.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -409,12 +409,15 @@ def _coerce_upstream_urls(value: Any) -> Optional[Dict[str, str]]:
409409
@pytest.fixture(scope="session")
410410
def ensure_chromium_and_puppeteer_installed(tmp_path_factory):
411411
"""Install Chromium and Puppeteer once for test sessions that require Chrome."""
412-
if not os.environ.get("SNAP_DIR"):
413-
os.environ["SNAP_DIR"] = str(tmp_path_factory.mktemp("chrome_test_data"))
414-
if not os.environ.get("PERSONAS_DIR"):
415-
os.environ["PERSONAS_DIR"] = str(
416-
tmp_path_factory.mktemp("chrome_test_personas")
417-
)
412+
os.environ["SNAP_DIR"] = str(tmp_path_factory.mktemp("chrome_test_data"))
413+
os.environ["PERSONAS_DIR"] = str(tmp_path_factory.mktemp("chrome_test_personas"))
414+
os.environ["HOME"] = str(tmp_path_factory.mktemp("chrome_test_home"))
415+
os.environ["XDG_CONFIG_HOME"] = str(Path(os.environ["HOME"]) / ".config")
416+
os.environ["XDG_CACHE_HOME"] = str(Path(os.environ["HOME"]) / ".cache")
417+
os.environ["XDG_DATA_HOME"] = str(Path(os.environ["HOME"]) / ".local" / "share")
418+
419+
for key in ("HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME"):
420+
Path(os.environ[key]).mkdir(parents=True, exist_ok=True)
418421

419422
env = get_test_env()
420423
chromium_binary = install_chromium_with_hooks(env)
@@ -1026,12 +1029,14 @@ def install_chromium_with_hooks(env: dict, timeout: int = 300) -> str:
10261029
records = parse_jsonl_records(result.stdout)
10271030
chromium_record = None
10281031
for record in records:
1029-
if record.get("type") == "Binary" and record.get("name") in (
1030-
"chromium",
1031-
"chrome",
1032-
):
1032+
if record.get("type") == "Binary" and record.get("name") == "chromium":
10331033
chromium_record = record
10341034
break
1035+
if not chromium_record:
1036+
for record in records:
1037+
if record.get("type") == "Binary" and record.get("name") == "chrome":
1038+
chromium_record = record
1039+
break
10351040
if not chromium_record:
10361041
chromium_record = parse_jsonl_output(result.stdout, record_type="Binary")
10371042
if not chromium_record:
@@ -1116,13 +1121,21 @@ def setup_test_env(tmpdir: Path) -> dict:
11161121
node_modules_dir = npm_dir / "node_modules"
11171122

11181123
personas_dir = tmpdir / "personas"
1124+
home_dir = tmpdir / "home"
1125+
xdg_config_home = home_dir / ".config"
1126+
xdg_cache_home = home_dir / ".cache"
1127+
xdg_data_home = home_dir / ".local" / "share"
11191128
chrome_extensions_dir = personas_dir / "Default" / "chrome_extensions"
11201129
chrome_downloads_dir = personas_dir / "Default" / "chrome_downloads"
11211130
chrome_user_data_dir = personas_dir / "Default" / "chrome_user_data"
11221131

11231132
# Create all directories
11241133
node_modules_dir.mkdir(parents=True, exist_ok=True)
11251134
npm_bin_dir.mkdir(parents=True, exist_ok=True)
1135+
home_dir.mkdir(parents=True, exist_ok=True)
1136+
xdg_config_home.mkdir(parents=True, exist_ok=True)
1137+
xdg_cache_home.mkdir(parents=True, exist_ok=True)
1138+
xdg_data_home.mkdir(parents=True, exist_ok=True)
11261139
chrome_extensions_dir.mkdir(parents=True, exist_ok=True)
11271140
chrome_downloads_dir.mkdir(parents=True, exist_ok=True)
11281141
chrome_user_data_dir.mkdir(parents=True, exist_ok=True)
@@ -1141,6 +1154,10 @@ def setup_test_env(tmpdir: Path) -> dict:
11411154
"MACHINE_TYPE": machine_type,
11421155
"NPM_BIN_DIR": str(npm_bin_dir),
11431156
"NODE_MODULES_DIR": str(node_modules_dir),
1157+
"HOME": str(home_dir),
1158+
"XDG_CONFIG_HOME": str(xdg_config_home),
1159+
"XDG_CACHE_HOME": str(xdg_cache_home),
1160+
"XDG_DATA_HOME": str(xdg_data_home),
11441161
"CHROME_EXTENSIONS_DIR": str(chrome_extensions_dir),
11451162
"CHROME_DOWNLOADS_DIR": str(chrome_downloads_dir),
11461163
"CHROME_USER_DATA_DIR": str(chrome_user_data_dir),
@@ -1481,6 +1498,10 @@ def chrome_session(
14811498
crawl_dir = tmpdir / "crawl" / crawl_id
14821499
snap_dir = tmpdir / "snap" / snapshot_id
14831500
personas_dir = tmpdir / "personas"
1501+
home_dir = tmpdir / "home"
1502+
xdg_config_home = home_dir / ".config"
1503+
xdg_cache_home = home_dir / ".cache"
1504+
xdg_data_home = home_dir / ".local" / "share"
14841505
env = os.environ.copy()
14851506

14861507
# Prefer an already-provisioned NODE_MODULES_DIR (set by session-level chrome fixture)
@@ -1507,6 +1528,10 @@ def chrome_session(
15071528
# Build env with tmpdir-specific paths
15081529
snap_dir.mkdir(parents=True, exist_ok=True)
15091530
personas_dir.mkdir(parents=True, exist_ok=True)
1531+
home_dir.mkdir(parents=True, exist_ok=True)
1532+
xdg_config_home.mkdir(parents=True, exist_ok=True)
1533+
xdg_cache_home.mkdir(parents=True, exist_ok=True)
1534+
xdg_data_home.mkdir(parents=True, exist_ok=True)
15101535

15111536
env.update(
15121537
{
@@ -1518,6 +1543,10 @@ def chrome_session(
15181543
"NODE_MODULES_DIR": str(node_modules_dir),
15191544
"NODE_PATH": str(node_modules_dir),
15201545
"NPM_BIN_DIR": str(npm_dir / ".bin"),
1546+
"HOME": str(home_dir),
1547+
"XDG_CONFIG_HOME": str(xdg_config_home),
1548+
"XDG_CACHE_HOME": str(xdg_cache_home),
1549+
"XDG_DATA_HOME": str(xdg_data_home),
15211550
"CHROME_HEADLESS": "true",
15221551
"PUPPETEER_CACHE_DIR": str(puppeteer_cache_dir),
15231552
}

0 commit comments

Comments
 (0)