Skip to content

Commit 4f78fd4

Browse files
committed
merge: chromium fallback + surface launch failures instead of silent exit
2 parents 335fb59 + cd7a867 commit 4f78fd4

7 files changed

Lines changed: 904 additions & 11 deletions

File tree

src/browser/chromium-fallback.js

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* Chromium pinned-build fallback (T-0109).
3+
*
4+
* Playwright pins one exact chromium / chromium_headless_shell revision (see
5+
* playwright-core/browsers.json). When that pinned build is absent from the
6+
* ms-playwright cache, chromium.launch() throws:
7+
*
8+
* browserType.launch: Executable doesn't exist at
9+
* <cache>/chromium_headless_shell-<rev>/.../chrome-headless-shell
10+
*
11+
* …even when several OTHER chromium builds sit cached right beside it. That was
12+
* the T-0083 incident: pinned -1217 missing while -1140/-1187/-1200/-1208/
13+
* -1223/-1228/-1234 were all present, yet the run died fatal with no fallback.
14+
*
15+
* This module enumerates viable alternatives so the resolver can retry:
16+
* 1. already-cached ms-playwright chromium builds (newest revision first)
17+
* 2. system chromium-family channels (chrome, msedge — Playwright-native)
18+
*
19+
* If nothing launches, chromiumInstallHint() produces an actionable error
20+
* naming the exact install command and what was searched.
21+
*
22+
* Explicit user choices (BROWSER_EXECUTABLE_PATH / BROWSER_CHANNEL) are NOT
23+
* handled here — the resolver only invokes this for the default / implicit
24+
* chromium path, so an explicit choice's failure always surfaces verbatim.
25+
*
26+
* Track: adopt-lightpanda
27+
*/
28+
29+
import fs from 'fs';
30+
import os from 'os';
31+
import path from 'path';
32+
33+
// Per-platform relative executable path inside a cached
34+
// `chromium_headless_shell-<rev>` directory. Mirrors Playwright's
35+
// EXECUTABLE_PATHS for "chromium-headless-shell" (registry/index.js).
36+
const HEADLESS_SHELL_REL = {
37+
'darwin-arm64': ['chrome-headless-shell-mac-arm64', 'chrome-headless-shell'],
38+
'darwin-x64': ['chrome-headless-shell-mac-x64', 'chrome-headless-shell'],
39+
'linux-x64': ['chrome-headless-shell-linux64', 'chrome-headless-shell'],
40+
'linux-arm64': ['chrome-linux', 'headless_shell'],
41+
'win32-x64': ['chrome-headless-shell-win64', 'chrome-headless-shell.exe'],
42+
};
43+
44+
/**
45+
* Root of the Playwright browser cache. Honors PLAYWRIGHT_BROWSERS_PATH,
46+
* otherwise the OS default cache dir + "ms-playwright" (matching Playwright's
47+
* defaultRegistryDirectory).
48+
*
49+
* @param {object} [env]
50+
* @param {NodeJS.Platform} [platform]
51+
* @returns {string}
52+
*/
53+
export function msPlaywrightRoot(env = process.env, platform = os.platform()) {
54+
const override = env.PLAYWRIGHT_BROWSERS_PATH;
55+
if (override && override !== '0') return override;
56+
57+
if (platform === 'darwin') {
58+
return path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright');
59+
}
60+
if (platform === 'win32') {
61+
const localAppData = env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
62+
return path.join(localAppData, 'ms-playwright');
63+
}
64+
// linux + others: XDG_CACHE_HOME or ~/.cache
65+
const xdg = env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
66+
return path.join(xdg, 'ms-playwright');
67+
}
68+
69+
/**
70+
* Is this the Playwright pinned-build "Executable doesn't exist" launch error
71+
* (the exact failure this module rescues)? Kept narrow so unrelated launch
72+
* failures (crashes, connection refusals) are NOT swallowed by the fallback.
73+
*
74+
* @param {unknown} err
75+
* @returns {boolean}
76+
*/
77+
export function isMissingBrowserError(err) {
78+
const msg = err && err.message ? String(err.message) : '';
79+
return msg.includes("Executable doesn't exist");
80+
}
81+
82+
/**
83+
* Enumerate cached chromium_headless_shell builds present on disk, newest
84+
* revision first. Each entry: { revision, executablePath }.
85+
*
86+
* @param {string} root ms-playwright cache root
87+
* @param {object} [opts]
88+
* @param {NodeJS.Platform} [opts.platform]
89+
* @param {string} [opts.arch]
90+
* @param {(dir: string) => string[]} [opts.readdir]
91+
* @param {(p: string) => boolean} [opts.exists]
92+
* @returns {{ revision: number, executablePath: string }[]}
93+
*/
94+
export function listCachedChromiumBuilds(
95+
root,
96+
{ platform = os.platform(), arch = os.arch(), readdir = defaultReaddir, exists = fs.existsSync } = {},
97+
) {
98+
const rel = HEADLESS_SHELL_REL[`${platform}-${arch}`];
99+
if (!rel) return [];
100+
101+
let entries;
102+
try {
103+
entries = readdir(root);
104+
} catch {
105+
return [];
106+
}
107+
108+
const builds = [];
109+
for (const name of entries) {
110+
const m = /^chromium_headless_shell-(\d+)$/.exec(name);
111+
if (!m) continue;
112+
const revision = Number(m[1]);
113+
const executablePath = path.join(root, name, ...rel);
114+
let present = false;
115+
try {
116+
present = exists(executablePath);
117+
} catch {
118+
present = false;
119+
}
120+
if (present) builds.push({ revision, executablePath });
121+
}
122+
123+
builds.sort((a, b) => b.revision - a.revision);
124+
return builds;
125+
}
126+
127+
function defaultReaddir(dir) {
128+
return fs.readdirSync(dir);
129+
}
130+
131+
/**
132+
* Build the ordered list of fallback launch candidates for the implicit
133+
* chromium path. Cached ms-playwright builds come first (exact same Chromium
134+
* family, no external dependency), then system channels Playwright resolves
135+
* natively.
136+
*
137+
* Each candidate: { source, executablePath?, channel?, revision?, label }
138+
*
139+
* @param {object} [opts]
140+
* @param {object} [opts.env]
141+
* @param {NodeJS.Platform} [opts.platform]
142+
* @param {string} [opts.arch]
143+
* @param {string} [opts.cacheRoot]
144+
* @param {(dir: string) => string[]} [opts.readdir]
145+
* @param {(p: string) => boolean} [opts.exists]
146+
* @returns {Array<object>}
147+
*/
148+
export function buildFallbackCandidates({
149+
env = process.env,
150+
platform = os.platform(),
151+
arch = os.arch(),
152+
cacheRoot,
153+
readdir,
154+
exists,
155+
} = {}) {
156+
const root = cacheRoot ?? msPlaywrightRoot(env, platform);
157+
const candidates = [];
158+
159+
for (const build of listCachedChromiumBuilds(root, { platform, arch, readdir, exists })) {
160+
candidates.push({
161+
source: 'ms-playwright-cache',
162+
executablePath: build.executablePath,
163+
revision: build.revision,
164+
label: `ms-playwright chromium build ${build.revision}`,
165+
});
166+
}
167+
168+
// Playwright-native system channels — resolved by `channel:` at launch time,
169+
// so we don't probe paths here; a launch attempt is the probe.
170+
for (const channel of ['chrome', 'msedge']) {
171+
candidates.push({
172+
source: 'system-channel',
173+
channel,
174+
label: `system ${channel}`,
175+
});
176+
}
177+
178+
return candidates;
179+
}
180+
181+
/**
182+
* Build an actionable error message for the "nothing available" terminal case.
183+
*
184+
* @param {object} args
185+
* @param {string[]} args.searched human-readable list of what was tried
186+
* @param {string} [args.pinned] the missing pinned build path/rev, if known
187+
* @returns {string}
188+
*/
189+
export function chromiumInstallHint({ searched = [], pinned } = {}) {
190+
const lines = [
191+
'No usable Chromium browser found.',
192+
pinned
193+
? `Playwright's pinned build is missing (${pinned}) and no cached or system fallback launched.`
194+
: `Playwright's pinned Chromium build is missing and no cached or system fallback launched.`,
195+
'',
196+
'Install the pinned build with:',
197+
' npx playwright install chromium',
198+
'',
199+
'Or set BROWSER_CHANNEL=chrome (or BROWSER_EXECUTABLE_PATH=<path>) to use a system browser.',
200+
];
201+
if (searched.length > 0) {
202+
lines.push('', 'Searched:');
203+
for (const s of searched) lines.push(` - ${s}`);
204+
}
205+
return lines.join('\n');
206+
}

src/browser/resolver.js

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
fingerprintError,
4141
} from './capability-manifest.js';
4242
import { signature as buildSignature } from './capability-signature.js';
43+
import * as chromiumFallback from './chromium-fallback.js';
4344

4445
// ── [SECTION: CHAIN] ─────────────────────────────────────────────────────────
4546
// T-0025: implement step 1 (exec-path) and step 3 (local probe).
@@ -660,6 +661,78 @@ export async function preflightCheck(env, { opKind, selector, stepTemplate, ligh
660661
};
661662
}
662663

664+
/**
665+
* Chromium pinned-build fallback (T-0109).
666+
*
667+
* Invoked when the implicit chromium path fails with Playwright's
668+
* "Executable doesn't exist" error (pinned build missing from the
669+
* ms-playwright cache). Walks buildFallbackCandidates() in order — cached
670+
* alternate chromium builds first, then system channels — launching each
671+
* until one succeeds. Emits a `browser.fallback` NDJSON naming the chosen
672+
* alternate so the switch is never silent. If nothing launches, throws an
673+
* actionable error naming `npx playwright install chromium` and what was
674+
* searched (with the original error preserved as `cause`).
675+
*
676+
* @param {object} env
677+
* @param {object} overrides
678+
* @param {Error} originalErr the pinned-build launch failure
679+
* @returns {Promise<import('./index.js').BrowserHandle>}
680+
*/
681+
/**
682+
* Human-readable label for a fallback candidate. Never returns undefined —
683+
* derives from label, revision, channel, or executablePath in that order so
684+
* the emitted `browser.fallback` event always identifies the chosen alternate.
685+
* @param {object} cand
686+
* @returns {string}
687+
*/
688+
function candidateLabel(cand) {
689+
if (cand.label) return cand.label;
690+
if (cand.revision != null) return `ms-playwright chromium build ${cand.revision}`;
691+
if (cand.channel) return `system ${cand.channel}`;
692+
if (cand.executablePath) return cand.executablePath;
693+
return cand.source || 'unknown';
694+
}
695+
696+
async function tryChromiumFallback(env, overrides, originalErr) {
697+
const candidates = chromiumFallback.buildFallbackCandidates({ env });
698+
const searched = [];
699+
let lastErr = originalErr;
700+
701+
for (const cand of candidates) {
702+
const record = {
703+
kind: 'chromium-launch',
704+
source: cand.source,
705+
version: cand.revision ?? null,
706+
executablePath: cand.executablePath ?? null,
707+
channel: cand.channel ?? null,
708+
};
709+
const label = candidateLabel(cand);
710+
try {
711+
const handle = await dispatch(record, overrides, env);
712+
emitCapabilityEvent({
713+
event: 'browser.fallback',
714+
from: 'chromium (pinned build missing)',
715+
to: label,
716+
reason: chromiumFallback.isMissingBrowserError(originalErr)
717+
? 'pinned build missing'
718+
: (originalErr && originalErr.message) || 'launch failed',
719+
});
720+
return handle;
721+
} catch (err) {
722+
lastErr = err;
723+
searched.push(label);
724+
}
725+
}
726+
727+
if (searched.length === 0) {
728+
searched.push('ms-playwright cache (no chromium builds present)', 'chrome', 'msedge');
729+
}
730+
const hint = chromiumFallback.chromiumInstallHint({ searched });
731+
const err = new Error(hint, { cause: lastErr });
732+
err.code = 'BROWSER_NOT_FOUND';
733+
throw err;
734+
}
735+
663736
/**
664737
* Public resolve() body. Wraps resolveInner with strict preflight (launch
665738
* level only) and the on-failure fallback path.
@@ -695,7 +768,22 @@ async function resolveWithCapability(env, overrides) {
695768
try {
696769
return await resolveInner(env, overrides);
697770
} catch (err) {
698-
if (channel !== 'lightpanda' || !env?.BROWSER_FALLBACK) throw err;
771+
if (channel !== 'lightpanda' || !env?.BROWSER_FALLBACK) {
772+
// Chromium pinned-build fallback (T-0109): when the IMPLICIT chromium
773+
// path (no explicit BROWSER_CHANNEL / BROWSER_EXECUTABLE_PATH) dies
774+
// because Playwright's pinned chromium_headless_shell build is absent
775+
// from the ms-playwright cache, retry an already-cached alternate build
776+
// or a system channel before failing. Explicit user choices are never
777+
// second-guessed — they fall through to `throw err` verbatim.
778+
if (
779+
!channel &&
780+
!env?.BROWSER_EXECUTABLE_PATH &&
781+
chromiumFallback.isMissingBrowserError(err)
782+
) {
783+
return await tryChromiumFallback(env, overrides, err);
784+
}
785+
throw err;
786+
}
699787

700788
const fallback = env.BROWSER_FALLBACK;
701789
const fallbackEnv = { ...env, BROWSER_CHANNEL: fallback };

src/index.js

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { loadAndBuildPrompt, listTools, parseToolArgs } from './commands/tool.js
1111
import { wsmAdapter } from './services/WsmAdapter.js';
1212
import { infraManager } from './browser/resolvers/InfraManager.js';
1313
import { CliError, ensureCliError, serializeCliError } from './utils/cliErrors.js';
14+
import { fatalExit } from './utils/fatalExit.js';
1415
import { createUpgrader } from './utils/upgrader.js';
1516
import { resolveBrowser } from './browser/index.js';
1617
import { checkRobots } from './utils/robotsCheck.js';
@@ -794,13 +795,12 @@ async function run() {
794795
await browserHandle.close();
795796
}
796797
} catch (error) {
798+
// Any failure inside run() (browser launch/acquire included) surfaces
799+
// here. fatalExit guarantees a non-empty stderr message + flushed stdio
800+
// BEFORE process.exit, so a launch failure can never be a silent 0-byte
801+
// exit on a piped/backpressured stream (T-0109).
797802
const cliError = ensureCliError(error, 'RUNTIME_ERROR');
798-
logger.error('Fatal error', {
799-
error: cliError.message,
800-
code: cliError.code
801-
});
802-
emitStructuredError(cliError);
803-
process.exit(1);
803+
await fatalExit(logger, cliError, { code: cliError.code, message: 'Fatal error' });
804804
}
805805
}
806806

@@ -813,10 +813,16 @@ let _isSea = false;
813813
try { _isSea = _require('node:sea').isSea(); } catch (_) {}
814814
const _isMain = _isSea || (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1]));
815815
if (_isMain) {
816-
run().catch(error => {
816+
run().catch(async error => {
817817
const cliError = ensureCliError(error, 'RUNTIME_ERROR');
818-
logger.error('Unhandled error in main', { error: cliError.message });
819-
emitStructuredError(cliError);
820-
process.exit(1);
818+
// Swallow any residual rejection: in production process.exit never
819+
// returns, but a test stub can make it throw — that must not become a
820+
// dangling unhandled rejection at the entry point.
821+
try {
822+
await fatalExit(logger, cliError, {
823+
code: cliError.code,
824+
message: 'Unhandled error in main',
825+
});
826+
} catch { /* process.exit stubbed to throw (tests) — already surfaced */ }
821827
});
822828
}

0 commit comments

Comments
 (0)