@broberg/lens-daemon 0.1.38 → 0.1.40

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/lens-daemon",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
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",
@@ -15,8 +15,8 @@
15
15
  "sharp": "^0.35.5",
16
16
  "turndown": "^7.2.4",
17
17
  "zod": "^3.23.8",
18
- "@broberg/lens-svg": "0.1.1",
19
- "@broberg/lens-engine": "0.14.1"
18
+ "@broberg/lens-engine": "0.14.1",
19
+ "@broberg/lens-svg": "0.1.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
package/src/browser.ts CHANGED
@@ -11,7 +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 { launchWithRepair, type BrowserRepair } from './ensure-chromium.ts';
14
+ import { sweepChromeClones, trackChromeLaunch } from './chrome-clones.ts';
15
+ import { launchWithRepair, repairInFlight, type BrowserRepair } from './ensure-chromium.ts';
15
16
  import {
16
17
  chromium,
17
18
  webkit,
@@ -118,6 +119,20 @@ export async function getBrowser(engine: LensEngine = 'chromium'): Promise<Brows
118
119
  // deadline; on timeout, discard the wedged browser and retry once fresh.
119
120
  const WEDGE_TIMEOUT_MS = 20_000;
120
121
 
122
+ /** F010.1 — the wedge deadline on a warm launch, except while that engine's
123
+ * browser is being reinstalled: then the repair is waited for (it has its own
124
+ * bound) and the deadline applies again after it. Exported for test. */
125
+ export async function launchDeadline<T>(p: Promise<T>, engine: LensEngine, ms: number): Promise<T> {
126
+ try {
127
+ return await withDeadline(p, ms, 'browser launch');
128
+ } catch (err) {
129
+ const repair = repairInFlight(engine);
130
+ if (!repair || !(err instanceof Error && err.message.includes('lens browser wedged'))) throw err;
131
+ await repair;
132
+ return withDeadline(p, ms, 'browser launch');
133
+ }
134
+ }
135
+
121
136
  function withDeadline<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
122
137
  return new Promise<T>((resolve, reject) => {
123
138
  const t = setTimeout(
@@ -430,12 +445,13 @@ function realUserAgent(channel: ProfileChannel): Promise<string> {
430
445
  let ua = headlessAgents.get(channel);
431
446
  if (!ua) {
432
447
  ua = (async () => {
433
- const b = await chromium.launch({ channel, headless: true });
448
+ const b = await trackChromeLaunch(channel, () => chromium.launch({ channel, headless: true }));
434
449
  try {
435
450
  const page = await b.newPage();
436
451
  return (await page.evaluate(() => navigator.userAgent)).replace('HeadlessChrome/', 'Chrome/');
437
452
  } finally {
438
453
  await b.close().catch(() => {});
454
+ sweepChromeClones();
439
455
  }
440
456
  })();
441
457
  ua.catch(() => headlessAgents.delete(channel));
@@ -449,14 +465,15 @@ async function launchRunBrowser(opts: WithPageOptions, b: RunBrowser): Promise<{
449
465
  const userDataDir = b.profile ? profileDir(b.profile) : await mkdtemp(join(tmpdir(), 'lens-run-'));
450
466
  const userAgent = opts.userAgent ?? (b.visible ? undefined : await realUserAgent(b.channel));
451
467
  try {
452
- const context = await chromium.launchPersistentContext(userDataDir, {
468
+ const context = await trackChromeLaunch(b.channel, () => chromium.launchPersistentContext(userDataDir, {
453
469
  ...contextOptions({ ...opts, ...(userAgent ? { userAgent } : {}) }),
454
470
  ...runBrowserLaunchOptions(b),
455
471
  timeout: EXTENSION_LAUNCH_TIMEOUT_MS,
456
- });
472
+ }));
457
473
  return { context, userDataDir };
458
474
  } catch (err) {
459
- killOwnBrowsersOn(userDataDir);
475
+ await killOwnBrowsersOn(userDataDir);
476
+ sweepChromeClones();
460
477
  if (!b.profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
461
478
  const msg = err instanceof Error ? err.message.split('\n')[0]! : String(err);
462
479
  throw new Error(`browser "${b.channel}" did not start: ${msg}${b.channel === 'chrome' ? ' — is Google Chrome installed on the daemon host?' : ''}`);
@@ -538,11 +555,12 @@ export async function withPage<T>(
538
555
  await withDeadline(context.close(), 5_000, 'browser context close').catch(() => {});
539
556
  if (!b.profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
540
557
  release();
558
+ sweepChromeClones();
541
559
  }
542
560
  }
543
561
 
544
562
  const makeContext = async (): Promise<BrowserContext> => {
545
- const browser = await withDeadline(getBrowser(engine), WEDGE_TIMEOUT_MS, 'browser launch');
563
+ const browser = await launchDeadline(getBrowser(engine), engine, WEDGE_TIMEOUT_MS);
546
564
  return withDeadline(
547
565
  browser.newContext(contextOptions(opts)),
548
566
  WEDGE_TIMEOUT_MS,
@@ -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
+ }
@@ -15,6 +15,7 @@ import { existsSync } from 'node:fs';
15
15
  import { createRequire } from 'node:module';
16
16
  import { dirname, join } from 'node:path';
17
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;
@@ -141,6 +148,8 @@ export function isMissingBrowserError(err: unknown): boolean {
141
148
 
142
149
  type Installer = (names: string[]) => Promise<{ ok: boolean; error?: string }>;
143
150
 
151
+ const INSTALL_TIMEOUT_MS = 10 * 60_000;
152
+
144
153
  const spawnInstaller: Installer = (names) =>
145
154
  new Promise((resolve) => {
146
155
  let cliPath: string;
@@ -152,8 +161,13 @@ const spawnInstaller: Installer = (names) =>
152
161
  }
153
162
  console.log(`[lens] browser missing — installing ${names.join(', ')} via ${cliPath} …`);
154
163
  const child = spawn(process.execPath, [cliPath, 'install', ...names], { stdio: 'inherit', env: process.env });
155
- child.on('error', (err) => resolve({ ok: false, error: err.message }));
156
- child.on('exit', (code) => resolve(code === 0 ? { ok: true } : { ok: false, error: `playwright install exited ${code}` }));
164
+ // A download that stalls must not hold every run waiting on it forever.
165
+ const timer = setTimeout(() => {
166
+ child.kill('SIGKILL');
167
+ resolve({ ok: false, error: `playwright install did not finish within ${INSTALL_TIMEOUT_MS / 60_000} min` });
168
+ }, INSTALL_TIMEOUT_MS);
169
+ child.on('error', (err) => { clearTimeout(timer); resolve({ ok: false, error: err.message }); });
170
+ child.on('exit', (code) => { clearTimeout(timer); resolve(code === 0 ? { ok: true } : { ok: false, error: `playwright install exited ${code}` }); });
157
171
  });
158
172
 
159
173
  let installer: Installer = spawnInstaller;
@@ -191,16 +205,31 @@ export async function launchWithRepair<T>(
191
205
  } catch (err) {
192
206
  if (!isMissingBrowserError(err)) throw err;
193
207
  const t0 = Date.now();
194
- const inst = await installBrowsers(engine);
195
- if (!inst.ok) {
196
- throw new Error(`${engine} browser is missing and could not be installed: ${inst.error ?? 'unknown'} — original: ${(err as Error).message.split('\n')[0]}`);
197
- }
198
- const out = await launch();
199
- onRepaired?.({ engine, ms: Date.now() - t0 });
200
- return out;
208
+ const repair = (async () => {
209
+ const inst = await installBrowsers(engine);
210
+ if (!inst.ok) {
211
+ throw new Error(`${engine} browser is missing and could not be installed: ${inst.error ?? 'unknown'} — original: ${(err as Error).message.split('\n')[0]}`);
212
+ }
213
+ const out = await launch();
214
+ onRepaired?.({ engine, ms: Date.now() - t0 });
215
+ return out;
216
+ })();
217
+ const settled = repair.then(() => {}, () => {});
218
+ repairsInFlight.set(engine, settled);
219
+ void settled.then(() => { if (repairsInFlight.get(engine) === settled) repairsInFlight.delete(engine); });
220
+ return repair;
201
221
  }
202
222
  }
203
223
 
224
+ // F010.1 — measured live 25 Oct: the warm launch sits under a 20 s "wedged"
225
+ // deadline, and a firefox download takes longer, so the run failed while the
226
+ // repair was still installing. A caller's deadline asks this first: a repair in
227
+ // flight (install, bounded by INSTALL_TIMEOUT_MS, then one relaunch) is waited for.
228
+ const repairsInFlight = new Map<BrowserEngine, Promise<void>>();
229
+ export function repairInFlight(engine: BrowserEngine): Promise<void> | undefined {
230
+ return repairsInFlight.get(engine);
231
+ }
232
+
204
233
  /** For /health: does each engine's browser binary exist where this daemon looks?
205
234
  * chromium is reported for both the full build and the headless shell, because
206
235
  * the warm headless launch needs the shell (the 25 Oct gap boot never checked). */
package/src/flow.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  type Target,
25
25
  } from '@broberg/lens-engine';
26
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';
@@ -426,6 +427,8 @@ export interface FlowResult {
426
427
  auth_persisted?: { ok: boolean; reason?: string };
427
428
  /** F010.1 — browsers the daemon reinstalled because their binary was missing. */
428
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;
429
432
  /** F009.137 — mails this run consumed for a code, and how many were removed
430
433
  * after. Present only when a step read mail. Never a code or a body. */
431
434
  mail?: { consumed: number; cleaned: number; reason?: string };
@@ -3512,6 +3515,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3512
3515
  ...(mailReport ? { mail: mailReport } : {}),
3513
3516
  // F010.1 — a missing browser this run had to reinstall, said out loud.
3514
3517
  ...((() => { const r = takeBrowserRepairs(); return r.length ? { browser_repaired: r } : {}; })()),
3518
+ ...((() => { const n = takeChromeClonesRemoved(); return n ? { chrome_clones_removed: n } : {}; })()),
3515
3519
  ...(extensionInfo ? { extension: extensionInfo } : {}),
3516
3520
  ...(networkShapesReport ? { network_shapes: networkShapesReport } : {}),
3517
3521
  downloads,
package/src/http.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // dashboard points straight at the daemon.
7
7
 
8
8
  import { browsersPresent } from './ensure-chromium.ts';
9
+ import { chromeCloneStatus } from './chrome-clones.ts';
9
10
  import { Hono } from 'hono';
10
11
  import { setSandboxResolver } from './sandbox.ts';
11
12
  import { registerDaemonRoutes } from './daemon-routes.ts';
@@ -81,7 +82,7 @@ export function createLensRouter(deps: LensDeps): Hono {
81
82
  // F276.1/F276.4 — the upload queue: what it holds, and one drain pass.
82
83
  r.get('/uploads', (c) => c.json({ ok: true, ...uploadStats(deps.db) }));
83
84
  // F010.1 — does each engine's browser binary exist where this daemon looks?
84
- r.get('/browsers', (c) => c.json({ ok: true, browsers: browsersPresent() }));
85
+ r.get('/browsers', (c) => c.json({ ok: true, browsers: browsersPresent(), chrome_clones: chromeCloneStatus() }));
85
86
  r.post('/uploads/drain', handle(async (body) => {
86
87
  const b = (body ?? {}) as { batch?: number };
87
88
  return drainUploads(deps.db, deps.storage, deps.cloud?.() ?? null, { batch: b.batch });
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ 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
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
- export function killOwnBrowsersOn(dir: string): number {
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
- let killed = 0;
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]), 'SIGKILL');
77
- killed++;
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
- return killed;
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
  }