@hraness/slopcamera 3.10.0 → 3.10.2

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/PRIVACY.md CHANGED
@@ -53,3 +53,18 @@ from vulnerability reports.
53
53
 
54
54
  Report suspected vulnerabilities through GitHub private vulnerability
55
55
  reporting at https://github.com/hraness/slopcamera/security/advisories/new.
56
+
57
+ ## Website analytics
58
+
59
+ The public slopcamera.com website sends cookieless page views, page exits,
60
+ performance measurements, explicit link and install-copy actions, missing-page
61
+ reports, and rate-limited redacted client errors to PostHog. It keeps no person
62
+ profiles, analytics cookies, or session recordings and respects Do Not Track.
63
+ Public campaign parameters are retained; other query values and credentials
64
+ are removed. Sensitive paths collapse to `/private` and their campaign
65
+ attribution is removed. Preview builds and the inert preview page send nothing.
66
+
67
+ The site checks the shared regional analytics policy service. Analytics
68
+ waits for acceptance where that policy requires consent, or when the region is
69
+ unknown. Elsewhere it can run without cookies before a visitor responds.
70
+ A stored refusal or Do Not Track keeps analytics off.
package/README.md CHANGED
@@ -34,11 +34,11 @@ for prompt-only hosted images; model usage is billed separately.
34
34
  ## Install SlopCamera
35
35
 
36
36
  Install [Bun 1.3.14 or newer](https://bun.sh), then install the
37
- [SlopCamera v3.9.2 release](https://github.com/hraness/slopcamera/releases/tag/v3.9.2)
37
+ [SlopCamera v3.10.1 release](https://github.com/hraness/slopcamera/releases/tag/v3.10.1)
38
38
  and its matching Agent Skill:
39
39
 
40
40
  ```sh
41
- bun add --global https://github.com/hraness/slopcamera/releases/download/v3.9.2/hraness-slopcamera-3.9.2.tgz
41
+ bun add --global https://github.com/hraness/slopcamera/releases/download/v3.10.1/hraness-slopcamera-3.10.1.tgz
42
42
  slopcamera skill install --target agents
43
43
  ```
44
44
 
@@ -4,7 +4,7 @@ import type { z } from "zod";
4
4
 
5
5
  import type { ApplicationContext } from "./context";
6
6
 
7
- export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.10.0" as const;
7
+ export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.10.2" as const;
8
8
 
9
9
  export const OPERATION_KINDS = [
10
10
  "scene.audit",
@@ -365,7 +365,7 @@ import {
365
365
  workflowRunStore,
366
366
  } from "./workflow-runs";
367
367
 
368
- export const SLOPCAMERA_VERSION = "3.10.0";
368
+ export const SLOPCAMERA_VERSION = "3.10.2";
369
369
 
370
370
  // Legacy direct renders predate per-target output contracts. Keep them
371
371
  // bounded generously enough for long-form production while preventing one
@@ -0,0 +1,184 @@
1
+ import { fstatSync, lstatSync, type BigIntStats } from "node:fs";
2
+ import { endianness } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import { ApplicationError } from "../application/errors";
5
+
6
+ export interface BrowserWatchIdentity {
7
+ readonly path: string;
8
+ readonly dev: string;
9
+ readonly ino: string;
10
+ readonly mode: number;
11
+ readonly size?: string;
12
+ readonly ctimeNs?: string;
13
+ readonly mtimeNs?: string;
14
+ readonly uid?: string;
15
+ readonly gid?: string;
16
+ readonly nlink?: string;
17
+ }
18
+
19
+ const metadataKeys = ["size", "ctimeNs", "mtimeNs", "uid", "gid", "nlink"] as const;
20
+ function matchesIdentity(details: BigIntStats, identity: BrowserWatchIdentity): boolean {
21
+ return details.dev.toString() === identity.dev && details.ino.toString() === identity.ino
22
+ && Number(details.mode & 0o177777n) === identity.mode
23
+ && metadataKeys.every(key => identity[key] === undefined || details[key].toString() === identity[key]);
24
+ }
25
+
26
+ interface WatchTarget {
27
+ readonly absolute: string;
28
+ readonly identity: BrowserWatchIdentity;
29
+ readonly label: string;
30
+ readonly kind: "anchor" | "container" | "payload" | "runtime-root";
31
+ }
32
+
33
+ /** Kernel vnode notes persist until read, including writes whose bytes were restored. */
34
+ export async function createBrowserRuntimeKqueue(options: {
35
+ readonly anchor: string;
36
+ readonly anchorIdentity: BrowserWatchIdentity;
37
+ readonly container: string;
38
+ readonly containerIdentity: BrowserWatchIdentity;
39
+ readonly runtimeRoot: string;
40
+ readonly identities: readonly BrowserWatchIdentity[];
41
+ }): Promise<{ poll: (mutations: Set<string>) => void; close: () => void }> {
42
+ if (process.platform !== "darwin") throw new ApplicationError("unavailable", "Browser vnode watches require macOS.");
43
+ if (!["arm64", "x64"].includes(process.arch) || endianness() !== "LE") throw new ApplicationError("unavailable", "Unsupported browser vnode watch ABI.");
44
+ const { dlopen, FFIType: T, ptr } = await import("bun:ffi");
45
+ const native = dlopen("/usr/lib/libSystem.B.dylib", {
46
+ kqueue: { args: [], returns: T.i32 },
47
+ kevent64: { args: [T.i32, T.ptr, T.i32, T.ptr, T.i32, T.u32, T.ptr], returns: T.i32 },
48
+ open: { args: [T.ptr, T.i32], returns: T.i32 },
49
+ close: { args: [T.i32], returns: T.i32 },
50
+ fcntl: { args: [T.i32, T.i32, T.i32], returns: T.i32 },
51
+ getrlimit: { args: [T.i32, T.ptr], returns: T.i32 },
52
+ fgetattrlist: { args: [T.i32, T.ptr, T.ptr, T.u64, T.u32], returns: T.i32 },
53
+ });
54
+ const targets: WatchTarget[] = [
55
+ { absolute: options.anchor, identity: options.anchorIdentity, label: "<snapshot-anchor>", kind: "anchor" },
56
+ { absolute: options.container, identity: options.containerIdentity, label: "<snapshot-container>", kind: "container" },
57
+ ...options.identities.map(identity => ({ absolute: identity.path === "." ? options.runtimeRoot : join(options.runtimeRoot, identity.path), identity, label: identity.path, kind: identity.path === "." ? "runtime-root" as const : "payload" as const })),
58
+ ];
59
+ let queue = -1;
60
+ let closed = false;
61
+ let closeFailure: unknown;
62
+ let failure: unknown;
63
+ const descriptors = new Map<number, WatchTarget>();
64
+ const payloadAccess = new Map<number, { atimeNs: bigint; readonly flags: number }>();
65
+ const immutableFlags = (descriptor: number): number => {
66
+ // Darwin attrlist: five attribute groups, requesting only ATTR_CMN_FLAGS.
67
+ const request = Buffer.alloc(24), result = Buffer.alloc(8);
68
+ request.writeUInt16LE(5, 0);
69
+ request.writeUInt32LE(0x00040000, 4);
70
+ if (native.symbols.fgetattrlist(descriptor, ptr(request), ptr(result), 8n, 0) !== 0
71
+ || result.readUInt32LE(0) !== 8) {
72
+ throw new ApplicationError("unavailable", "Browser vnode immutable flags could not be read completely.");
73
+ }
74
+ return result.readUInt32LE(4);
75
+ };
76
+ const close = () => {
77
+ if (closed) { if (closeFailure !== undefined) throw closeFailure; return; }
78
+ // A failed close may already have released its numeric descriptor. Never
79
+ // retry it: the number could belong to a different resource afterwards.
80
+ closed = true;
81
+ let failed = false;
82
+ for (const descriptor of descriptors.keys()) {
83
+ if (native.symbols.close(descriptor) === 0) descriptors.delete(descriptor);
84
+ else failed = true;
85
+ }
86
+ if (queue !== -1) {
87
+ if (native.symbols.close(queue) === 0) queue = -1;
88
+ else failed = true;
89
+ }
90
+ if (failed) {
91
+ closeFailure = new ApplicationError("unavailable", "Browser vnode descriptor collection is unproven.", { unresolvedDescriptors: descriptors.size + (queue === -1 ? 0 : 1) });
92
+ throw closeFailure;
93
+ }
94
+ native.close();
95
+ };
96
+ const reject = (message: string): never => { throw new ApplicationError("conflict", message); };
97
+ try {
98
+ const limits = new BigUint64Array(2);
99
+ // RLIMIT_NOFILE; never modify process or host resource limits.
100
+ if (targets.length > 8192 || native.symbols.getrlimit(8, ptr(limits)) !== 0 || limits[0]! < BigInt(targets.length + 64)) {
101
+ reject("Insufficient bounded descriptor capacity for complete browser vnode watches.");
102
+ }
103
+ queue = native.symbols.kqueue();
104
+ if (queue < 0) reject("Could not create browser vnode event queue.");
105
+ if (native.symbols.fcntl(queue, 2, 1) !== 0) reject("Could not prevent browser vnode queue inheritance.");
106
+ for (const target of targets) {
107
+ const path = Buffer.from(`${target.absolute}\0`);
108
+ // O_EVTONLY | O_SYMLINK | O_CLOEXEC. Symlinks bind their own vnode.
109
+ const descriptor = native.symbols.open(ptr(path), 0x8000 | 0x200000 | 0x1000000);
110
+ if (descriptor < 0) reject(`Could not open browser vnode watch: ${target.label}`);
111
+ descriptors.set(descriptor, target);
112
+ const details = fstatSync(descriptor, { bigint: true });
113
+ const identity = target.identity;
114
+ if (!matchesIdentity(details, identity)) {
115
+ reject(`Browser vnode identity changed before registration: ${target.label}`);
116
+ }
117
+ if (target.kind === "payload" && details.isFile()) {
118
+ payloadAccess.set(descriptor, { atimeNs: details.atimeNs, flags: immutableFlags(descriptor) });
119
+ }
120
+ const change = Buffer.alloc(48);
121
+ const receipt = Buffer.alloc(48);
122
+ change.writeBigUInt64LE(BigInt(descriptor), 0);
123
+ change.writeInt16LE(-4, 8); // EVFILT_VNODE
124
+ change.writeUInt16LE(1 | 0x20 | 0x40, 10); // EV_ADD | EV_CLEAR | EV_RECEIPT
125
+ // Unrelated children of /private/tmp are not this snapshot. Its own
126
+ // replacement/revocation/attribute changes remain observed; container
127
+ // and all payload directories independently retain child-write notes.
128
+ change.writeUInt32LE(target.kind === "anchor" ? 0x69 : 0x7f, 12);
129
+ const count = native.symbols.kevent64(queue, ptr(change), 1, ptr(receipt), 1, 1, null);
130
+ if (count !== 1 || receipt.readBigUInt64LE(0) !== BigInt(descriptor)
131
+ || receipt.readInt16LE(8) !== -4 || !(receipt.readUInt16LE(10) & 0x4000)
132
+ || receipt.readBigInt64LE(16) !== 0n) reject(`Browser vnode registration failed: ${target.label}`);
133
+ }
134
+ return {
135
+ close,
136
+ poll(mutations) {
137
+ if (failure !== undefined) throw failure;
138
+ if (closed) reject("Browser vnode event queue was closed before verification.");
139
+ try {
140
+ // One pending entry per registered vnode; aggregate flags are sticky
141
+ // in the caller before the next read can clear kernel state.
142
+ const output = Buffer.alloc(48 * targets.length);
143
+ const count = native.symbols.kevent64(queue, null, 0, ptr(output), targets.length, 1, null);
144
+ if (count < 0 || count > targets.length) reject("Browser vnode event queue could not be read completely.");
145
+ for (let index = 0; index < count; index += 1) {
146
+ const offset = index * 48;
147
+ const descriptor = Number(output.readBigUInt64LE(offset));
148
+ const target = descriptors.get(descriptor);
149
+ const flags = output.readUInt16LE(offset + 10);
150
+ const notes = output.readUInt32LE(offset + 12);
151
+ if (target === undefined) throw new ApplicationError("conflict", "Browser vnode event has an unknown descriptor.");
152
+ if (output.readInt16LE(offset + 8) !== -4 || flags & (0x4000 | 0x8000) || notes === 0 || notes & ~0x7f) {
153
+ reject("Browser vnode event queue returned an untrusted event.");
154
+ }
155
+ // Preserve the existing launch-managed app-root ATTRIB exception.
156
+ // No WRITE/RENAME/DELETE/LINK combination becomes a metadata event.
157
+ if (target.kind === "runtime-root" && notes === 8) {
158
+ mutations.add(`change:${basename(options.runtimeRoot)}`);
159
+ continue;
160
+ }
161
+ const access = payloadAccess.get(descriptor);
162
+ if (target.kind === "payload" && notes === 8 && access !== undefined
163
+ && (access.flags & 2) !== 0 && metadataKeys.every(key => target.identity[key] !== undefined)) {
164
+ const details = fstatSync(descriptor, { bigint: true });
165
+ const pathDetails = lstatSync(target.absolute, { bigint: true });
166
+ // mmap can emit pure ATTRIB for access-time updates on immutable
167
+ // files. Original ctime and all status identity remain fixed:
168
+ // restored flags, xattrs, permissions, and times still reject.
169
+ // The renderer also retains its full content-manifest checks.
170
+ if (details.isFile() && pathDetails.isFile()
171
+ && matchesIdentity(details, target.identity) && matchesIdentity(pathDetails, target.identity)
172
+ && details.atimeNs > access.atimeNs && pathDetails.atimeNs >= details.atimeNs
173
+ && immutableFlags(descriptor) === access.flags) {
174
+ access.atimeNs = pathDetails.atimeNs;
175
+ continue;
176
+ }
177
+ }
178
+ mutations.add(`vnode-${String(notes)}:${target.label}`);
179
+ }
180
+ } catch (error) { failure = error; throw error; }
181
+ },
182
+ };
183
+ } catch (error) { close(); throw error; }
184
+ }
@@ -23,6 +23,9 @@ import { basename, dirname, join } from "node:path";
23
23
 
24
24
  import { z } from "zod";
25
25
 
26
+ import { verifyStableBrowserWatcherBaseline } from "./html-overlay-watcher-baseline";
27
+ import { createBrowserRuntimeKqueue } from "./html-overlay-kqueue";
28
+
26
29
  import {
27
30
  chromium,
28
31
  type Browser,
@@ -389,10 +392,14 @@ interface SnapshotAnchorIdentity {
389
392
  interface SnapshotPathIdentity {
390
393
  readonly ctimeNs: string;
391
394
  readonly dev: string;
395
+ readonly gid: string;
392
396
  readonly ino: string;
393
397
  readonly mode: number;
398
+ readonly mtimeNs: string;
399
+ readonly nlink: string;
394
400
  readonly path: string;
395
401
  readonly size: string;
402
+ readonly uid: string;
396
403
  }
397
404
 
398
405
  interface SnapshotDirectChildIdentity {
@@ -543,10 +550,14 @@ async function captureBrowserRuntimeSnapshotIdentity(
543
550
  identities.push({
544
551
  ctimeNs: details.ctimeNs.toString(),
545
552
  dev: details.dev.toString(),
553
+ gid: details.gid.toString(),
546
554
  ino: details.ino.toString(),
547
555
  mode: Number(details.mode & 0o177777n),
556
+ mtimeNs: details.mtimeNs.toString(),
557
+ nlink: details.nlink.toString(),
548
558
  path: entry.path,
549
559
  size: details.size.toString(),
560
+ uid: details.uid.toString(),
550
561
  });
551
562
  }
552
563
  return identities;
@@ -567,10 +578,14 @@ async function captureBrowserRuntimeSnapshotContainerIdentity(
567
578
  return {
568
579
  ctimeNs: details.ctimeNs.toString(),
569
580
  dev: details.dev.toString(),
581
+ gid: details.gid.toString(),
570
582
  ino: details.ino.toString(),
571
583
  mode: Number(details.mode & 0o177777n),
584
+ mtimeNs: details.mtimeNs.toString(),
585
+ nlink: details.nlink.toString(),
572
586
  path: "<snapshot-container>",
573
587
  size: details.size.toString(),
588
+ uid: details.uid.toString(),
574
589
  };
575
590
  }
576
591
 
@@ -1456,8 +1471,10 @@ export async function removeBrowserRuntimeSnapshot(
1456
1471
  async function assertNoBrowserRuntimeMutationEvents(
1457
1472
  mutations: ReadonlySet<string>,
1458
1473
  label: string,
1474
+ poll?: () => void,
1459
1475
  ): Promise<void> {
1460
1476
  await new Promise<void>(resolve => setImmediate(resolve));
1477
+ poll?.();
1461
1478
  if (mutations.size > 0) {
1462
1479
  throw new ApplicationError(
1463
1480
  "conflict",
@@ -2009,6 +2026,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2009
2026
  let browserRuntimeSnapshot: PreparedBrowserRuntimeSnapshot | undefined;
2010
2027
  const browserRuntimeWatchers: FSWatcher[] = [];
2011
2028
  const browserRuntimeMutations = new Set<string>();
2029
+ let kernelWatcher: Awaited<ReturnType<typeof createBrowserRuntimeKqueue>> | undefined;
2012
2030
  let renderFailure: Readonly<{ error: unknown }> | undefined;
2013
2031
  let renderResult: HtmlOverlayFrameRenderResult | undefined;
2014
2032
  let gpuEvidence: HtmlOverlayGpuEvidence | undefined;
@@ -2020,94 +2038,126 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2020
2038
  signal,
2021
2039
  );
2022
2040
  browserRuntimeSnapshot = preparedBrowserRuntime;
2041
+ let watcherVersion = 0;
2042
+ let watcherFailure: Error | undefined;
2023
2043
  const runtimeName = basename(preparedBrowserRuntime.runtimeRoot);
2024
2044
  const snapshotName = basename(preparedBrowserRuntime.directory);
2025
- const anchorWatcher = watch(
2026
- preparedBrowserRuntime.anchorPath,
2027
- { persistent: false },
2028
- (eventType, filename) => {
2029
- const path = filename?.toString() ?? "<snapshot-anchor>";
2030
- if (path === snapshotName) {
2031
- browserRuntimeMutations.add(`anchor-${eventType}:${path}`);
2032
- }
2033
- },
2034
- );
2035
- anchorWatcher.on("error", error => {
2036
- browserRuntimeMutations.add(`anchor-watch-error:${error.message}`);
2037
- });
2038
- browserRuntimeWatchers.push(anchorWatcher);
2039
- const containerWatcher = watch(
2040
- preparedBrowserRuntime.directory,
2041
- { persistent: false, recursive: process.platform === "darwin" },
2042
- (eventType, filename) => {
2043
- const path = filename?.toString().replaceAll("\\", "/")
2044
- ?? "<snapshot-container>";
2045
- if (
2046
- path === "home"
2047
- || path.startsWith("home/")
2048
- || path === "tmp"
2049
- || path.startsWith("tmp/")
2050
- ) return;
2051
- if (path === runtimeName && eventType === "change") {
2052
- // Gatekeeper may change the app root's first-launch metadata. The
2053
- // immutable parent and its exact ctime identity independently
2054
- // prove that this pathname was never removed or replaced.
2055
- return;
2056
- }
2057
- if (
2058
- request.browserRuntime.provenance.kind === "test-only-unverified"
2059
- && request.browserRuntime.manifest.layout === "single-executable"
2060
- && path === runtimeName
2061
- && eventType === "rename"
2062
- ) {
2063
- // Darwin reports synthetic rename events for accesses to the
2064
- // test-only single-file fixture. Production rejects this layout;
2065
- // its exact path identity still detects fixture substitution.
2066
- return;
2067
- }
2068
- if (path === runtimeName || path.startsWith(`${runtimeName}/`)) {
2045
+ if (process.platform === "darwin") {
2046
+ kernelWatcher = await createBrowserRuntimeKqueue({
2047
+ anchor: preparedBrowserRuntime.anchorPath,
2048
+ anchorIdentity: preparedBrowserRuntime.anchorIdentity,
2049
+ container: preparedBrowserRuntime.directory,
2050
+ containerIdentity: preparedBrowserRuntime.containerIdentity,
2051
+ runtimeRoot: preparedBrowserRuntime.runtimeRoot,
2052
+ identities: preparedBrowserRuntime.identity,
2053
+ });
2054
+ } else {
2055
+ const anchorWatcher = watch(
2056
+ preparedBrowserRuntime.anchorPath,
2057
+ { persistent: false },
2058
+ (eventType, filename) => {
2059
+ const path = filename?.toString() ?? "<snapshot-anchor>";
2060
+ if (path === snapshotName) {
2061
+ watcherVersion += 1;
2062
+ browserRuntimeMutations.add(`anchor-${eventType}:${path}`);
2063
+ }
2064
+ },
2065
+ );
2066
+ anchorWatcher.on("error", error => {
2067
+ watcherFailure = error;
2068
+ browserRuntimeMutations.add(`anchor-watch-error:${error.message}`);
2069
+ });
2070
+ browserRuntimeWatchers.push(anchorWatcher);
2071
+ const containerWatcher = watch(
2072
+ preparedBrowserRuntime.directory,
2073
+ { persistent: false, recursive: false },
2074
+ (eventType, filename) => {
2075
+ const path = filename?.toString().replaceAll("\\", "/")
2076
+ ?? "<snapshot-container>";
2077
+ if (
2078
+ path === "home"
2079
+ || path.startsWith("home/")
2080
+ || path === "tmp"
2081
+ || path.startsWith("tmp/")
2082
+ ) return;
2083
+ if (path === runtimeName && eventType === "change") {
2084
+ // Gatekeeper may change the app root's first-launch metadata. The
2085
+ // immutable parent and its exact ctime identity independently
2086
+ // prove that this pathname was never removed or replaced.
2087
+ return;
2088
+ }
2089
+ if (
2090
+ request.browserRuntime.provenance.kind === "test-only-unverified"
2091
+ && request.browserRuntime.manifest.layout === "single-executable"
2092
+ && path === runtimeName
2093
+ && eventType === "rename"
2094
+ ) {
2095
+ // Darwin reports synthetic rename events for accesses to the
2096
+ // test-only single-file fixture. Production rejects this layout;
2097
+ // its exact path identity still detects fixture substitution.
2098
+ return;
2099
+ }
2100
+ watcherVersion += 1;
2101
+ if (path === runtimeName || path.startsWith(`${runtimeName}/`)) {
2102
+ browserRuntimeMutations.add(`${eventType}:${path}`);
2103
+ return;
2104
+ }
2069
2105
  browserRuntimeMutations.add(`${eventType}:${path}`);
2070
- return;
2071
- }
2072
- browserRuntimeMutations.add(`${eventType}:${path}`);
2073
- },
2074
- );
2075
- containerWatcher.on("error", error => {
2076
- browserRuntimeMutations.add(`watch-error:${error.message}`);
2077
- });
2078
- browserRuntimeWatchers.push(containerWatcher);
2079
- await new Promise<void>(resolve => setTimeout(resolve, 25));
2080
- if (signal.aborted) throw cancellationReason(signal);
2081
- // FSEvents can deliver the just-created direct-child notification after
2082
- // both watches attach. Clear that bounded baseline before re-verifying
2083
- // every manifest and path identity. A swap during this window changes
2084
- // the already-captured container ctime and therefore fails below.
2085
- browserRuntimeMutations.clear();
2106
+ },
2107
+ );
2108
+ containerWatcher.on("error", error => {
2109
+ watcherFailure = error;
2110
+ browserRuntimeMutations.add(`watch-error:${error.message}`);
2111
+ });
2112
+ browserRuntimeWatchers.push(containerWatcher);
2113
+ }
2086
2114
  let browser: Browser | undefined;
2087
2115
  let browserFailure: Readonly<{ error: unknown }> | undefined;
2088
2116
  try {
2089
- await verifyBrowserRuntimeSnapshot(
2090
- preparedBrowserRuntime,
2091
- request.browserRuntime,
2092
- "immediately before launch",
2093
- signal,
2094
- );
2095
- await assertBrowserRuntimeSnapshotIdentity(
2096
- preparedBrowserRuntime,
2097
- request.browserRuntime.manifest.entries,
2098
- "immediately before launch",
2099
- signal,
2100
- );
2101
- await assertBrowserRuntimeSnapshotContainerIdentity(
2102
- preparedBrowserRuntime,
2103
- "immediately before launch",
2104
- signal,
2105
- );
2106
- await assertBrowserRuntimeSnapshotAnchorIdentity(
2107
- preparedBrowserRuntime,
2108
- "immediately before launch",
2117
+ // Delayed preparation notifications can arrive during full verification.
2118
+ // Recheck the SAME pre-wait identities until one pass is stable. Nothing
2119
+ // after the synchronous baseline commit is discarded.
2120
+ const baselineOptions = {
2109
2121
  signal,
2110
- );
2122
+ version: () => watcherVersion,
2123
+ failure: () => watcherFailure,
2124
+ quietMs: process.platform === "darwin" ? 2_000 : 25,
2125
+ deadlineMs: 10_000,
2126
+ verify: async () => {
2127
+ await verifyBrowserRuntimeSnapshot(
2128
+ preparedBrowserRuntime,
2129
+ request.browserRuntime,
2130
+ "immediately before launch",
2131
+ signal,
2132
+ );
2133
+ await assertBrowserRuntimeSnapshotIdentity(
2134
+ preparedBrowserRuntime,
2135
+ request.browserRuntime.manifest.entries,
2136
+ "immediately before launch",
2137
+ signal,
2138
+ );
2139
+ await assertBrowserRuntimeSnapshotContainerIdentity(
2140
+ preparedBrowserRuntime,
2141
+ "immediately before launch",
2142
+ signal,
2143
+ );
2144
+ await assertBrowserRuntimeSnapshotAnchorIdentity(
2145
+ preparedBrowserRuntime,
2146
+ "immediately before launch",
2147
+ signal,
2148
+ );
2149
+ },
2150
+ commit: () => { browserRuntimeMutations.clear(); },
2151
+ };
2152
+ if (kernelWatcher !== undefined) {
2153
+ // Vnode notes begin at registration, not an asynchronous history
2154
+ // stream. Never clear a payload event, including during baseline.
2155
+ await baselineOptions.verify();
2156
+ kernelWatcher.poll(browserRuntimeMutations);
2157
+ } else {
2158
+ await verifyStableBrowserWatcherBaseline(baselineOptions);
2159
+ }
2160
+ kernelWatcher?.poll(browserRuntimeMutations);
2111
2161
  discardProvenLaunchManagedRootMetadataEvent(
2112
2162
  browserRuntimeMutations,
2113
2163
  runtimeName,
@@ -2115,6 +2165,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2115
2165
  await assertNoBrowserRuntimeMutationEvents(
2116
2166
  browserRuntimeMutations,
2117
2167
  "before browser launch",
2168
+ () => kernelWatcher?.poll(browserRuntimeMutations),
2118
2169
  );
2119
2170
  const launchedBrowser = await boundedBrowserStep(
2120
2171
  async () => {
@@ -2174,6 +2225,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2174
2225
  "during browser launch",
2175
2226
  signal,
2176
2227
  );
2228
+ kernelWatcher?.poll(browserRuntimeMutations);
2177
2229
  discardProvenLaunchManagedRootMetadataEvent(
2178
2230
  browserRuntimeMutations,
2179
2231
  runtimeName,
@@ -2181,6 +2233,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2181
2233
  await assertNoBrowserRuntimeMutationEvents(
2182
2234
  browserRuntimeMutations,
2183
2235
  "during browser launch",
2236
+ () => kernelWatcher?.poll(browserRuntimeMutations),
2184
2237
  );
2185
2238
  const contextContract = HTML_OVERLAY_RENDERER_CONTRACT.browserContext;
2186
2239
  const context = await boundedBrowserStep(
@@ -2475,6 +2528,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2475
2528
  "through browser shutdown",
2476
2529
  signal,
2477
2530
  );
2531
+ kernelWatcher?.poll(browserRuntimeMutations);
2478
2532
  discardProvenLaunchManagedRootMetadataEvent(
2479
2533
  browserRuntimeMutations,
2480
2534
  runtimeName,
@@ -2482,6 +2536,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2482
2536
  await assertNoBrowserRuntimeMutationEvents(
2483
2537
  browserRuntimeMutations,
2484
2538
  "through browser shutdown",
2539
+ () => kernelWatcher?.poll(browserRuntimeMutations),
2485
2540
  );
2486
2541
  renderResult = {
2487
2542
  ...(gpuEvidence === undefined ? {} : { gpuEvidence }),
@@ -2496,6 +2551,12 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2496
2551
  .catch(() => undefined);
2497
2552
  } finally {
2498
2553
  for (const watcher of browserRuntimeWatchers) watcher.close();
2554
+ try { kernelWatcher?.close(); } catch (error) {
2555
+ renderFailure = { error: new ApplicationError("unavailable", "Browser vnode watch cleanup failed; frames were not published.", {
2556
+ cause: error,
2557
+ ...(renderFailure === undefined ? {} : { renderFailure: renderFailure.error }),
2558
+ }) };
2559
+ }
2499
2560
  if (browserRuntimeSnapshot !== undefined) {
2500
2561
  if (browserShutdownRequired) {
2501
2562
  const retainedError = new ApplicationError("unavailable", "HTML overlay browser shutdown is unproven; its active runtime snapshot was retained and frames were not published.", {
@@ -0,0 +1,65 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ interface BaselineClock {
4
+ readonly now: () => number;
5
+ readonly wait: (milliseconds: number, signal: AbortSignal) => Promise<void>;
6
+ }
7
+
8
+ const clock: BaselineClock = {
9
+ now: () => performance.now(),
10
+ wait: async (milliseconds, signal) => { await delay(milliseconds, undefined, { signal }); },
11
+ };
12
+
13
+ /** Drain asynchronous preparation notifications before revalidating the saved identities. */
14
+ interface BrowserWatcherBaselineOptions {
15
+ readonly signal: AbortSignal;
16
+ readonly version: () => number;
17
+ readonly failure: () => Error | undefined;
18
+ readonly quietMs: number;
19
+ readonly deadlineMs: number;
20
+ readonly clock?: BaselineClock;
21
+ }
22
+
23
+ export async function settleBrowserWatcherBaseline(options: BrowserWatcherBaselineOptions): Promise<void> {
24
+ const timing = options.clock ?? clock;
25
+ const started = timing.now();
26
+ let lastActivity = started;
27
+ let observedVersion = options.version();
28
+ for (;;) {
29
+ options.signal.throwIfAborted();
30
+ const failure = options.failure();
31
+ if (failure !== undefined) throw failure;
32
+ const now = timing.now();
33
+ const version = options.version();
34
+ if (version !== observedVersion) {
35
+ observedVersion = version;
36
+ lastActivity = now;
37
+ }
38
+ if (now - started >= options.deadlineMs) {
39
+ throw new Error("Browser runtime filesystem notifications did not settle before launch.");
40
+ }
41
+ if (now - lastActivity >= options.quietMs) return;
42
+ await timing.wait(Math.min(50, options.quietMs - (now - lastActivity), options.deadlineMs - (now - started)), options.signal);
43
+ }
44
+ }
45
+
46
+ /** Every discarded notification batch must precede a stable, complete identity check. */
47
+ export async function verifyStableBrowserWatcherBaseline(options: BrowserWatcherBaselineOptions & {
48
+ readonly verify: () => Promise<void>;
49
+ readonly commit: () => void;
50
+ }): Promise<void> {
51
+ for (let pass = 0; pass < 3; pass += 1) {
52
+ await settleBrowserWatcherBaseline(options);
53
+ const version = options.version();
54
+ await options.verify();
55
+ options.signal.throwIfAborted();
56
+ const failure = options.failure();
57
+ if (failure !== undefined) throw failure;
58
+ if (options.version() === version) {
59
+ // No await may separate the stable-generation check from arming the guard.
60
+ options.commit();
61
+ return;
62
+ }
63
+ }
64
+ throw new Error("Browser runtime filesystem notifications changed during all three verification passes.");
65
+ }