also looking at this
test: retain owned browser startup process evidence
#100
21 files
+1516
-155
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -30,6 +30,22 @@ jobs: | ||
| 30 | 30 | - run: bun run parity:check |
| 31 | 31 | - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py |
| 32 | 32 | - run: python3 -m unittest discover -s tests/hardware |
| 33 | - name: Qualify startup observations with actual exited and live native processes | |
| 34 | env: | |
| 35 | BROWSER_LAUNCH_EVIDENCE: ${{ runner.temp }}/browser-launch-exited | |
| 36 | BROWSER_LAUNCH_LIVE_EVIDENCE: ${{ runner.temp }}/browser-launch-live | |
| 37 | run: bun test tests/unit/public-safety-browser-launch.test.ts tests/unit/public-safety-browser-launch-evidence.test.ts tests/unit/public-safety-browser-startup-process.test.ts | |
| 38 | - uses: actions/upload-artifact@v4 | |
| 39 | if: always() | |
| 40 | with: | |
| 41 | name: browser-launch-native-evidence | |
| 42 | if-no-files-found: ignore | |
| 43 | retention-days: 7 | |
| 44 | path: | | |
| 45 | ${{ runner.temp }}/browser-launch-exited/browser-launch.json | |
| 46 | ${{ runner.temp }}/browser-launch-exited/chrome-startup-stderr.log | |
| 47 | ${{ runner.temp }}/browser-launch-live/browser-launch.json | |
| 48 | ${{ runner.temp }}/browser-launch-live/chrome-startup-stderr.log | |
| 33 | 49 | - name: Qualify cold build setup across the former hook deadline |
| 34 | 50 | run: bun scripts/qualify-runtime-cold-build.ts "$RUNNER_TEMP/runtime-cold-build" |
| 35 | 51 | - uses: actions/upload-artifact@v4 |
| @@ -51,6 +51,7 @@ const config: BunPressOptions = { | ||
| 51 | 51 | { text: 'Raspberry Pi setup', link: '/raspberry-pi' }, |
| 52 | 52 | { text: 'Library API', link: '/api' }, |
| 53 | 53 | { text: 'Validation evidence', link: '/validation' }, |
| 54 | { text: 'Browser qualification evidence', link: '/browser-qualification' }, | |
| 54 | 55 | { text: 'Flight-development gates', link: '/flight-development' }, |
| 55 | 56 | ], |
| 56 | 57 | }, |
| @@ -0,0 +1,80 @@ | ||
| 1 | # Browser qualification observations | |
| 2 | ||
| 3 | The rendered workspace and execution checks launch a fresh native Chrome process in the | |
| 4 | existing fixture-owned process group. They retain the original launch arguments and the | |
| 5 | 400-read, 50 ms DevToolsActivePort discovery guard. No retry or browser flag bypass is added. | |
| 6 | The full page/route/Worker scenarios must still pass. A successful native launcher fixture | |
| 7 | does not qualify those scenarios or explain an intermittent Chrome startup failure. | |
| 8 | ||
| 9 | ## Startup evidence | |
| 10 | ||
| 11 | `browser-launch.json` retains its exit/port counters and adds a `startup` observation. The | |
| 12 | observer requests samples near launch, 1, 5, 10 and 15 seconds, then at discovery end. Samples | |
| 13 | run asynchronously during discovery, with at most one actual task in flight and a 500 ms | |
| 14 | cooperative cancellation budget. Cancellation stops further process/resource reads after | |
| 15 | outstanding native calls settle. It never releases ownership of an unfinished task. A timeout | |
| 16 | disables later Linux samples and hashing, closes captured descriptors after settlement and | |
| 17 | emits a bounded pending-task notice. Finalization waits for that task; it cannot allow a page | |
| 18 | visit behind it. An uninterruptible filesystem call can hold finalization until the existing | |
| 19 | outer fixture guard fails and terminates its group. This is not a hard OS syscall deadline. | |
| 20 | Actual start/completion epochs are retained; labels are requested milestones, not | |
| 21 | promises that an event occurred at that exact time. A pre-page cleanup sample follows the | |
| 22 | existing root termination/2 second wait. No process or resource sample is requested after a | |
| 23 | page visit. Post-page stderr remains private. | |
| 24 | ||
| 25 | Each sample includes a native root PID existence probe. Linux adds selected `/proc` state: | |
| 26 | parent/group/session, start/user/system ticks, thread count, RSS and kernel wait channel. | |
| 27 | Names, argv, environment, stacks and memory contents are not exported. Main-task child | |
| 28 | traversal and previously observed descendants are capped at 64 processes per sample; root | |
| 29 | thread wait details are capped at 32. Start ticks flag observed PID reuse. Fork/exit/reparenting, | |
| 30 | permissions and truncation can make this incomplete. It is not proof of whole-group cleanup. | |
| 31 | The outer fixture owner separately terminates its isolated group before disposable removal. | |
| 32 | ||
| 33 | Expected discovery-file metadata, observer cgroup-v2 counters and system CPU/memory/IO pressure | |
| 34 | help distinguish missing files, a live process and resource contention. Cgroup counters may | |
| 35 | cover other runner work; system pressure covers other host workloads. Neither proves Chrome | |
| 36 | causality. Unsupported platforms, missing files, permission errors and observation timeouts | |
| 37 | are explicit outcomes. Numeric kernel fields are selected and bounded; raw status, cgroup | |
| 38 | membership, thread names and arbitrary text are not retained. | |
| 39 | ||
| 40 | Startup samples pin at most four distinct actual Linux `/proc/PID/exe` file descriptors | |
| 41 | without reading executable bytes. Only after discovery ends does it hash them under one | |
| 42 | 2 second cooperative hash budget, with a 512 MiB per-executable limit. Device/inode identity uses exact | |
| 43 | integer strings. Byte count and file timestamps are checked before retaining a digest; all | |
| 44 | pinned descriptors are then closed, with close errors reported. This identifies an observed | |
| 45 | backing executable rather than assuming the configured launcher is Chrome. It neither warms | |
| 46 | the binary before the attempt nor authenticates the code or its entire loaded graph. | |
| 47 | ||
| 48 | The browser version is retained only from a successful CDP version response and a closed | |
| 49 | Chrome version format. If discovery fails, it stays null. Do not infer the failed binary's | |
| 50 | version from another run without matching actual identity evidence. Opening descriptors, | |
| 51 | reading process files and hashing are observation work and can affect scheduling; the | |
| 52 | observed times and unavailable/timeout outcomes must accompany comparisons. | |
| 53 | ||
| 54 | ## Native qualification | |
| 55 | ||
| 56 | ```sh | |
| 57 | bun test tests/unit/public-safety-browser-launch.test.ts tests/unit/public-safety-browser-launch-evidence.test.ts tests/unit/public-safety-browser-startup-process.test.ts | |
| 58 | ``` | |
| 59 | ||
| 60 | One actual native fixture exits 23 before discovery. Another owns a live child until the | |
| 61 | original cleanup request. The checks preserve all 400 failed probes, inspect actual PIDs, | |
| 62 | verify parent/child shutdown and ensure argv/environment sentinels do not enter the report. | |
| 63 | On Linux the live fixture also checks actual descendant/start/RSS data and the retained | |
| 64 | executable hash against the independently read runtime binary. Optional `BROWSER_LAUNCH_EVIDENCE` | |
| 65 | and `BROWSER_LAUNCH_LIVE_EVIDENCE` retain reports in new directories. CI publishes only the | |
| 66 | allowlisted launch JSON and startup stderr, not scripts, credentials or private fixture files. | |
| 67 | A held OS-adapter promise separately verifies that cancellation cannot overlap another sample, | |
| 68 | finalize early or begin hashing while an operation remains unfinished. It does not qualify | |
| 69 | Linux field acquisition; the actual native Linux fixture provides that evidence. | |
| 70 | A Linux cancellation case also opens real executable descriptors and checks that they are | |
| 71 | closed without a digest once the held native sample settles. | |
| 72 | ||
| 73 | Linux-only observations require Linux evidence; a macOS pass does not establish them. | |
| 74 | [Issue #72](https://github.com/stacksjs/drone/issues/72) retains the original failed attempts and | |
| 75 | passing controls. Its root cause and correction remain open until reproduced and verified. | |
| 76 | These observations change evidence, not aircraft control, physical outputs or flight readiness. | |
| 77 | ||
| 78 | Field semantics follow the kernel's [proc reference](https://docs.kernel.org/filesystems/proc.html), | |
| 79 | [cgroup-v2 reference](https://docs.kernel.org/admin-guide/cgroup-v2.html) and | |
| 80 | [pressure reference](https://docs.kernel.org/accounting/psi.html). | |
| @@ -23,3 +23,6 @@ The owned runtime follows the dependency-linked [roadmap](roadmap/README.md). Th | ||
| 23 | 23 | support comparison and development while native board controls are implemented and validated. |
| 24 | 24 | |
| 25 | 25 | Until those milestones are complete, the code here should be used for simulation and bench development. |
| 26 | ||
| 27 | Rendered application checks have a separate [browser qualification contract](browser-qualification.md). | |
| 28 | A browser-startup failure does not establish a vehicle, estimator or physical flight failure. | |
| @@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer' | ||
| 2 | 2 | import { writeSync } from 'node:fs' |
| 3 | 3 | import { mkdir, open, writeFile } from 'node:fs/promises' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | import type { BrowserStartupEvidence } from './browser-startup-process' | |
| 5 | 6 | |
| 6 | 7 | export interface BrowserLaunchObservation { |
| 7 | 8 | phase: 'startup-unavailable' | 'discovery-failed' | 'closed' |
| 8 | 9 | elapsedMs: number |
| 9 | 10 | launch: { pid: number, exitObserved: boolean, settledExitCode: number | null, signalCode: string | number | null, exitObservedBeforeCleanup: boolean, cleanupRequested: boolean, exitElapsedMs: number | null } |
| 10 | 11 | portProbe: { attempts: number, successfulReads: number, failedReads: number, lastReadError: string | null, discovered: boolean } |
| 12 | startup?: BrowserStartupEvidence | |
| 11 | 13 | } |
| 12 | 14 | export function browserFileErrorCode(error: unknown): string { |
| 13 | 15 | const code = (error as NodeJS.ErrnoException | null)?.code |
| @@ -0,0 +1,167 @@ | ||
| 1 | import { createHash } from 'node:crypto' | |
| 2 | import { lstat, open, readFile, readlink, readdir } from 'node:fs/promises' | |
| 3 | import type { FileHandle } from 'node:fs/promises' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { browserFileErrorCode } from './browser-launch-evidence' | |
| 6 | ||
| 7 | const processLimit = 64, threadLimit = 32, executableLimit = 4, executableByteLimit = 512 * 1024 * 1024 | |
| 8 | async function text(path: string, signal: AbortSignal) { | |
| 9 | if (signal.aborted) return { value: null, code: 'ABORT_ERR' } | |
| 10 | try { | |
| 11 | const value = await readFile(path, { encoding: 'utf8', signal }) | |
| 12 | return value.length <= 32768 ? { value, code: null } : { value: null, code: 'BYTE_LIMIT' } | |
| 13 | } | |
| 14 | catch (error) { return { value: null, code: browserFileErrorCode(error) } } | |
| 15 | } | |
| 16 | const decimal = (value: string | undefined) => value && /^\d{1,20}$/.test(value) ? value : null | |
| 17 | const integer = (value: string | undefined) => { const n = decimal(value); return n !== null && Number.isSafeInteger(Number(n)) ? Number(n) : null } | |
| 18 | async function processState(pid: number, signal: AbortSignal) { | |
| 19 | const [raw, status, wait, children] = await Promise.all([text(`/proc/${pid}/stat`, signal), text(`/proc/${pid}/status`, signal), text(`/proc/${pid}/wchan`, signal), text(`/proc/${pid}/task/${pid}/children`, signal)]) | |
| 20 | signal.throwIfAborted() | |
| 21 | const end = raw.value?.lastIndexOf(') ') ?? -1 | |
| 22 | // tcomm may contain spaces/parentheses and is deliberately discarded. | |
| 23 | const fields = end >= 0 ? raw.value!.slice(end + 2).trim().split(/\s+/) : [] | |
| 24 | const valid = fields.length >= 22 && /^[RSDZTtXxKWPI]$/.test(fields[0]!) && decimal(fields[19]) !== null | |
| 25 | const rss = status.value?.match(/^VmRSS:\s+(\d+)\s+kB$/m)?.[1] | |
| 26 | const ids = children.value?.trim().split(/\s+/).map(integer).filter((n): n is number => n !== null && n > 0) ?? [] | |
| 27 | return { | |
| 28 | record: { pid, code: valid ? null : raw.code ?? 'FORMAT', state: valid ? fields[0]! : null, | |
| 29 | parentPid: valid ? integer(fields[1]) : null, group: valid ? integer(fields[2]) : null, session: valid ? integer(fields[3]) : null, | |
| 30 | startTicks: valid ? decimal(fields[19]) : null, userTicks: valid ? decimal(fields[11]) : null, systemTicks: valid ? decimal(fields[12]) : null, | |
| 31 | threads: valid ? integer(fields[17]) : null, rssKiB: integer(rss), statusCode: status.code, | |
| 32 | wait: wait.value !== null && /^[A-Za-z0-9_.:]{1,80}$/.test(wait.value.trim()) ? wait.value.trim() : null, waitCode: wait.code, childrenCode: children.code }, | |
| 33 | children: ids.slice(0, processLimit), childrenCapped: ids.length > processLimit, | |
| 34 | } | |
| 35 | } | |
| 36 | async function counters(path: string, allowed: string[], signal: AbortSignal) { | |
| 37 | const result = await text(path, signal), values: Record<string, string> = {} | |
| 38 | signal.throwIfAborted() | |
| 39 | for (const line of result.value?.split('\n') ?? []) { | |
| 40 | const [key, value] = line.trim().split(/\s+/) | |
| 41 | if (key && allowed.includes(key) && decimal(value) !== null) values[key] = value! | |
| 42 | } | |
| 43 | return { values, code: result.code } | |
| 44 | } | |
| 45 | async function scalar(path: string, signal: AbortSignal) { | |
| 46 | const result = await text(path, signal), value = result.value?.trim() | |
| 47 | signal.throwIfAborted() | |
| 48 | return { value: value === 'max' || decimal(value) !== null ? value! : null, code: result.code ?? (value === 'max' || decimal(value) !== null ? null : 'FORMAT') } | |
| 49 | } | |
| 50 | async function pressure(resource: string, signal: AbortSignal) { | |
| 51 | const result = await text(`/proc/pressure/${resource}`, signal), values: Record<string, { avg10: number, avg60: number, avg300: number, totalMicroseconds: string }> = {} | |
| 52 | for (const line of result.value?.split('\n') ?? []) { | |
| 53 | const match = /^(some|full) avg10=(\d{1,3}(?:\.\d{1,6})?) avg60=(\d{1,3}(?:\.\d{1,6})?) avg300=(\d{1,3}(?:\.\d{1,6})?) total=(\d{1,20})$/.exec(line) | |
| 54 | if (match) values[match[1]!] = { avg10: Number(match[2]), avg60: Number(match[3]), avg300: Number(match[4]), totalMicroseconds: match[5]! } | |
| 55 | } | |
| 56 | return { values, code: result.code } | |
| 57 | } | |
| 58 | async function resources(signal: AbortSignal) { | |
| 59 | signal.throwIfAborted() | |
| 60 | const membership = await text('/proc/self/cgroup', signal) | |
| 61 | signal.throwIfAborted() | |
| 62 | const path = membership.value?.split('\n').find(line => line.startsWith('0::'))?.slice(3) | |
| 63 | const base = path?.startsWith('/') && !path.split('/').includes('..') ? join('/sys/fs/cgroup', path) : null | |
| 64 | const [cpuPressure, memoryPressure, ioPressure] = await Promise.all(['cpu', 'memory', 'io'].map(name => pressure(name, signal))) | |
| 65 | signal.throwIfAborted() | |
| 66 | const cgroup = base ? { | |
| 67 | memoryCurrent: await scalar(join(base, 'memory.current'), signal), memoryMax: await scalar(join(base, 'memory.max'), signal), | |
| 68 | memoryEvents: await counters(join(base, 'memory.events'), ['low', 'high', 'max', 'oom', 'oom_kill', 'oom_group_kill', 'sock_throttled'], signal), | |
| 69 | cpu: await counters(join(base, 'cpu.stat'), ['usage_usec', 'user_usec', 'system_usec', 'nr_periods', 'nr_throttled', 'throttled_usec', 'nr_bursts', 'burst_usec'], signal), | |
| 70 | pidsCurrent: await scalar(join(base, 'pids.current'), signal), pidsMax: await scalar(join(base, 'pids.max'), signal), | |
| 71 | } : null | |
| 72 | return { scope: 'observer cgroup, potentially shared with other runner processes', pressureScope: 'system pressure, shared with other host workloads', membershipCode: membership.code ?? (base ? null : 'NOT_V2'), cgroup, cpuPressure, memoryPressure, ioPressure } | |
| 73 | } | |
| 74 | ||
| 75 | type Executable = { path: string, device: string, inode: string, byteLength: number, observedPids: number[], openedAtMs: number, sha256: string | null, hashedAfterDiscovery: boolean, code: string | null, closeCode: string | null } | |
| 76 | /** No argv, environment, comm, stack or memory dump is exported. */ | |
| 77 | export function createLinuxStartupObservation(pid: number, profile: string, began: number) { | |
| 78 | const tracked = new Map<number, string>(), pinned = new Map<string, { handle: FileHandle, mtimeNs: bigint, ctimeNs: bigint, evidence: Executable }>() | |
| 79 | const identityErrors: Array<{ pid: number, code: string }> = [] | |
| 80 | let handlesSealed = false, executableLimitReached = false | |
| 81 | async function executable(id: number, signal: AbortSignal) { | |
| 82 | if (handlesSealed || signal.aborted) return | |
| 83 | let handle: FileHandle | undefined | |
| 84 | try { | |
| 85 | handle = await open(`/proc/${id}/exe`, 'r') | |
| 86 | if (handlesSealed || signal.aborted) return | |
| 87 | const stat = await handle.stat({ bigint: true }), key = `${stat.dev}:${stat.ino}` | |
| 88 | if (handlesSealed || signal.aborted) return | |
| 89 | const prior = pinned.get(key) | |
| 90 | if (prior) { if (!prior.evidence.observedPids.includes(id)) prior.evidence.observedPids.push(id); return } | |
| 91 | if (pinned.size >= executableLimit) { executableLimitReached = true; return } | |
| 92 | const path = await readlink(`/proc/self/fd/${handle.fd}`) | |
| 93 | if (handlesSealed || signal.aborted) return | |
| 94 | if (!stat.isFile() || stat.size <= 0n || stat.size > BigInt(executableByteLimit) || path.length > 4096) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: 'IDENTITY_LIMIT' }); return } | |
| 95 | pinned.set(key, { handle, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, evidence: { path, device: String(stat.dev), inode: String(stat.ino), byteLength: Number(stat.size), observedPids: [id], openedAtMs: performance.now() - began, sha256: null, hashedAfterDiscovery: false, code: null, closeCode: null } }) | |
| 96 | handle = undefined | |
| 97 | } | |
| 98 | catch (error) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: browserFileErrorCode(error) }) } | |
| 99 | finally { | |
| 100 | if (handle) { | |
| 101 | try { await handle.close() } | |
| 102 | catch (closeError) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: browserFileErrorCode(closeError) }) } | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | return { | |
| 107 | async snapshot(signal: AbortSignal) { | |
| 108 | signal.throwIfAborted() | |
| 109 | const root = await processState(pid, signal), descendants: Array<typeof root.record & { reused: boolean }> = [] | |
| 110 | const check = (id: number, start: string | null) => { | |
| 111 | if (!start) return false | |
| 112 | const prior = tracked.get(id) | |
| 113 | if (prior && prior !== start) return false | |
| 114 | if (tracked.size < processLimit) tracked.set(id, start) | |
| 115 | return true | |
| 116 | } | |
| 117 | const rootMatches = check(pid, root.record.startTicks) | |
| 118 | if (rootMatches) await executable(pid, signal) | |
| 119 | const queue = [...new Set([...(rootMatches ? root.children : []), ...tracked.keys()].filter(id => id !== pid))] | |
| 120 | const seen = new Set<number>([pid]); let processLimitReached = root.childrenCapped | |
| 121 | while (queue.length && seen.size < processLimit && !signal.aborted) { | |
| 122 | const id = queue.shift()! | |
| 123 | if (seen.has(id)) continue | |
| 124 | seen.add(id) | |
| 125 | const state = await processState(id, signal), matches = check(id, state.record.startTicks) | |
| 126 | descendants.push({ ...state.record, reused: state.record.startTicks !== null && !matches }) | |
| 127 | if (matches) { await executable(id, signal); queue.push(...state.children.filter(n => !seen.has(n))); processLimitReached ||= state.childrenCapped } | |
| 128 | } | |
| 129 | processLimitReached ||= queue.length > 0 || tracked.size === processLimit | |
| 130 | signal.throwIfAborted() | |
| 131 | let threadCode: string | null = null | |
| 132 | const allThreads = await readdir(`/proc/${pid}/task`).catch(error => { threadCode = browserFileErrorCode(error); return [] }) | |
| 133 | signal.throwIfAborted() | |
| 134 | const threads = await Promise.all(allThreads.filter(id => /^\d+$/.test(id)).slice(0, threadLimit).map(async id => { | |
| 135 | const wait = await text(`/proc/${pid}/task/${id}/wchan`, signal) | |
| 136 | return { tid: Number(id), wait: wait.value !== null && /^[A-Za-z0-9_.:]{1,80}$/.test(wait.value.trim()) ? wait.value.trim() : null, code: wait.code } | |
| 137 | })) | |
| 138 | signal.throwIfAborted() | |
| 139 | const discoveryFile = await lstat(join(profile, 'DevToolsActivePort')).then(value => ({ present: true, regular: value.isFile(), bytes: value.size, code: null })).catch(error => ({ present: false, regular: null, bytes: null, code: browserFileErrorCode(error) })) | |
| 140 | signal.throwIfAborted() | |
| 141 | return { status: 'observed' as const, root: { ...root.record, reused: root.record.startTicks !== null && !rootMatches }, descendants, processLimitReached, | |
| 142 | childScope: 'main-task children and previously observed descendants; incomplete during fork/exit/reparenting', threads, threadCode, threadDetailsCapped: allThreads.length > threadLimit, discoveryFile, resources: await resources(signal) } | |
| 143 | }, | |
| 144 | async settleExecutables(shouldHash: boolean) { | |
| 145 | handlesSealed = true | |
| 146 | const abort = new AbortController(), timer = setTimeout(() => abort.abort(), 2000) | |
| 147 | try { | |
| 148 | for (const { handle, evidence, mtimeNs, ctimeNs } of pinned.values()) { | |
| 149 | try { | |
| 150 | if (!shouldHash) { evidence.code = 'OBSERVATION_CANCELED'; continue } | |
| 151 | if (abort.signal.aborted) { evidence.code = 'HASH_BUDGET'; continue } | |
| 152 | const hash = createHash('sha256'); let bytes = 0 | |
| 153 | for await (const chunk of handle.createReadStream({ autoClose: false, start: 0, end: evidence.byteLength - 1, signal: abort.signal })) { hash.update(chunk); bytes += chunk.length } | |
| 154 | const after = await handle.stat({ bigint: true }) | |
| 155 | if (abort.signal.aborted) { evidence.code = 'HASH_BUDGET'; continue } | |
| 156 | if (bytes !== evidence.byteLength || after.size !== BigInt(bytes) || after.mtimeNs !== mtimeNs || after.ctimeNs !== ctimeNs) { evidence.code = 'EXECUTABLE_CHANGED'; continue } | |
| 157 | evidence.sha256 = hash.digest('hex'); evidence.hashedAfterDiscovery = true | |
| 158 | } | |
| 159 | catch (error) { evidence.code = browserFileErrorCode(error) } | |
| 160 | finally { await handle.close().catch(error => { evidence.closeCode = browserFileErrorCode(error) }) } | |
| 161 | } | |
| 162 | } | |
| 163 | finally { clearTimeout(timer) } | |
| 164 | }, | |
| 165 | evidence() { return { executables: [...pinned.values()].map(value => structuredClone(value.evidence)), identityErrors: structuredClone(identityErrors), executableLimitReached } }, | |
| 166 | } | |
| 167 | } | |
| @@ -0,0 +1,61 @@ | ||
| 1 | import { browserFileErrorCode } from './browser-launch-evidence' | |
| 2 | import type { createLinuxStartupObservation } from './browser-startup-linux' | |
| 3 | ||
| 4 | type NativeSnapshot = Awaited<ReturnType<ReturnType<typeof createLinuxStartupObservation>['snapshot']>> | |
| 5 | export interface StartupProcessAdapter { | |
| 6 | snapshot: (signal: AbortSignal) => Promise<NativeSnapshot | { status: 'unavailable', code: string | null }> | |
| 7 | settleExecutables: (hash: boolean) => Promise<void> | |
| 8 | evidence: ReturnType<typeof createLinuxStartupObservation>['evidence'] | |
| 9 | } | |
| 10 | ||
| 11 | type Probe = 'present' | 'missing' | 'denied' | 'unavailable' | |
| 12 | function rootProbe(pid: number): Probe { | |
| 13 | try { process.kill(pid, 0); return 'present' } | |
| 14 | catch (error) { | |
| 15 | const code = browserFileErrorCode(error) | |
| 16 | return code === 'ESRCH' ? 'missing' : code === 'EPERM' ? 'denied' : 'unavailable' | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | /** Startup-only facts. A PID probe does not establish complete process-group ownership. */ | |
| 21 | export function createBrowserStartupObserver(pid: number, began: number, linux: StartupProcessAdapter | null) { | |
| 22 | type Linux = NativeSnapshot | { status: 'unsupported' | 'timeout' | 'unavailable', code: string | null } | |
| 23 | const snapshots: Array<{ label: string, elapsedMs: number, completedAtMs: number, root: { pid: number, probe: Probe }, linux: Linux }> = [] | |
| 24 | const milestones = [0, 1000, 5000, 10000, 15000] | |
| 25 | let next = 0, sealed = false, inFlight = false, canceled = false, browserVersion: string | null = null | |
| 26 | const sample = async (label: string) => { | |
| 27 | const elapsedMs = performance.now() - began, root = { pid, probe: rootProbe(pid) } | |
| 28 | const abort = new AbortController() | |
| 29 | let timer: ReturnType<typeof setTimeout> | undefined, expired = false | |
| 30 | try { | |
| 31 | let observation: Linux = { status: 'unsupported', code: null } | |
| 32 | if (linux && canceled) observation = { status: 'unavailable', code: 'OBSERVATION_CANCELED' } | |
| 33 | else if (linux) { | |
| 34 | timer = setTimeout(() => { | |
| 35 | expired = true; canceled = true; abort.abort() | |
| 36 | console.error(JSON.stringify({ oracle: 'browser-startup-observation-pending', pid, elapsedMs: performance.now() - began })) | |
| 37 | }, 500) | |
| 38 | // Cancellation requests are not settlement. Own this actual task until it returns. | |
| 39 | observation = await linux.snapshot(abort.signal).catch(error => ({ status: 'unavailable' as const, code: browserFileErrorCode(error) })) | |
| 40 | if (expired) observation = { status: 'timeout', code: 'OBSERVATION_BUDGET' } | |
| 41 | } | |
| 42 | snapshots.push({ label, elapsedMs, completedAtMs: performance.now() - began, root, linux: observation }) | |
| 43 | } | |
| 44 | finally { if (timer) clearTimeout(timer) } | |
| 45 | } | |
| 46 | let pending: Promise<void> = Promise.resolve() | |
| 47 | return { | |
| 48 | tick() { | |
| 49 | if (!sealed && !canceled && !inFlight && next < milestones.length && performance.now() - began >= milestones[next]!) { | |
| 50 | const at = milestones[next++]! | |
| 51 | inFlight = true | |
| 52 | pending = sample(at === 0 ? 'launch' : `elapsed-${at}ms`).finally(() => { inFlight = false }) | |
| 53 | } | |
| 54 | }, | |
| 55 | async finishDiscovery() { sealed = true; await pending; await sample('discovery-ended'); await linux?.settleExecutables(!canceled) }, | |
| 56 | async cleanupEnded() { await sample('cleanup-ended') }, | |
| 57 | recordVersion(value: unknown) { if (typeof value === 'string' && /^(?:HeadlessChrome|Chrome)\/\d{1,6}(?:\.\d{1,8}){3}$/.test(value)) browserVersion = value }, | |
| 58 | evidence() { return { method: 'startup-only-process-observation-v1' as const, platform: process.platform, browserVersion, snapshots: structuredClone(snapshots), ...linux?.evidence() ?? { executables: [], identityErrors: [], executableLimitReached: false } } }, | |
| 59 | } | |
| 60 | } | |
| 61 | export type BrowserStartupEvidence = ReturnType<ReturnType<typeof createBrowserStartupObserver>['evidence']> | |
| @@ -4,6 +4,8 @@ import { join } from 'node:path' | ||
| 4 | 4 | import { observeBrowserOperation } from './browser-operation' |
| 5 | 5 | import { browserFileErrorCode, retainBrowserLaunchEvidence } from './browser-launch-evidence' |
| 6 | 6 | import type { BrowserLaunchObservation } from './browser-launch-evidence' |
| 7 | import { createBrowserStartupObserver } from './browser-startup-process' | |
| 8 | import { createLinuxStartupObservation } from './browser-startup-linux' | |
| 7 | 9 | |
| 8 | 10 | /** Dependency-free CDP, with a fresh profile. Credentials never enter command arguments. */ |
| 9 | 11 | async function openSocket(url: string): Promise<WebSocket> { |
| @@ -22,31 +24,41 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 22 | 24 | const child = Bun.spawn([process.env.BROWSE_BROWSER ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '--headless=new', '--disable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=CalculateNativeWinOcclusion', '--disable-dev-shm-usage', '--disable-extensions', '--disable-component-update', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdout: 'ignore', stderr: Bun.file(join(root, 'chrome.log')) }) |
| 23 | 25 | const started = Date.now() |
| 24 | 26 | const began = performance.now() |
| 27 | const startup = createBrowserStartupObserver(child.pid, began, process.platform === 'linux' ? createLinuxStartupObservation(child.pid, profile, began) : null) | |
| 25 | 28 | let closing = false |
| 26 | 29 | const launch: BrowserLaunchObservation['launch'] = { pid: child.pid, exitObserved: false, settledExitCode: null, signalCode: null, exitObservedBeforeCleanup: false, cleanupRequested: false, exitElapsedMs: null } |
| 27 | 30 | const portProbe: BrowserLaunchObservation['portProbe'] = { attempts: 0, successfulReads: 0, failedReads: 0, lastReadError: null, discovered: false } |
| 28 | 31 | let pageVisited = false |
| 29 | const retain = (phase: BrowserLaunchObservation['phase']) => retainBrowserLaunchEvidence(root, { phase, elapsedMs: performance.now() - began, launch: { ...launch }, portProbe: { ...portProbe } }, { startupStderr: !pageVisited, exportDirectory: process.env.WORKSPACE_BROWSER_EVIDENCE ?? process.env.EXECUTION_BROWSER_EVIDENCE }) | |
| 32 | const retain = (phase: BrowserLaunchObservation['phase']) => retainBrowserLaunchEvidence(root, { phase, elapsedMs: performance.now() - began, launch: { ...launch }, portProbe: { ...portProbe }, startup: startup.evidence() }, { startupStderr: !pageVisited, exportDirectory: process.env.WORKSPACE_BROWSER_EVIDENCE ?? process.env.EXECUTION_BROWSER_EVIDENCE }) | |
| 33 | const cleanup = async () => { | |
| 34 | closing = true; launch.cleanupRequested = true | |
| 35 | if (child.exitCode === null) child.kill() | |
| 36 | await Promise.race([child.exited, Bun.sleep(2000)]) | |
| 37 | if (!pageVisited) await startup.cleanupEnded() | |
| 38 | } | |
| 30 | 39 | void child.exited.then(code => { |
| 31 | 40 | launch.exitObserved = true; launch.settledExitCode = code; launch.signalCode = child.signalCode; launch.exitObservedBeforeCleanup = !closing; launch.exitElapsedMs = performance.now() - began |
| 32 | 41 | if (!closing) console.log(`QA isolated Chrome exited unexpectedly: ${code}, elapsedMs:${Date.now() - started}`) |
| 33 | 42 | }) |
| 34 | 43 | let port = '' |
| 35 | 44 | for (let attempt = 0; attempt < 400; attempt++) { |
| 45 | startup.tick() | |
| 36 | 46 | portProbe.attempts++ |
| 37 | 47 | port = await readFile(join(profile, 'DevToolsActivePort'), 'utf8').then(value => { portProbe.successfulReads++; portProbe.lastReadError = null; return value.split('\n')[0]! }).catch(error => { portProbe.failedReads++; portProbe.lastReadError = browserFileErrorCode(error); return '' }) |
| 38 | 48 | if (port) { portProbe.discovered = true; break } |
| 39 | 49 | await Bun.sleep(50) |
| 40 | 50 | } |
| 41 | if (!port) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('startup-unavailable'); throw new Error('Fresh browser must start') } | |
| 51 | await startup.finishDiscovery() | |
| 52 | if (!port) { await cleanup(); await retain('startup-unavailable'); throw new Error('Fresh browser must start') } | |
| 42 | 53 | // Keep a browser-level session, as well as page sessions, for the owned lifecycle. |
| 43 | 54 | let browserSocket: WebSocket |
| 44 | 55 | try { |
| 45 | 56 | const response = await observeBrowserOperation('version-fetch', () => fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })) |
| 46 | const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string } | |
| 57 | const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string, Browser?: unknown } | |
| 58 | startup.recordVersion(version.Browser) | |
| 47 | 59 | browserSocket = await observeBrowserOperation('browser-socket', () => openSocket(version.webSocketDebuggerUrl)) |
| 48 | 60 | } |
| 49 | catch (error) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('discovery-failed'); throw error } | |
| 61 | catch (error) { await cleanup(); await retain('discovery-failed'); throw error } | |
| 50 | 62 | let browserSequence = 0 |
| 51 | 63 | browserSocket.onmessage = () => {} |
| 52 | 64 | const keepAlive = setInterval(() => { if (browserSocket.readyState === WebSocket.OPEN) browserSocket.send(JSON.stringify({ id: ++browserSequence, method: 'Browser.getVersion' })) }, 5000) |
| @@ -96,6 +108,6 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 96 | 108 | await page.wait(javascript ? "document.querySelector('#incident-key') !== null && document.querySelector('[data-workspace-status]')?.textContent.trim() === 'disconnected'" : "document.querySelector('#incident-key') !== null", 'Compiled workspace must initialize') |
| 97 | 109 | return page |
| 98 | 110 | }, |
| 99 | async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); closing = true; launch.cleanupRequested = true; clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('closed') }, | |
| 111 | async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); await cleanup(); await retain('closed') }, | |
| 100 | 112 | } |
| 101 | 113 | } |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 2 | import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' | |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | 5 | import process from 'node:process' |
| @@ -25,6 +25,11 @@ test.skipIf(process.platform === 'win32')('failed native launch retains startup | ||
| 25 | 25 | expect(report.launch.cleanupRequested).toBe(true) |
| 26 | 26 | expect(report.portProbe.attempts).toBe(400) |
| 27 | 27 | expect(report.portProbe.lastReadError).toBe('ENOENT') |
| 28 | expect(report.startup.platform).toBe(process.platform) | |
| 29 | expect(report.startup.method).toBe('startup-only-process-observation-v1') | |
| 30 | expect(report.startup.snapshots.at(-1).label).toBe('cleanup-ended') | |
| 31 | expect(report.startup.snapshots.at(-1).root.pid).toBe(actualPid) | |
| 32 | expect(report.startup.snapshots.at(-1).root.probe).toBe('missing') | |
| 28 | 33 | expect(report.stderr.status).toBe('retained') |
| 29 | 34 | expect(report.stderr.retainedBytes).toBeLessThanOrEqual(65536) |
| 30 | 35 | expect(await readFile(join(root, 'chrome-startup-stderr.log'), 'utf8')).toContain('controlled native browser startup refusal') |
| @@ -46,3 +51,57 @@ test.skipIf(process.platform === 'win32')('failed native launch retains startup | ||
| 46 | 51 | else console.log(JSON.stringify({ evidence: 'controlled-native-launch', retainedRoot: root, passed })) |
| 47 | 52 | } |
| 48 | 53 | }, 30000) |
| 54 | ||
| 55 | // The existing native launch seam checks observed processes, not the cause of a Chrome failure. | |
| 56 | test.skipIf(process.platform === 'win32')('a live native launcher and child remain observable until cleanup without exporting argv or environment', async () => { | |
| 57 | const supplied = process.env.BROWSER_LAUNCH_LIVE_EVIDENCE | |
| 58 | const root = supplied ? (await mkdir(supplied), supplied) : await mkdtemp(join(tmpdir(), 'drone-browser-live-')) | |
| 59 | const executable = join(root, 'launch.ts'), pidPath = join(root, 'pids.json') | |
| 60 | const original = process.env.BROWSE_BROWSER | |
| 61 | let passed = false | |
| 62 | try { | |
| 63 | await writeFile(executable, `#!${process.execPath}\nimport { writeFileSync } from 'node:fs'\nconst child=Bun.spawn([process.execPath,'-e','setInterval(()=>{},1000)','PRIVATE_CHILD_ARG_SENTINEL'],{stdout:'ignore',stderr:'ignore',env:{...process.env,PRIVATE_FIXTURE_TOKEN:'PRIVATE_CHILD_ENV_SENTINEL'}})\nwriteFileSync(${JSON.stringify(pidPath)},JSON.stringify([process.pid,child.pid]))\nprocess.on('SIGTERM',async()=>{child.kill();await child.exited;process.exit(0)})\nconsole.error('controlled live browser startup fixture')\nsetInterval(()=>{},1000)\n`, { mode: 0o700 }) | |
| 64 | process.env.BROWSE_BROWSER = executable | |
| 65 | await expect(startWorkspaceBrowser(root)).rejects.toThrow('Fresh browser must start') | |
| 66 | const [pid, childPid] = JSON.parse(await readFile(pidPath, 'utf8')) as [number, number] | |
| 67 | const text = await readFile(join(root, 'browser-launch.json'), 'utf8'), report = JSON.parse(text) | |
| 68 | expect(text).not.toContain('PRIVATE_CHILD_ARG_SENTINEL') | |
| 69 | expect(text).not.toContain('PRIVATE_CHILD_ENV_SENTINEL') | |
| 70 | expect(report.portProbe.attempts).toBe(400) | |
| 71 | expect(report.launch.exitObservedBeforeCleanup).toBe(false) | |
| 72 | expect(report.launch.exitObserved).toBe(true) | |
| 73 | const before = report.startup.snapshots.find((s: any) => s.label === 'discovery-ended') | |
| 74 | expect(before.root.pid).toBe(pid) | |
| 75 | expect(before.root.probe).toBe('present') | |
| 76 | expect(report.startup.snapshots.at(-1).root.probe).toBe('missing') | |
| 77 | expect(report.startup.executables).toBeArray() | |
| 78 | if (process.platform === 'linux') { | |
| 79 | expect(before.linux.status).toBe('observed') | |
| 80 | expect(before.linux.root.startTicks).toMatch(/^\d+$/) | |
| 81 | expect(before.linux.root.rssKiB).toBeGreaterThan(0) | |
| 82 | expect(before.linux.descendants.some((p: any) => p.pid === childPid && p.parentPid === pid)).toBe(true) | |
| 83 | const identity = report.startup.executables.find((e: any) => e.observedPids.includes(pid) && e.sha256 !== null) | |
| 84 | expect(identity).toBeDefined() | |
| 85 | expect(identity.byteLength).toBe((await stat(process.execPath)).size) | |
| 86 | expect(identity.sha256).toBe(new Bun.CryptoHasher('sha256').update(await Bun.file(process.execPath).arrayBuffer()).digest('hex')) | |
| 87 | expect(identity.hashedAfterDiscovery).toBe(true) | |
| 88 | } | |
| 89 | else expect(before.linux.status).toBe('unsupported') | |
| 90 | expect(() => process.kill(pid, 0)).toThrow() | |
| 91 | expect(() => process.kill(childPid, 0)).toThrow() | |
| 92 | passed = true | |
| 93 | } | |
| 94 | finally { | |
| 95 | if (original === undefined) delete process.env.BROWSE_BROWSER | |
| 96 | else process.env.BROWSE_BROWSER = original | |
| 97 | const pids: number[] = JSON.parse(await readFile(pidPath, 'utf8').catch(() => '[]')) | |
| 98 | for (const pid of pids) { | |
| 99 | let gone = false | |
| 100 | try { process.kill(pid, 0) } | |
| 101 | catch (error) { gone = (error as NodeJS.ErrnoException).code === 'ESRCH' } | |
| 102 | expect(gone).toBe(true) | |
| 103 | } | |
| 104 | if (passed && !supplied) await rm(root, { recursive: true, force: true }) | |
| 105 | else console.log(JSON.stringify({ evidence: 'controlled-live-native-launch', retainedRoot: root, passed })) | |
| 106 | } | |
| 107 | }, 35000) | |
| @@ -0,0 +1,80 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtemp, readlink, readdir, rm } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { createBrowserStartupObserver } from '../helpers/browser-startup-process' | |
| 6 | import type { StartupProcessAdapter } from '../helpers/browser-startup-process' | |
| 7 | import { createLinuxStartupObservation } from '../helpers/browser-startup-linux' | |
| 8 | ||
| 9 | // OS adapter seam: a held syscall promise checks ownership/cancellation, not Linux field acquisition. | |
| 10 | test('an expired observation remains owned through actual settlement and prevents later provider work', async () => { | |
| 11 | let release!: () => void | |
| 12 | const held = new Promise<void>(resolve => { release = resolve }) | |
| 13 | let calls = 0, active = 0, maximumActive = 0, finalized = false | |
| 14 | const settlements: boolean[] = [] | |
| 15 | const adapter: StartupProcessAdapter = { | |
| 16 | async snapshot() { | |
| 17 | calls++; active++; maximumActive = Math.max(maximumActive, active) | |
| 18 | try { if (calls === 1) await held; return { status: 'unavailable', code: 'CONTROLLED_OS' } } | |
| 19 | finally { active-- } | |
| 20 | }, | |
| 21 | async settleExecutables(hash) { expect(active).toBe(0); settlements.push(hash) }, | |
| 22 | evidence: () => ({ executables: [], identityErrors: [], executableLimitReached: false }), | |
| 23 | } | |
| 24 | const observer = createBrowserStartupObserver(process.pid, performance.now() - 1001, adapter) | |
| 25 | let finishing: Promise<void> | undefined | |
| 26 | try { | |
| 27 | observer.tick() | |
| 28 | await Bun.sleep(600) | |
| 29 | observer.tick() | |
| 30 | expect(calls).toBe(1) | |
| 31 | finishing = observer.finishDiscovery().then(() => { finalized = true }) | |
| 32 | await Bun.sleep(10) | |
| 33 | expect(finalized).toBe(false) | |
| 34 | expect(settlements).toEqual([]) | |
| 35 | release(); await finishing | |
| 36 | expect(maximumActive).toBe(1) | |
| 37 | expect(calls).toBe(1) | |
| 38 | expect(settlements).toEqual([false]) | |
| 39 | const final = observer.evidence() | |
| 40 | observer.tick(); await Bun.sleep(10) | |
| 41 | expect(observer.evidence()).toEqual(final) | |
| 42 | expect(final.snapshots[0]!.linux.status).toBe('timeout') | |
| 43 | expect(final.snapshots[0]!.completedAtMs).toBeGreaterThan(final.snapshots[0]!.elapsedMs + 500) | |
| 44 | } | |
| 45 | finally { release(); await finishing; await Bun.sleep(0) } | |
| 46 | }) | |
| 47 | ||
| 48 | test.skipIf(process.platform !== 'linux')('cancellation closes actual pinned executable descriptors without hashing after the held native sample settles', async () => { | |
| 49 | const root = await mkdtemp(join(tmpdir(), 'drone-observer-descriptors-')) | |
| 50 | const executable = await readlink('/proc/self/exe') | |
| 51 | const descriptors = async () => { | |
| 52 | const ids = await readdir('/proc/self/fd') | |
| 53 | const targets = await Promise.all(ids.map(id => readlink(`/proc/self/fd/${id}`).catch(() => null))) | |
| 54 | return targets.filter(path => path === executable).length | |
| 55 | } | |
| 56 | const before = await descriptors(), began = performance.now() | |
| 57 | const native = createLinuxStartupObservation(process.pid, root, began) | |
| 58 | let release!: () => void, ready!: () => void | |
| 59 | const held = new Promise<void>(resolve => { release = resolve }), captured = new Promise<void>(resolve => { ready = resolve }) | |
| 60 | const observer = createBrowserStartupObserver(process.pid, began, { | |
| 61 | async snapshot(signal) { | |
| 62 | const result = await native.snapshot(signal).catch(() => ({ status: 'unavailable' as const, code: 'NATIVE_UNAVAILABLE' })) | |
| 63 | ready(); await held; return result | |
| 64 | }, | |
| 65 | settleExecutables: hash => native.settleExecutables(hash), evidence: native.evidence, | |
| 66 | }) | |
| 67 | let finishing: Promise<void> | undefined | |
| 68 | try { | |
| 69 | observer.tick(); await captured | |
| 70 | expect(await descriptors()).toBeGreaterThan(before) | |
| 71 | await Bun.sleep(600) | |
| 72 | finishing = observer.finishDiscovery() | |
| 73 | release(); await finishing | |
| 74 | const report = observer.evidence() | |
| 75 | expect(report.executables.length).toBeGreaterThan(0) | |
| 76 | expect(report.executables.every(e => e.sha256 === null && e.code === 'OBSERVATION_CANCELED' && e.closeCode === null)).toBe(true) | |
| 77 | expect(await descriptors()).toBe(before) | |
| 78 | } | |
| 79 | finally { release(); await (finishing ?? observer.finishDiscovery()); await rm(root, { recursive: true, force: true }) } | |
| 80 | }) | |