@broberg/lens-daemon 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/browser.ts +20 -7
- package/src/capture-session.ts +7 -3
- package/src/chrome-clones.ts +159 -0
- package/src/ensure-chromium.ts +120 -1
- package/src/flow.ts +9 -1
- package/src/http.ts +4 -0
- package/src/index.ts +2 -1
- package/src/profiles.ts +17 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
4
4
|
"description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/browser.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// context + page are always closed in a finally, even on capture error.
|
|
12
12
|
|
|
13
13
|
import { bindProfileChannel, claimProfile, killOwnBrowsersOn, profileDir, type ProfileChannel } from './profiles.ts';
|
|
14
|
+
import { sweepChromeClones, trackChromeLaunch } from './chrome-clones.ts';
|
|
15
|
+
import { launchWithRepair, type BrowserRepair } from './ensure-chromium.ts';
|
|
14
16
|
import {
|
|
15
17
|
chromium,
|
|
16
18
|
webkit,
|
|
@@ -80,7 +82,15 @@ const CHROMIUM_ARGS = [
|
|
|
80
82
|
|
|
81
83
|
async function launch(engine: LensEngine): Promise<Browser> {
|
|
82
84
|
const args = engine === 'chromium' ? CHROMIUM_ARGS : [];
|
|
83
|
-
|
|
85
|
+
// F010.1 — a deleted browser is installed and the launch retried, not a fleet-wide outage.
|
|
86
|
+
return launchWithRepair(engine, () => ENGINES[engine].launch({ headless: true, args }), (r) => lastRepairs.push(r));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** F010.1 — repairs made since the last read; a run reads (and clears) them into
|
|
90
|
+
* its report. Read-and-clear, so one repair is reported once. */
|
|
91
|
+
const lastRepairs: BrowserRepair[] = [];
|
|
92
|
+
export function takeBrowserRepairs(): BrowserRepair[] {
|
|
93
|
+
return lastRepairs.splice(0, lastRepairs.length);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
/** Get the warm browser for an engine, launching it on first use. Re-launches
|
|
@@ -353,7 +363,7 @@ async function launchWithExtension(
|
|
|
353
363
|
if (profile) bindProfileChannel(profile, EXTENSION_CHANNEL);
|
|
354
364
|
userDataDir = profile ? profileDir(profile) : await mkdtemp(join(tmpdir(), 'lens-ext-'));
|
|
355
365
|
try {
|
|
356
|
-
context = await chromium.launchPersistentContext(userDataDir, {
|
|
366
|
+
context = await launchWithRepair('chromium', () => chromium.launchPersistentContext(userDataDir, {
|
|
357
367
|
...contextOptions(opts),
|
|
358
368
|
headless: true,
|
|
359
369
|
channel: EXTENSION_CHANNEL,
|
|
@@ -364,7 +374,7 @@ async function launchWithExtension(
|
|
|
364
374
|
'--disable-dev-shm-usage',
|
|
365
375
|
'--no-sandbox',
|
|
366
376
|
],
|
|
367
|
-
});
|
|
377
|
+
}), (r) => lastRepairs.push(r));
|
|
368
378
|
} catch (err) {
|
|
369
379
|
lastLaunchError = err instanceof Error ? err.message.split('\n')[0]! : String(err);
|
|
370
380
|
if (!profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
@@ -421,12 +431,13 @@ function realUserAgent(channel: ProfileChannel): Promise<string> {
|
|
|
421
431
|
let ua = headlessAgents.get(channel);
|
|
422
432
|
if (!ua) {
|
|
423
433
|
ua = (async () => {
|
|
424
|
-
const b = await chromium.launch({ channel, headless: true });
|
|
434
|
+
const b = await trackChromeLaunch(channel, () => chromium.launch({ channel, headless: true }));
|
|
425
435
|
try {
|
|
426
436
|
const page = await b.newPage();
|
|
427
437
|
return (await page.evaluate(() => navigator.userAgent)).replace('HeadlessChrome/', 'Chrome/');
|
|
428
438
|
} finally {
|
|
429
439
|
await b.close().catch(() => {});
|
|
440
|
+
sweepChromeClones();
|
|
430
441
|
}
|
|
431
442
|
})();
|
|
432
443
|
ua.catch(() => headlessAgents.delete(channel));
|
|
@@ -440,14 +451,15 @@ async function launchRunBrowser(opts: WithPageOptions, b: RunBrowser): Promise<{
|
|
|
440
451
|
const userDataDir = b.profile ? profileDir(b.profile) : await mkdtemp(join(tmpdir(), 'lens-run-'));
|
|
441
452
|
const userAgent = opts.userAgent ?? (b.visible ? undefined : await realUserAgent(b.channel));
|
|
442
453
|
try {
|
|
443
|
-
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
454
|
+
const context = await trackChromeLaunch(b.channel, () => chromium.launchPersistentContext(userDataDir, {
|
|
444
455
|
...contextOptions({ ...opts, ...(userAgent ? { userAgent } : {}) }),
|
|
445
456
|
...runBrowserLaunchOptions(b),
|
|
446
457
|
timeout: EXTENSION_LAUNCH_TIMEOUT_MS,
|
|
447
|
-
});
|
|
458
|
+
}));
|
|
448
459
|
return { context, userDataDir };
|
|
449
460
|
} catch (err) {
|
|
450
|
-
killOwnBrowsersOn(userDataDir);
|
|
461
|
+
await killOwnBrowsersOn(userDataDir);
|
|
462
|
+
sweepChromeClones();
|
|
451
463
|
if (!b.profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
452
464
|
const msg = err instanceof Error ? err.message.split('\n')[0]! : String(err);
|
|
453
465
|
throw new Error(`browser "${b.channel}" did not start: ${msg}${b.channel === 'chrome' ? ' — is Google Chrome installed on the daemon host?' : ''}`);
|
|
@@ -529,6 +541,7 @@ export async function withPage<T>(
|
|
|
529
541
|
await withDeadline(context.close(), 5_000, 'browser context close').catch(() => {});
|
|
530
542
|
if (!b.profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
531
543
|
release();
|
|
544
|
+
sweepChromeClones();
|
|
532
545
|
}
|
|
533
546
|
}
|
|
534
547
|
|
package/src/capture-session.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// file. The endpoint is loopback-only (127.0.0.1) behind the daemon's auth.
|
|
20
20
|
|
|
21
21
|
import { bindProfileChannel, claimProfile, killOwnBrowsersOn, profileDir, type ProfileChannel } from './profiles.ts';
|
|
22
|
+
import { sweepChromeClones, trackChromeLaunch } from './chrome-clones.ts';
|
|
22
23
|
import { EXTENSION_CHANNEL } from './browser.ts';
|
|
23
24
|
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
|
24
25
|
import { z } from 'zod';
|
|
@@ -86,7 +87,7 @@ async function launchHeaded(): Promise<{ browser: Browser; appName: string }> {
|
|
|
86
87
|
// F240.10 — the macOS app NAME comes back with the browser. Playwright's
|
|
87
88
|
// Browser handle exposes no process, so the name is the only handle we have
|
|
88
89
|
// on the thing that has to come to the front — and it differs by channel.
|
|
89
|
-
return { browser: await chromium.launch({ ...opts, channel: 'chrome' }), appName: 'Google Chrome' };
|
|
90
|
+
return { browser: await trackChromeLaunch('chrome', () => chromium.launch({ ...opts, channel: 'chrome' })), appName: 'Google Chrome' };
|
|
90
91
|
} catch {
|
|
91
92
|
return { browser: await chromium.launch(opts), appName: 'Chromium' };
|
|
92
93
|
}
|
|
@@ -419,9 +420,10 @@ export async function startCaptureSession(input: {
|
|
|
419
420
|
const dir = profileDir(input.profile);
|
|
420
421
|
releaseProfile = claimProfile(input.profile);
|
|
421
422
|
try {
|
|
422
|
-
context = await chromium.launchPersistentContext(dir, profileLaunchOptions(channel));
|
|
423
|
+
context = await trackChromeLaunch(channel, () => chromium.launchPersistentContext(dir, profileLaunchOptions(channel)));
|
|
423
424
|
} catch (err) {
|
|
424
|
-
killOwnBrowsersOn(dir);
|
|
425
|
+
await killOwnBrowsersOn(dir);
|
|
426
|
+
sweepChromeClones();
|
|
425
427
|
releaseProfile();
|
|
426
428
|
throw err;
|
|
427
429
|
}
|
|
@@ -550,6 +552,7 @@ export async function finishCaptureSession(input: {
|
|
|
550
552
|
} finally {
|
|
551
553
|
reg.delete(input.sessionId);
|
|
552
554
|
await s.browser.close().catch(() => {});
|
|
555
|
+
sweepChromeClones();
|
|
553
556
|
}
|
|
554
557
|
}
|
|
555
558
|
|
|
@@ -572,6 +575,7 @@ export async function cancelCaptureSession(
|
|
|
572
575
|
clearTimeout(s.timer);
|
|
573
576
|
reg.delete(sessionId);
|
|
574
577
|
await s.browser.close().catch(() => {});
|
|
578
|
+
sweepChromeClones();
|
|
575
579
|
return { ok: true, status: 'cancelled' };
|
|
576
580
|
}
|
|
577
581
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// F010.7 — every start of Google Chrome.app makes a code-signing copy of the app in
|
|
2
|
+
// /var/folders/<…>/X/com.google.Chrome.code_sign_clone/code_sign_clone.XXXXXX. Chrome
|
|
3
|
+
// removes it on a normal exit; a killed Chrome leaves it (buddy, 25 Oct: 15 left, 10
|
|
4
|
+
// of them from Lens runs). The owner runs his own Chrome on the same Mac, its copy in
|
|
5
|
+
// the same folder, so Lens removes ONLY copies it caused.
|
|
6
|
+
//
|
|
7
|
+
// Measured 25 Oct: the copies are hard links of one another (one inode, link count 4),
|
|
8
|
+
// so `lsof +D <copy>` reports every copy "in use" while any Chrome runs, and a Chrome
|
|
9
|
+
// process never names its copy. The only link between a copy and a process is WHEN it
|
|
10
|
+
// appeared. So: a copy that appeared while Lens launched Chrome, while no other Chrome
|
|
11
|
+
// started, is ours — recorded with the pids of the Chrome we launched — and is removed
|
|
12
|
+
// once none of those pids is a running Chrome. A launch window in which another Chrome
|
|
13
|
+
// started records nothing: we cannot tell whose copy it is, so it is not ours to touch.
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const CLONE = /^code_sign_clone\.[A-Za-z0-9]+$/;
|
|
20
|
+
// The main process of an installed Chrome build (Chrome, Beta, Canary …): its own
|
|
21
|
+
// binary, and no --type= (every helper carries one).
|
|
22
|
+
const CHROME_MAIN = /\/Google Chrome[^/]*\.app\/Contents\/MacOS\/Google Chrome[^/]*(?: |$)/;
|
|
23
|
+
|
|
24
|
+
export interface ChromeProc { pid: number; ppid: number; args: string }
|
|
25
|
+
interface Probe { root: () => string | null; ledger: () => string; processes: () => ChromeProc[]; selfPid: () => number }
|
|
26
|
+
|
|
27
|
+
function darwinCloneRoot(): string | null {
|
|
28
|
+
if (process.platform !== 'darwin') return null;
|
|
29
|
+
try {
|
|
30
|
+
const temp = execFileSync('getconf', ['DARWIN_USER_TEMP_DIR'], { encoding: 'utf8' }).trim();
|
|
31
|
+
return join(dirname(temp.replace(/\/$/, '')), 'X', 'com.google.Chrome.code_sign_clone');
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function listProcesses(): ChromeProc[] {
|
|
38
|
+
const out = execFileSync('ps', ['-eo', 'pid=,ppid=,args='], { encoding: 'utf8' });
|
|
39
|
+
const procs: ChromeProc[] = [];
|
|
40
|
+
for (const line of out.split('\n')) {
|
|
41
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
42
|
+
if (m) procs.push({ pid: Number(m[1]), ppid: Number(m[2]), args: m[3]! });
|
|
43
|
+
}
|
|
44
|
+
return procs;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const defaultProbe: Probe = {
|
|
48
|
+
root: darwinCloneRoot,
|
|
49
|
+
ledger: () => process.env.LENS_CHROME_CLONE_LEDGER || join(homedir(), '.lens', 'chrome-clones.json'),
|
|
50
|
+
processes: listProcesses,
|
|
51
|
+
selfPid: () => process.pid,
|
|
52
|
+
};
|
|
53
|
+
let probe: Probe = defaultProbe;
|
|
54
|
+
|
|
55
|
+
/** Test seam: a fake clone folder, ledger and process table. */
|
|
56
|
+
export function setChromeCloneProbe(p: Partial<Probe> | null): void {
|
|
57
|
+
probe = p ? { ...defaultProbe, ...p } : defaultProbe;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Ledger { entries: Array<{ clone: string; pids: number[]; at: string }>; removed_total: number }
|
|
61
|
+
|
|
62
|
+
function readLedger(): Ledger {
|
|
63
|
+
try {
|
|
64
|
+
const l = JSON.parse(readFileSync(probe.ledger(), 'utf8')) as Ledger;
|
|
65
|
+
if (Array.isArray(l.entries) && typeof l.removed_total === 'number') return l;
|
|
66
|
+
} catch {
|
|
67
|
+
// no ledger yet
|
|
68
|
+
}
|
|
69
|
+
return { entries: [], removed_total: 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeLedger(l: Ledger): void {
|
|
73
|
+
const file = probe.ledger();
|
|
74
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
75
|
+
writeFileSync(file, JSON.stringify(l), { mode: 0o600 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clonesNow(root: string): Set<string> {
|
|
79
|
+
try {
|
|
80
|
+
return new Set(readdirSync(root).filter((n) => CLONE.test(n)));
|
|
81
|
+
} catch {
|
|
82
|
+
return new Set();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function chromeMains(): ChromeProc[] {
|
|
87
|
+
try {
|
|
88
|
+
return probe.processes().filter((p) => CHROME_MAIN.test(p.args) && !p.args.includes(' --type='));
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Run a launch of the installed Google Chrome and record the code-sign copies it
|
|
95
|
+
* made. Any other channel runs untouched (Playwright's Chromium makes no copy). */
|
|
96
|
+
export async function trackChromeLaunch<T>(channel: string | undefined, launch: () => Promise<T>): Promise<T> {
|
|
97
|
+
const root = channel === 'chrome' ? probe.root() : null;
|
|
98
|
+
if (!root) return launch();
|
|
99
|
+
const clonesBefore = clonesNow(root);
|
|
100
|
+
const pidsBefore = new Set(chromeMains().map((p) => p.pid));
|
|
101
|
+
try {
|
|
102
|
+
return await launch();
|
|
103
|
+
} finally {
|
|
104
|
+
// Recorded on failure too: a launch that timed out may still have started Chrome.
|
|
105
|
+
const added = [...clonesNow(root)].filter((n) => !clonesBefore.has(n));
|
|
106
|
+
const started = chromeMains().filter((p) => !pidsBefore.has(p.pid));
|
|
107
|
+
const self = probe.selfPid();
|
|
108
|
+
const ours = started.filter((p) => p.ppid === self).map((p) => p.pid);
|
|
109
|
+
const foreign = started.some((p) => p.ppid !== self);
|
|
110
|
+
if (added.length > 0 && !foreign) {
|
|
111
|
+
const l = readLedger();
|
|
112
|
+
for (const name of added) l.entries.push({ clone: join(root, name), pids: ours, at: new Date().toISOString() });
|
|
113
|
+
writeLedger(l);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Remove the recorded copies whose Chrome is gone; keep the rest. Returns how many
|
|
119
|
+
* were removed. Only ever a path in the ledger, and only a code_sign_clone.* entry
|
|
120
|
+
* directly inside the clone folder. */
|
|
121
|
+
export function sweepChromeClones(): number {
|
|
122
|
+
const root = probe.root();
|
|
123
|
+
if (!root) return 0;
|
|
124
|
+
const l = readLedger();
|
|
125
|
+
if (l.entries.length === 0) return 0;
|
|
126
|
+
const running = new Set(chromeMains().map((p) => p.pid));
|
|
127
|
+
let removed = 0;
|
|
128
|
+
l.entries = l.entries.filter((e) => {
|
|
129
|
+
if (dirname(e.clone) !== root || !CLONE.test(e.clone.slice(root.length + 1))) return false;
|
|
130
|
+
if (!existsSync(e.clone)) return false;
|
|
131
|
+
if (e.pids.some((pid) => running.has(pid))) return true;
|
|
132
|
+
try {
|
|
133
|
+
rmSync(e.clone, { recursive: true, force: true });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// Kept in the ledger, so the next sweep tries again; the reason is not hidden.
|
|
136
|
+
console.warn(`[lens] could not remove Chrome copy ${e.clone}: ${err instanceof Error ? err.message : String(err)}`);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
removed++;
|
|
140
|
+
return false;
|
|
141
|
+
});
|
|
142
|
+
l.removed_total += removed;
|
|
143
|
+
writeLedger(l);
|
|
144
|
+
lastRemoved += removed;
|
|
145
|
+
return removed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let lastRemoved = 0;
|
|
149
|
+
/** Copies removed since the last read; a run reads (and clears) it into its report. */
|
|
150
|
+
export function takeChromeClonesRemoved(): number {
|
|
151
|
+
const n = lastRemoved;
|
|
152
|
+
lastRemoved = 0;
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function chromeCloneStatus(): { ours_pending: number; removed_total: number } {
|
|
157
|
+
const l = readLedger();
|
|
158
|
+
return { ours_pending: l.entries.length, removed_total: l.removed_total };
|
|
159
|
+
}
|
package/src/ensure-chromium.ts
CHANGED
|
@@ -14,7 +14,8 @@ import { spawn } from 'node:child_process';
|
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
import { createRequire } from 'node:module';
|
|
16
16
|
import { dirname, join } from 'node:path';
|
|
17
|
-
import { chromium } from 'playwright';
|
|
17
|
+
import { chromium, firefox, webkit } from 'playwright';
|
|
18
|
+
import { sweepChromeClones } from './chrome-clones.ts';
|
|
18
19
|
|
|
19
20
|
export interface ChromiumExpectation {
|
|
20
21
|
installed: boolean;
|
|
@@ -81,6 +82,12 @@ export function ensureChromium(): Promise<{
|
|
|
81
82
|
error?: string;
|
|
82
83
|
alreadyInstalled?: boolean;
|
|
83
84
|
}> {
|
|
85
|
+
// F010.7 — the boot is also where Chrome copies left by a previous daemon go.
|
|
86
|
+
try {
|
|
87
|
+
sweepChromeClones();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.warn(`[lens] Chrome copy sweep failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
90
|
+
}
|
|
84
91
|
const exp = chromiumExpectation();
|
|
85
92
|
if (exp.installed) return Promise.resolve({ ok: true, alreadyInstalled: true });
|
|
86
93
|
if (inFlight) return inFlight;
|
|
@@ -116,3 +123,115 @@ export function ensureChromium(): Promise<{
|
|
|
116
123
|
|
|
117
124
|
return inFlight;
|
|
118
125
|
}
|
|
126
|
+
|
|
127
|
+
// ── F010.1 — repair at the moment of need, for every engine ────────────────────
|
|
128
|
+
//
|
|
129
|
+
// ensureChromium() runs once at daemon boot and installs the FULL Chromium only.
|
|
130
|
+
// On 25 Oct the browsers were deleted while the daemon was running, and the warm
|
|
131
|
+
// headless launch uses chromium-headless-shell, which boot never checked: every
|
|
132
|
+
// run fleet-wide failed until a hand reinstalled. Now a launch that fails because
|
|
133
|
+
// its binary is missing installs that engine's browsers (from the daemon's own
|
|
134
|
+
// pinned Playwright, same as boot) and is retried once.
|
|
135
|
+
|
|
136
|
+
export type BrowserEngine = 'chromium' | 'webkit' | 'firefox';
|
|
137
|
+
|
|
138
|
+
/** What `playwright install` must fetch for an engine to launch in every mode Lens uses. */
|
|
139
|
+
const INSTALL_NAMES: Record<BrowserEngine, string[]> = {
|
|
140
|
+
chromium: ['chromium', 'chromium-headless-shell', 'ffmpeg'],
|
|
141
|
+
webkit: ['webkit'],
|
|
142
|
+
firefox: ['firefox'],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export function isMissingBrowserError(err: unknown): boolean {
|
|
146
|
+
return /Executable doesn't exist/.test(err instanceof Error ? err.message : String(err));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type Installer = (names: string[]) => Promise<{ ok: boolean; error?: string }>;
|
|
150
|
+
|
|
151
|
+
const spawnInstaller: Installer = (names) =>
|
|
152
|
+
new Promise((resolve) => {
|
|
153
|
+
let cliPath: string;
|
|
154
|
+
try {
|
|
155
|
+
cliPath = resolvePlaywrightCli();
|
|
156
|
+
} catch (err) {
|
|
157
|
+
resolve({ ok: false, error: `cannot resolve playwright CLI: ${err instanceof Error ? err.message : String(err)}` });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
console.log(`[lens] browser missing — installing ${names.join(', ')} via ${cliPath} …`);
|
|
161
|
+
const child = spawn(process.execPath, [cliPath, 'install', ...names], { stdio: 'inherit', env: process.env });
|
|
162
|
+
child.on('error', (err) => resolve({ ok: false, error: err.message }));
|
|
163
|
+
child.on('exit', (code) => resolve(code === 0 ? { ok: true } : { ok: false, error: `playwright install exited ${code}` }));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
let installer: Installer = spawnInstaller;
|
|
167
|
+
/** Test seam: replace the installer (null restores the real one). */
|
|
168
|
+
export function setBrowserInstaller(fn: Installer | null): void {
|
|
169
|
+
installer = fn ?? spawnInstaller;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// One install per engine at a time: two runs that hit the gap together share it.
|
|
173
|
+
const engineInFlight = new Map<BrowserEngine, Promise<{ ok: boolean; error?: string }>>();
|
|
174
|
+
|
|
175
|
+
export function installBrowsers(engine: BrowserEngine): Promise<{ ok: boolean; error?: string }> {
|
|
176
|
+
let p = engineInFlight.get(engine);
|
|
177
|
+
if (!p) {
|
|
178
|
+
p = installer(INSTALL_NAMES[engine]).finally(() => engineInFlight.delete(engine));
|
|
179
|
+
engineInFlight.set(engine, p);
|
|
180
|
+
}
|
|
181
|
+
return p;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface BrowserRepair {
|
|
185
|
+
engine: BrowserEngine;
|
|
186
|
+
ms: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Run a launch; if it fails ONLY because the browser binary is missing, install
|
|
190
|
+
* that engine's browsers and retry once. Any other error propagates untouched. */
|
|
191
|
+
export async function launchWithRepair<T>(
|
|
192
|
+
engine: BrowserEngine,
|
|
193
|
+
launch: () => Promise<T>,
|
|
194
|
+
onRepaired?: (r: BrowserRepair) => void,
|
|
195
|
+
): Promise<T> {
|
|
196
|
+
try {
|
|
197
|
+
return await launch();
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (!isMissingBrowserError(err)) throw err;
|
|
200
|
+
const t0 = Date.now();
|
|
201
|
+
const inst = await installBrowsers(engine);
|
|
202
|
+
if (!inst.ok) {
|
|
203
|
+
throw new Error(`${engine} browser is missing and could not be installed: ${inst.error ?? 'unknown'} — original: ${(err as Error).message.split('\n')[0]}`);
|
|
204
|
+
}
|
|
205
|
+
const out = await launch();
|
|
206
|
+
onRepaired?.({ engine, ms: Date.now() - t0 });
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** For /health: does each engine's browser binary exist where this daemon looks?
|
|
212
|
+
* chromium is reported for both the full build and the headless shell, because
|
|
213
|
+
* the warm headless launch needs the shell (the 25 Oct gap boot never checked). */
|
|
214
|
+
export function browsersPresent(): Record<string, boolean> {
|
|
215
|
+
const has = (f: () => string) => {
|
|
216
|
+
try {
|
|
217
|
+
return existsSync(f());
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const full = (() => {
|
|
223
|
+
try {
|
|
224
|
+
return chromium.executablePath();
|
|
225
|
+
} catch {
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
229
|
+
const m = /^(.*[\\/])chromium-(\d+)[\\/]/.exec(full);
|
|
230
|
+
const shellDir = m ? join(m[1]!, `chromium_headless_shell-${m[2]}`) : '';
|
|
231
|
+
return {
|
|
232
|
+
chromium: has(() => full),
|
|
233
|
+
'chromium-headless-shell': shellDir ? existsSync(shellDir) : false,
|
|
234
|
+
webkit: has(() => webkit.executablePath()),
|
|
235
|
+
firefox: has(() => firefox.executablePath()),
|
|
236
|
+
};
|
|
237
|
+
}
|
package/src/flow.ts
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
type AssertOutcome,
|
|
24
24
|
type Target,
|
|
25
25
|
} from '@broberg/lens-engine';
|
|
26
|
-
import { withPage, type LensEngine, type RunBrowser } from './browser.ts';
|
|
26
|
+
import { takeBrowserRepairs, withPage, type LensEngine, type RunBrowser } from './browser.ts';
|
|
27
|
+
import { takeChromeClonesRemoved } from './chrome-clones.ts';
|
|
27
28
|
import { collectNetworkShapes, type NetworkShapesOptions, type NetworkShapesReport } from './network-shapes.ts';
|
|
28
29
|
import { deadline, defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
|
|
29
30
|
import { authPrepare, fetchMintJson, persistStorageState, persistTarget, type AuthConfig, type AuthEnv } from './auth/index.ts';
|
|
@@ -424,6 +425,10 @@ export interface FlowResult {
|
|
|
424
425
|
/** F009.131 — set when auth.persist was asked for: whether the renewed login was
|
|
425
426
|
* written back to statePath, and why not when it was not. Never a value. */
|
|
426
427
|
auth_persisted?: { ok: boolean; reason?: string };
|
|
428
|
+
/** F010.1 — browsers the daemon reinstalled because their binary was missing. */
|
|
429
|
+
browser_repaired?: Array<{ engine: string; ms: number }>;
|
|
430
|
+
/** F010.7 — Chrome code-sign copies Lens removed after this run's browser closed. */
|
|
431
|
+
chrome_clones_removed?: number;
|
|
427
432
|
/** F009.137 — mails this run consumed for a code, and how many were removed
|
|
428
433
|
* after. Present only when a step read mail. Never a code or a body. */
|
|
429
434
|
mail?: { consumed: number; cleaned: number; reason?: string };
|
|
@@ -3508,6 +3513,9 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3508
3513
|
reduced_motion: opts.reducedMotion ?? 'reduce',
|
|
3509
3514
|
...(authPersisted ? { auth_persisted: authPersisted } : {}),
|
|
3510
3515
|
...(mailReport ? { mail: mailReport } : {}),
|
|
3516
|
+
// F010.1 — a missing browser this run had to reinstall, said out loud.
|
|
3517
|
+
...((() => { const r = takeBrowserRepairs(); return r.length ? { browser_repaired: r } : {}; })()),
|
|
3518
|
+
...((() => { const n = takeChromeClonesRemoved(); return n ? { chrome_clones_removed: n } : {}; })()),
|
|
3511
3519
|
...(extensionInfo ? { extension: extensionInfo } : {}),
|
|
3512
3520
|
...(networkShapesReport ? { network_shapes: networkShapesReport } : {}),
|
|
3513
3521
|
downloads,
|
package/src/http.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// all hit these. Artifact bytes are served here too, so an <img> in the
|
|
6
6
|
// dashboard points straight at the daemon.
|
|
7
7
|
|
|
8
|
+
import { browsersPresent } from './ensure-chromium.ts';
|
|
9
|
+
import { chromeCloneStatus } from './chrome-clones.ts';
|
|
8
10
|
import { Hono } from 'hono';
|
|
9
11
|
import { setSandboxResolver } from './sandbox.ts';
|
|
10
12
|
import { registerDaemonRoutes } from './daemon-routes.ts';
|
|
@@ -79,6 +81,8 @@ export function createLensRouter(deps: LensDeps): Hono {
|
|
|
79
81
|
|
|
80
82
|
// F276.1/F276.4 — the upload queue: what it holds, and one drain pass.
|
|
81
83
|
r.get('/uploads', (c) => c.json({ ok: true, ...uploadStats(deps.db) }));
|
|
84
|
+
// F010.1 — does each engine's browser binary exist where this daemon looks?
|
|
85
|
+
r.get('/browsers', (c) => c.json({ ok: true, browsers: browsersPresent(), chrome_clones: chromeCloneStatus() }));
|
|
82
86
|
r.post('/uploads/drain', handle(async (body) => {
|
|
83
87
|
const b = (body ?? {}) as { batch?: number };
|
|
84
88
|
return drainUploads(deps.db, deps.storage, deps.cloud?.() ?? null, { batch: b.batch });
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,8 @@ export { durabilityOf, type EvidenceDurability } from './durability.ts';
|
|
|
16
16
|
export { setSandboxResolver, type SandboxResolver } from './sandbox.ts';
|
|
17
17
|
// Lifecycle the daemon owns: provision Chromium at boot, drain the evidence
|
|
18
18
|
// upload queue on its timer, close the warm browser on shutdown.
|
|
19
|
-
export { ensureChromium } from './ensure-chromium.ts';
|
|
19
|
+
export { ensureChromium, browsersPresent } from './ensure-chromium.ts';
|
|
20
|
+
export { sweepChromeClones, chromeCloneStatus } from './chrome-clones.ts';
|
|
20
21
|
export { drainUploads } from './artifact-uploads.ts';
|
|
21
22
|
export { closeBrowser } from './browser.ts';
|
|
22
23
|
|
package/src/profiles.ts
CHANGED
|
@@ -58,14 +58,17 @@ export function bindProfileChannel(name: string, channel: ProfileChannel): void
|
|
|
58
58
|
// started running (measured twice on 25 Oct: a headless Chrome stayed up holding the
|
|
59
59
|
// profile, so every retry hung on the lock it left). Kill only processes this
|
|
60
60
|
// daemon started on this exact user-data dir.
|
|
61
|
-
|
|
61
|
+
// F010.7 — SIGTERM first: a Chrome that exits on its own removes its code-sign copy
|
|
62
|
+
// of the app; a SIGKILLed one leaves it behind. SIGKILL only after graceMs.
|
|
63
|
+
export async function killOwnBrowsersOn(dir: string, graceMs = 5_000): Promise<number> {
|
|
62
64
|
let out = '';
|
|
63
65
|
try {
|
|
64
66
|
out = execFileSync('ps', ['-eo', 'pid=,ppid=,args='], { encoding: 'utf8' });
|
|
65
67
|
} catch {
|
|
66
68
|
return 0;
|
|
67
69
|
}
|
|
68
|
-
|
|
70
|
+
const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
71
|
+
const targets: number[] = [];
|
|
69
72
|
for (const line of out.split('\n')) {
|
|
70
73
|
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
71
74
|
// The EXACT dir: a plain substring test matched profile "a" inside "a-b" (measured).
|
|
@@ -73,11 +76,20 @@ export function killOwnBrowsersOn(dir: string): number {
|
|
|
73
76
|
const at = m ? (` ${m[3]!} `).indexOf(` ${flag} `) : -1;
|
|
74
77
|
if (!m || Number(m[2]) !== process.pid || at < 0) continue;
|
|
75
78
|
try {
|
|
76
|
-
process.kill(Number(m[1]), '
|
|
77
|
-
|
|
79
|
+
process.kill(Number(m[1]), 'SIGTERM');
|
|
80
|
+
targets.push(Number(m[1]));
|
|
78
81
|
} catch {
|
|
79
82
|
// already gone
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
|
-
|
|
85
|
+
const deadline = Date.now() + graceMs;
|
|
86
|
+
while (targets.some(alive) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100));
|
|
87
|
+
for (const pid of targets.filter(alive)) {
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 'SIGKILL');
|
|
90
|
+
} catch {
|
|
91
|
+
// exited between the check and the kill
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return targets.length;
|
|
83
95
|
}
|