@ultimat3/scraping 30.8.0 → 31.0.0

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.
Files changed (47) hide show
  1. package/README.md +73 -8
  2. package/package.json +9 -6
  3. package/src/actionability.ts +2 -2
  4. package/src/adapter.ts +22 -0
  5. package/src/auth.ts +4 -5
  6. package/src/browser-dial.ts +55 -0
  7. package/src/cdp-arm.ts +81 -22
  8. package/src/cdp-connection.ts +290 -0
  9. package/src/cdp-error-codes.ts +28 -0
  10. package/src/cdp-errors.ts +179 -0
  11. package/src/cdp-launch-attempt.ts +234 -0
  12. package/src/cdp-launch-candidates.ts +62 -0
  13. package/src/cdp-launch-probe.ts +75 -0
  14. package/src/cdp-launch-profile.ts +106 -0
  15. package/src/cdp-launch-reap.ts +68 -0
  16. package/src/cdp-launch-wire.ts +171 -0
  17. package/src/cdp-launch.ts +216 -0
  18. package/src/cdp-pipe.ts +77 -0
  19. package/src/cdp-wire-watch.ts +113 -0
  20. package/src/cdp.ts +46 -0
  21. package/src/clock.ts +4 -111
  22. package/src/driver-cdp.ts +3 -0
  23. package/src/driver-fake.ts +4 -5
  24. package/src/driver.ts +16 -11
  25. package/src/error-throws-pdf.ts +45 -0
  26. package/src/error-throws-session.ts +1 -1
  27. package/src/error-throws.ts +4 -4
  28. package/src/errors.ts +17 -1
  29. package/src/event-prompt.ts +10 -4
  30. package/src/events.ts +2 -3
  31. package/src/html-target.ts +2 -2
  32. package/src/http-recorded.ts +2 -2
  33. package/src/http.ts +2 -2
  34. package/src/index.ts +21 -75
  35. package/src/launch-args.ts +10 -1
  36. package/src/page-over-target.ts +2 -3
  37. package/src/pdf-action.ts +116 -0
  38. package/src/pdf-cdp-params.ts +65 -0
  39. package/src/pdf-fence.ts +86 -0
  40. package/src/pdf-render.ts +241 -0
  41. package/src/pdf-slots.ts +68 -0
  42. package/src/rate.ts +2 -2
  43. package/src/scrape-run.ts +3 -4
  44. package/src/scrape.ts +5 -5
  45. package/src/target.ts +1 -1
  46. package/src/usage.ts +2 -2
  47. package/src/watchdog.ts +3 -3
@@ -0,0 +1,241 @@
1
+ // One page printed to PDF over raw CDP: open a target, fence its requests to the route's origin,
2
+ // navigate, wait for the load event and the fonts, refuse an error page, `Page.printToPDF`. The
3
+ // browser is `cdp-launch.ts`'s — one launch per render, at most `maxConcurrent` alive, killed when
4
+ // the request is aborted, closed in a `finally` — and injectable, so `bun test` needs no Chrome.
5
+
6
+ import type { SleepClock } from '@ultimat3/core';
7
+ import { ambientClock, finiteCount, stringField } from '@ultimat3/core';
8
+ import type { CdpConnection } from './cdp-connection';
9
+ import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
10
+ import type { FoundChromeOptions } from './cdp-launch';
11
+ import { LAUNCH_TIMEOUT_MS, launchFoundChrome } from './cdp-launch';
12
+ import { pdfRouteFailed } from './error-throws-pdf';
13
+ import { printToPdfParams } from './pdf-cdp-params';
14
+ import { raisePdfFence } from './pdf-fence';
15
+ import type { PdfLayout } from './pdf-request';
16
+ import { pdfSlots } from './pdf-slots';
17
+
18
+ /** One render's every deadline: the launch's first answer, each CDP call, the load event. */
19
+ export const DEFAULT_PDF_TIMEOUT_MS = 30_000;
20
+
21
+ /**
22
+ * Sequential deadlines one render spends after the launch: create, attach, `Page.enable`, the two
23
+ * header calls, the load, the settled read, the print, the close — and one spare.
24
+ */
25
+ export const PDF_RENDER_DEADLINES = 10;
26
+
27
+ /** The launched browser's OS-level bound: a cold start, then every deadline a render spends. */
28
+ export const pdfBrowserLifetimeMs = (timeoutMs: number): number =>
29
+ Math.max(timeoutMs, LAUNCH_TIMEOUT_MS) + PDF_RENDER_DEADLINES * timeoutMs;
30
+
31
+ /**
32
+ * Live print browsers per renderer, by default. Each Chrome holds 150-300 MB; two keeps a burst of
33
+ * prints (or a retry storm) from taking the web role's memory with it, and queues the rest.
34
+ */
35
+ export const DEFAULT_PDF_MAX_CONCURRENT = 2;
36
+
37
+ /**
38
+ * How a production render launches. The SANDBOX STAYS ON: a print renders the app's own pages, and
39
+ * those carry user content — the sandbox is what stands between a renderer exploit and the web
40
+ * role. The test launcher's `--no-sandbox` is for CI runners; the framework's runtime image is
41
+ * distroless and ships no Chrome, so the app's own image supplies it and runs it as non-root, where
42
+ * Chrome's namespace sandbox needs unprivileged user namespaces (or its setuid helper). A container
43
+ * that cannot grant them fails the launch with `X_CDP_LAUNCH_FAILED` quoting Chrome's "No usable
44
+ * sandbox"; that operator opts out in one visible line, `chromePdfRenderer({ sandbox: false })`.
45
+ */
46
+ export const pdfLaunchOptions = (timeoutMs: number, sandbox = true): FoundChromeOptions => ({
47
+ lifetimeMs: pdfBrowserLifetimeMs(timeoutMs),
48
+ sandbox,
49
+ });
50
+
51
+ export interface PdfRenderRequest {
52
+ /** Absolute. `pdf()` builds it from the app's own origin and a route path, never a caller's URL. */
53
+ readonly url: string;
54
+ readonly layout: PdfLayout;
55
+ /**
56
+ * Added to the requests the page makes to the ROUTE'S OWN ORIGIN — how a private route learns
57
+ * who is printing it. Never sent anywhere else: every other origin is blocked (`pdf-fence.ts`).
58
+ */
59
+ readonly headers?: Readonly<Record<string, string>> | undefined;
60
+ /** The request's cancellation: aborted, a queued render leaves the queue, a running one its browser. */
61
+ readonly signal?: AbortSignal | undefined;
62
+ }
63
+
64
+ /** The seam `pdf()` prints through. `chromePdfRenderer()` is the one this package ships. */
65
+ export interface PdfRenderer {
66
+ render(request: PdfRenderRequest): Promise<Uint8Array>;
67
+ }
68
+
69
+ /** A browser one render owns: its connection, and the close that ends it. `LaunchedBrowser` fits. */
70
+ export interface PdfBrowser {
71
+ readonly connection: CdpConnection;
72
+ close(): Promise<void>;
73
+ }
74
+
75
+ export interface ChromePdfRendererOptions {
76
+ /** Defaults to `launchFoundChrome` with `pdfLaunchOptions`: `CHROME_PATH`, then the known paths. */
77
+ readonly launch?: (() => Promise<PdfBrowser>) | undefined;
78
+ readonly timeoutMs?: number | undefined;
79
+ /** Browsers alive at once. Default `DEFAULT_PDF_MAX_CONCURRENT`. */
80
+ readonly maxConcurrent?: number | undefined;
81
+ /** How long a render waits for a slot before `X_SCRAPE_PDF_BUSY`. Default `timeoutMs`. */
82
+ readonly queueTimeoutMs?: number | undefined;
83
+ /** What the queue waits on. Default `ambientClock()`, read per wait. */
84
+ readonly clock?: SleepClock | undefined;
85
+ /** Chrome's sandbox for the default launch — on unless the container cannot grant it. */
86
+ readonly sandbox?: boolean | undefined;
87
+ }
88
+
89
+ /**
90
+ * Read in the page once it loaded: the fonts settled (a PDF printed before a webfont arrives prints
91
+ * the fallback face), then the document's own HTTP status — `0` where the browser cannot say.
92
+ */
93
+ const SETTLED_STATUS = `(async () => {
94
+ await document.fonts.ready;
95
+ const entry = performance.getEntriesByType('navigation')[0];
96
+ return entry && typeof entry.responseStatus === 'number' ? entry.responseStatus : 0;
97
+ })()`;
98
+
99
+ const required = (value: string | undefined, method: string, field: string): string => {
100
+ if (value === undefined || value === '') {
101
+ throw new CdpCallFailedError({ method, detail: `the browser answered no ${field}` });
102
+ }
103
+ return value;
104
+ };
105
+
106
+ /** Navigate and wait for `load` — the event, not the reply, which Chrome drops on a process swap. */
107
+ async function navigate(
108
+ connection: CdpConnection,
109
+ sessionId: string,
110
+ url: string,
111
+ timeoutMs: number,
112
+ ): Promise<void> {
113
+ const loaded = connection.once('Page.loadEventFired', sessionId, timeoutMs);
114
+ const refused = connection.send('Page.navigate', { url }, sessionId).then(
115
+ (answer) => {
116
+ const failed = answer.result?.['errorText'];
117
+ return typeof failed === 'string' && failed !== '' ? failed : undefined;
118
+ },
119
+ () => undefined,
120
+ );
121
+ const failed = await Promise.race([loaded.then(() => undefined), refused]);
122
+ if (failed !== undefined) {
123
+ throw new CdpCallFailedError({ method: `Page.navigate to ${url}`, detail: failed });
124
+ }
125
+ if (!(await loaded)) throw new CdpTimeoutError({ method: 'Page.loadEventFired', timeoutMs });
126
+ }
127
+
128
+ async function printOnce(
129
+ connection: CdpConnection,
130
+ request: PdfRenderRequest,
131
+ timeoutMs: number,
132
+ ): Promise<Uint8Array> {
133
+ const created = await connection.send('Target.createTarget', { url: 'about:blank' });
134
+ const targetId = required(
135
+ stringField(created.result, 'targetId'),
136
+ 'Target.createTarget',
137
+ 'targetId',
138
+ );
139
+ try {
140
+ const attached = await connection.send('Target.attachToTarget', { targetId, flatten: true });
141
+ const sessionId = required(
142
+ stringField(attached.result, 'sessionId'),
143
+ 'Target.attachToTarget',
144
+ 'sessionId',
145
+ );
146
+ await connection.send('Page.enable', {}, sessionId);
147
+ await raisePdfFence({
148
+ connection,
149
+ sessionId,
150
+ origin: new URL(request.url).origin,
151
+ headers: request.headers,
152
+ });
153
+ await navigate(connection, sessionId, request.url, timeoutMs);
154
+ const settled = await connection.send(
155
+ 'Runtime.evaluate',
156
+ { expression: SETTLED_STATUS, awaitPromise: true, returnByValue: true },
157
+ sessionId,
158
+ );
159
+ const inner = settled.result?.['result'];
160
+ const status =
161
+ typeof inner === 'object' && inner !== null && 'value' in inner ? inner.value : 0;
162
+ if (typeof status === 'number' && status >= 400) throw pdfRouteFailed(request.url, status);
163
+ const printed = await connection.send(
164
+ 'Page.printToPDF',
165
+ printToPdfParams(request.layout),
166
+ sessionId,
167
+ );
168
+ return Uint8Array.fromBase64(
169
+ required(stringField(printed.result, 'data'), 'Page.printToPDF', 'data'),
170
+ );
171
+ } finally {
172
+ await connection.send('Target.closeTarget', { targetId }).catch(() => undefined);
173
+ }
174
+ }
175
+
176
+ /** One render on a browser this call owns: killed the moment the request is aborted. */
177
+ async function renderOn(
178
+ browser: PdfBrowser,
179
+ request: PdfRenderRequest,
180
+ timeoutMs: number,
181
+ ): Promise<Uint8Array> {
182
+ const { signal } = request;
183
+ const kill = (): void => {
184
+ void browser.close();
185
+ };
186
+ signal?.addEventListener('abort', kill, { once: true });
187
+ try {
188
+ signal?.throwIfAborted();
189
+ return await printOnce(browser.connection, request, timeoutMs);
190
+ } catch (error) {
191
+ // A call cut off by the kill reports the dead socket; the reason the request was dropped is
192
+ // the truth, and is what the caller's own catch expects.
193
+ throw signal?.aborted === true ? signal.reason : error;
194
+ } finally {
195
+ signal?.removeEventListener('abort', kill);
196
+ await browser.close();
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Prints through a Chrome launched for the one render and closed after it, success or not: no
202
+ * browser outlives a request, and nothing is shared between two tenants' documents. At most
203
+ * `maxConcurrent` are alive; the rest queue, bounded. A launch costs a second or two; a pool is the
204
+ * measured next step, not a guess (axiom 9).
205
+ */
206
+ export function chromePdfRenderer(options: ChromePdfRendererOptions = {}): PdfRenderer {
207
+ const timeoutMs = finiteCount(
208
+ 'chromePdfRenderer',
209
+ 'timeoutMs',
210
+ options.timeoutMs ?? DEFAULT_PDF_TIMEOUT_MS,
211
+ 1,
212
+ );
213
+ const slots = pdfSlots({
214
+ maxConcurrent: finiteCount(
215
+ 'chromePdfRenderer',
216
+ 'maxConcurrent',
217
+ options.maxConcurrent ?? DEFAULT_PDF_MAX_CONCURRENT,
218
+ 1,
219
+ ),
220
+ queueTimeoutMs: finiteCount(
221
+ 'chromePdfRenderer',
222
+ 'queueTimeoutMs',
223
+ options.queueTimeoutMs ?? timeoutMs,
224
+ 0,
225
+ ),
226
+ clock: () => options.clock ?? ambientClock(),
227
+ });
228
+ const launch =
229
+ options.launch ??
230
+ (() => launchFoundChrome(process.env, timeoutMs, pdfLaunchOptions(timeoutMs, options.sandbox)));
231
+ return {
232
+ async render(request) {
233
+ const release = await slots.acquire(request.signal);
234
+ try {
235
+ return await renderOn(await launch(), request, timeoutMs);
236
+ } finally {
237
+ release();
238
+ }
239
+ },
240
+ };
241
+ }
@@ -0,0 +1,68 @@
1
+ // The bound on live print browsers: a counting semaphore, first come first served. A queued render
2
+ // waits on the CLOCK (never a timer of its own — `clock-discipline.test.ts`) for a bounded time and
3
+ // then refuses with a code; an aborted request leaves the queue at once and takes no slot.
4
+
5
+ import type { SleepClock } from '@ultimat3/core';
6
+ import { pdfBusy } from './error-throws-pdf';
7
+
8
+ export interface PdfSlotsOptions {
9
+ readonly maxConcurrent: number;
10
+ readonly queueTimeoutMs: number;
11
+ /** Read per wait, so an `installClock` made after the renderer still governs it. */
12
+ readonly clock: () => SleepClock;
13
+ }
14
+
15
+ export interface PdfSlots {
16
+ /** Answers the release — idempotent — once a slot is this caller's. */
17
+ acquire(signal?: AbortSignal): Promise<() => void>;
18
+ }
19
+
20
+ interface Waiter {
21
+ readonly grant: () => void;
22
+ }
23
+
24
+ export function pdfSlots(options: PdfSlotsOptions): PdfSlots {
25
+ let busy = 0;
26
+ const queue: Waiter[] = [];
27
+ const releaser = (): (() => void) => {
28
+ let released = false;
29
+ return () => {
30
+ if (released) return;
31
+ released = true;
32
+ const next = queue.shift();
33
+ // The slot passes straight to the next waiter: `busy` never dips, so no newcomer jumps it.
34
+ if (next === undefined) busy -= 1;
35
+ else next.grant();
36
+ };
37
+ };
38
+ return {
39
+ async acquire(signal) {
40
+ signal?.throwIfAborted();
41
+ if (busy < options.maxConcurrent) {
42
+ busy += 1;
43
+ return releaser();
44
+ }
45
+ const granted = new AbortController();
46
+ const waiter: Waiter = { grant: () => granted.abort() };
47
+ queue.push(waiter);
48
+ const leave = (): void => {
49
+ const at = queue.indexOf(waiter);
50
+ if (at !== -1) queue.splice(at, 1);
51
+ };
52
+ try {
53
+ const wake =
54
+ signal === undefined ? granted.signal : AbortSignal.any([granted.signal, signal]);
55
+ await options.clock().sleep(options.queueTimeoutMs, wake);
56
+ } catch (error) {
57
+ // Woken: either the slot is ours, or the caller gave up — the request's reason, verbatim.
58
+ if (granted.signal.aborted) return releaser();
59
+ leave();
60
+ throw signal?.aborted === true ? signal.reason : error;
61
+ }
62
+ // Slept the whole deadline. A grant racing the wake-up still owns the slot: hand it back.
63
+ if (granted.signal.aborted) return releaser();
64
+ leave();
65
+ throw pdfBusy(options);
66
+ },
67
+ };
68
+ }
package/src/rate.ts CHANGED
@@ -2,14 +2,14 @@
2
2
  // scraper that looks like a denial of service to the site it depends on, and the ban is charged
3
3
  // to whoever owns the IP — usually the whole fleet, not the one run.
4
4
 
5
- import type { ScrapeClock } from './clock';
5
+ import type { SleepClock } from '@ultimat3/core';
6
6
 
7
7
  /** Deliberately slow. Raising it is a decision; there is no way to turn pacing off. */
8
8
  export const DEFAULT_NAVIGATION_RATE = 1;
9
9
 
10
10
  export type Pacer = (signal?: AbortSignal) => Promise<void>;
11
11
 
12
- export function scrapePacer(rate: number, clock: ScrapeClock): Pacer {
12
+ export function scrapePacer(rate: number, clock: SleepClock): Pacer {
13
13
  const intervalMs = 1_000 / rate;
14
14
  let nextAt = 0;
15
15
  return async (signal?: AbortSignal): Promise<void> => {
package/src/scrape-run.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // A step record saying "logged in" would be a checkpoint asserting something about a session that
10
10
  // may have expired an hour ago.
11
11
 
12
- import { finiteCount, finiteOption } from '@ultimat3/core';
12
+ import { ambientClock, finiteCount, finiteOption } from '@ultimat3/core';
13
13
  import type { JobRunArgs } from '@ultimat3/jobs';
14
14
  import { parse } from '@ultimat3/schema';
15
15
  import { artifactWriter } from './artifacts';
@@ -22,7 +22,6 @@ import {
22
22
  persistSession,
23
23
  scrapePrompt,
24
24
  } from './auth';
25
- import { scrapeClock } from './clock';
26
25
  import type { ScrapeSession } from './driver';
27
26
  import { scrapeDriver } from './driver';
28
27
  import { driverUnknown, outputInvalid } from './error-throws';
@@ -88,7 +87,7 @@ export async function runScrape<I, Row>(
88
87
  definition: ScrapeDefinition<I, Row>,
89
88
  args: JobRunArgs<I>,
90
89
  ): Promise<ScrapeReport<Row>> {
91
- const clock = definition.clock ?? scrapeClock();
90
+ const clock = definition.clock ?? ambientClock();
92
91
  const driver = definition.driver ?? scrapeDriver();
93
92
  // The scrape's name goes in the SCRAPE slot: there is no driver here to name, which is the
94
93
  // whole failure.
@@ -342,7 +341,7 @@ async function bodyWithRecovery<I, Row>(
342
341
  });
343
342
  try {
344
343
  return await withStepEvent(
345
- { name: 'body', logger, clock: definition.clock ?? scrapeClock(), attempt: args.attempt },
344
+ { name: 'body', logger, clock: definition.clock ?? ambientClock(), attempt: args.attempt },
346
345
  body,
347
346
  );
348
347
  } catch (thrown) {
package/src/scrape.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // the account. So this file is a FACTORY over `job()`, and a scrape inherits `.enqueue()`, the
10
10
  // worker's cancellation, the dead-letter path, `x jobs show` and its manifest row for free.
11
11
 
12
- import type { Ctx, HostRule } from '@ultimat3/core';
12
+ import type { Ctx, HostRule, SleepClock } from '@ultimat3/core';
13
13
  import { assert } from '@ultimat3/core';
14
14
  import type {
15
15
  JobCompleted,
@@ -26,7 +26,6 @@ import type { StandardSchemaV1 } from '@ultimat3/schema';
26
26
  import type { StorageDriver } from '@ultimat3/storage';
27
27
  import type { ArtifactWriter } from './artifacts';
28
28
  import type { PromptHandler, ScrapeAuth } from './auth';
29
- import type { ScrapeClock } from './clock';
30
29
  import type { ScrapeDriver } from './driver';
31
30
  import { yieldHistoryMissing } from './error-throws';
32
31
  import type { YieldExpectation, YieldHistory } from './expect';
@@ -140,10 +139,11 @@ export interface ScrapeDefinition<I, Row> {
140
139
  readonly concurrency?: JobConcurrency<I> | undefined;
141
140
  readonly queue?: string | undefined;
142
141
  /**
143
- * Pins this definition's clock. Omit it: a run waits on the process's (`scrapeClock()`), which
144
- * is the system clock and what a test replaces with `setScrapeClock()`.
142
+ * Pins this definition's clock. Omit it: a run waits on the process's (`ambientClock()`), which
143
+ * is the system clock unless `clock` in `apps/<app>/runtime.ts` (or a test's
144
+ * `installClock`, `@ultimat3/core/adapter`) replaced it.
145
145
  */
146
- readonly clock?: ScrapeClock | undefined;
146
+ readonly clock?: SleepClock | undefined;
147
147
  run(args: ScrapeRunArgs<I>): Promise<readonly unknown[]>;
148
148
  /**
149
149
  * The job's own `onSettled`, with what a scrape adds: a `completed` run hands over its
package/src/target.ts CHANGED
@@ -118,7 +118,7 @@ export interface GotoOptions {
118
118
  * so `page.screenshot({ timeout })` was a documented deadline that bounded nothing. Deleted
119
119
  * rather than implemented: the CDP port's own `screenshot({ fullPage })` has no timeout slot to
120
120
  * forward it to, and a deadline enforced in `page-over-target.ts` would have to race
121
- * `ScrapeClock.sleep`, which under `testScrapeClock` resolves on the first microtask and would time
121
+ * `SleepClock.sleep`, which under `frozenClock` resolves on the first microtask and would time
122
122
  * out every capture in every test. A driver's own default is the honest bound.
123
123
  */
124
124
  export type CaptureOptions = CaptureFraming;
package/src/usage.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  // and nothing else: no quota, no plan, no ledger — what a number here is worth, and what happens
3
3
  // when it is too high, is the app's (`docs/idea/19-mechanism-not-convention.md`).
4
4
 
5
+ import type { SleepClock } from '@ultimat3/core';
5
6
  import type { MoneyValue } from '@ultimat3/schema';
6
- import type { ScrapeClock } from './clock';
7
7
 
8
8
  export interface ScrapeUsage {
9
9
  /** Milliseconds the browser session was held, from before `driver.open()` to this report. */
@@ -43,7 +43,7 @@ export interface RunUsageMeter extends UsageMeter {
43
43
  }
44
44
 
45
45
  /** Built BEFORE `driver.open()`, so the time a rented browser took to arrive is time it was held. */
46
- export function usageMeter(clock: ScrapeClock): RunUsageMeter {
46
+ export function usageMeter(clock: SleepClock): RunUsageMeter {
47
47
  const startedAt = clock.monotonic();
48
48
  let navigations = 0;
49
49
  let httpRequests = 0;
package/src/watchdog.ts CHANGED
@@ -10,8 +10,8 @@
10
10
  // socket die, which is what turns an infinite await into a catchable `X_SCRAPE_WEDGED` — and a
11
11
  // graceful-quit CEILING on shutdown, past which the same kill runs.
12
12
 
13
+ import type { SleepClock } from '@ultimat3/core';
13
14
  import { finiteCount } from '@ultimat3/core';
14
- import type { ScrapeClock } from './clock';
15
15
  import { watchdogStopped, wedged } from './error-throws';
16
16
 
17
17
  export const DEFAULT_IDLE_MS = 120_000;
@@ -19,7 +19,7 @@ export const DEFAULT_GRACE_MS = 5_000;
19
19
  const WATCH_POLL_MS = 250;
20
20
 
21
21
  export interface WedgeGuardInit {
22
- readonly clock: ScrapeClock;
22
+ readonly clock: SleepClock;
23
23
  /** Longest gap between two browser operations before the process is killed. */
24
24
  readonly idleMs?: number | undefined;
25
25
  /** How long a graceful quit may take before the same kill runs. */
@@ -129,7 +129,7 @@ export function createWedgeGuard(init: WedgeGuardInit): WedgeGuard {
129
129
  }
130
130
  }
131
131
  };
132
- // Never floating. `ScrapeClock` is a seam an app implements and `init.kill()` comes from a
132
+ // Never floating. `SleepClock` is a seam an app implements and `init.kill()` comes from a
133
133
  // driver, so this loop can reject on somebody else's code — and a bare `void` turned that into
134
134
  // an unhandled rejection, a process-level event belonging to no run, with the guard silently
135
135
  // dead behind it. A guard that stopped measuring is incident #1 with nothing armed, so the run