| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { spawn } from "node:child_process"; |
| 4 | import { mkdirSync, readFileSync, writeFileSync, createWriteStream } from "node:fs"; |
| 5 | import { createTimings } from "./app-memory-timing.mjs"; |
| 6 | import { once } from "node:events"; |
| 7 | import path from "node:path"; |
| 8 | import { fileURLToPath } from "node:url"; |
| 9 | import { startPreviewServer } from "./vite-preview-server.mjs"; |
| 10 | import { readActiveSessionLabel, selectSession } from "./app-page-actions.mjs"; |
| 11 | import { attributeRetention, buildIdentity, evidenceIntegrity, retainedCohorts, screeningBlockers, summarizeHeap } from "./app-memory-evidence.mjs"; |
| 12 | import { completeShard, memoryProtocol, protocolSamples, verifyIdentity, MEMORY_FIXTURES } from "./app-memory-shards.mjs"; |
| 13 | |
| 14 | const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 15 | process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers" |
| 16 | ? path.join(frontendDir, ".pw-browsers") |
| 17 | : process.env.PLAYWRIGHT_BROWSERS_PATH; |
| 18 | // Playwright reads PLAYWRIGHT_BROWSERS_PATH at module evaluation; import it |
| 19 | // only after the path normalization above. |
| 20 | const { chromium } = await import("playwright"); |
| 21 | |
| 22 | function integerEnv(name, fallback) { |
| 23 | const value = Number(process.env[name]); |
| 24 | return Number.isInteger(value) && value > 0 ? value : fallback; |
| 25 | } |
| 26 | |
| 27 | const MEMORY_PROTOCOL = memoryProtocol(process.env.REASONIX_APP_MEMORY_PROFILE ?? "full"); |
| 28 | const CYCLES = integerEnv("REASONIX_APP_MEMORY_CYCLES", MEMORY_PROTOCOL.cycles); |
| 29 | const MIXED_CYCLES = integerEnv("REASONIX_APP_MEMORY_MIXED_CYCLES", MEMORY_PROTOCOL.mixedCycles); |
| 30 | const BASELINE_ATTEMPTS = integerEnv("REASONIX_APP_MEMORY_BASELINE_ATTEMPTS", 4); |
| 31 | const SHARD = process.env.REASONIX_APP_MEMORY_SHARD === undefined ? null : Number(process.env.REASONIX_APP_MEMORY_SHARD); |
| 32 | if (SHARD !== null && (!Number.isInteger(SHARD) || SHARD < 1 || SHARD > MEMORY_PROTOCOL.shards)) throw new Error(`memory shard must be between 1 and ${MEMORY_PROTOCOL.shards}`); |
| 33 | const PROCESSES = SHARD === null ? integerEnv("REASONIX_APP_MEMORY_PROCESSES", MEMORY_PROTOCOL.shards) : 1; |
| 34 | const preparedFile = process.env.REASONIX_APP_MEMORY_PREPARED; |
| 35 | const prepared = preparedFile ? JSON.parse(readFileSync(preparedFile, "utf8")) : null; |
| 36 | if (SHARD !== null && (!prepared || CYCLES !== MEMORY_PROTOCOL.cycles || MIXED_CYCLES !== MEMORY_PROTOCOL.mixedCycles |
| 37 | || JSON.stringify(prepared.protocol) !== JSON.stringify(MEMORY_PROTOCOL))) throw new Error(`memory shard requires the shared build and complete ${MEMORY_PROTOCOL.profile} protocol`); |
| 38 | const PORT = integerEnv("REASONIX_APP_MEMORY_PORT", 4647); |
| 39 | const artifacts = path.resolve(process.env.REASONIX_APP_MEMORY_ARTIFACTS ?? path.join(frontendDir, "bench/app-memory-artifacts")); |
| 40 | mkdirSync(artifacts, { recursive: true }); |
| 41 | |
| 42 | const fixtures = MEMORY_FIXTURES; |
| 43 | const timings = createTimings(); |
| 44 | |
| 45 | async function ensureBuild() { |
| 46 | if (prepared) { |
| 47 | verifyIdentity(buildIdentity(frontendDir), prepared.identity); |
| 48 | if (!prepared.executionId || prepared.identity.sourceSHA !== process.env.EXPECTED_SOURCE_SHA) throw new Error("prepared memory build belongs to another commit"); |
| 49 | return; |
| 50 | } |
| 51 | // Each run owns a fresh production build; an unverified dist is not evidence. |
| 52 | await new Promise((resolve, reject) => { |
| 53 | const child = spawn("pnpm", ["build"], { cwd: frontendDir, stdio: "inherit" }); |
| 54 | child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`pnpm build exited ${code}`))); |
| 55 | }); |
| 56 | } |
| 57 | |
| 58 | async function settleFrames(page, count = 6) { |
| 59 | await timings.measure("settle.frames", () => page.evaluate((frames) => new Promise((resolve) => { |
| 60 | const tick = () => --frames <= 0 ? resolve() : requestAnimationFrame(tick); |
| 61 | requestAnimationFrame(tick); |
| 62 | }), count)); |
| 63 | } |
| 64 | |
| 65 | async function selectFixture(page, fixture) { |
| 66 | const active = await timings.measure("navigation.active", () => readActiveSessionLabel(page)); |
| 67 | if (active?.includes(fixture.label)) throw new Error(`invalid repeated navigation: ${fixture.label}`); |
| 68 | await timings.measure("navigation.click", () => selectSession(page, fixture.label)); |
| 69 | // Sample a resting page, not a hover card whose 350ms timer races hydration. |
| 70 | await timings.measure("navigation.pointer", () => page.mouse.move(0, 0)); |
| 71 | await timings.measure("navigation.ready", () => page.waitForFunction(({ label, marker }) => { |
| 72 | const activeLabel = document.querySelector('.project-tree__topic--active .project-tree__topic-label')?.textContent ?? ""; |
| 73 | const transcript = document.querySelector(".transcript"); |
| 74 | return activeLabel.includes(label) |
| 75 | && transcript?.dataset.transcriptHydrating === "false" |
| 76 | && transcript.textContent?.includes(marker) |
| 77 | && !document.querySelector(".transcript-navigation-overlay"); |
| 78 | }, fixture, { timeout: 45_000, polling: "raf" })); |
| 79 | await settleFrames(page); |
| 80 | } |
| 81 | |
| 82 | async function forceGc(cdp, page) { |
| 83 | await timings.measure("gc.collect", () => cdp.send("HeapProfiler.collectGarbage")); |
| 84 | await settleFrames(page, 2); |
| 85 | await timings.measure("gc.collect", () => cdp.send("HeapProfiler.collectGarbage")); |
| 86 | await settleFrames(page, 2); |
| 87 | const [heap, dom, lifecycle, performance] = await Promise.all([ |
| 88 | cdp.send("Runtime.getHeapUsage"), |
| 89 | cdp.send("Memory.getDOMCounters"), |
| 90 | page.evaluate(() => window.__reasonixAppLifecycle?.snapshot()), |
| 91 | page.evaluate(() => ({ entries: window.performance.getEntries().length, attachedElements: document.querySelectorAll("*").length })), |
| 92 | ]); |
| 93 | if (!lifecycle) throw new Error("App lifecycle probe was not published by the production build"); |
| 94 | return { heap, dom, lifecycle, performance }; |
| 95 | } |
| 96 | |
| 97 | async function enterSafety(page) { |
| 98 | await selectFixture(page, fixtures.windowed); |
| 99 | await page.evaluate(() => { |
| 100 | const transcript = document.querySelector(".transcript"); |
| 101 | if (!(transcript instanceof HTMLElement)) throw new Error("transcript viewport missing"); |
| 102 | window.__reasonixMemoryScrollWrites = []; |
| 103 | window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => window.__reasonixMemoryScrollWrites.push(write); |
| 104 | Object.defineProperty(transcript, "scrollHeight", { configurable: true, get: () => Number.NaN }); |
| 105 | const probe = document.createElement("span"); |
| 106 | probe.hidden = true; |
| 107 | probe.dataset.memoryScrollProbe = "true"; |
| 108 | transcript.querySelector(".chat-column")?.append(probe); |
| 109 | }); |
| 110 | await timings.measure("safety.ready", () => page.waitForFunction(() => ( |
| 111 | window.__reasonixMemoryScrollWrites?.some((write) => write.rejectedReason === "invalid-geometry") |
| 112 | ), undefined, { timeout: 15_000, polling: "raf" })); |
| 113 | await page.evaluate(() => { |
| 114 | const transcript = document.querySelector(".transcript"); |
| 115 | if (transcript instanceof HTMLElement) delete transcript.scrollHeight; |
| 116 | transcript?.querySelector("[data-memory-scroll-probe]")?.remove(); |
| 117 | delete window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__; |
| 118 | delete window.__reasonixMemoryScrollWrites; |
| 119 | }); |
| 120 | await settleFrames(page); |
| 121 | } |
| 122 | |
| 123 | async function heapSnapshot(cdp, name) { |
| 124 | const file = path.join(artifacts, `${name}.heapsnapshot`); |
| 125 | const output = createWriteStream(file); |
| 126 | const listener = ({ chunk }) => output.write(chunk); |
| 127 | cdp.on("HeapProfiler.addHeapSnapshotChunk", listener); |
| 128 | try { await timings.measure("heap.capture", () => cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false, captureNumericValue: true })); } |
| 129 | finally { cdp.off("HeapProfiler.addHeapSnapshotChunk", listener); output.end(); } |
| 130 | await once(output, "finish"); |
| 131 | const summary = await timings.measure("heap.summarize", () => summarizeHeap(JSON.parse(readFileSync(file, "utf8")))); |
| 132 | writeFileSync(path.join(artifacts, `${name}.summary.json`), JSON.stringify(summary, null, 2)); |
| 133 | return { file: path.basename(file), summary }; |
| 134 | } |
| 135 | |
| 136 | async function runProcess(index) { |
| 137 | const browser = await chromium.launch({ |
| 138 | headless: true, |
| 139 | args: ["--enable-precise-memory-info", "--disable-dev-shm-usage"], |
| 140 | }); |
| 141 | const context = await browser.newContext({ viewport: MEMORY_PROTOCOL.viewport }); |
| 142 | const page = await context.newPage(); |
| 143 | const pageErrors = []; |
| 144 | page.on("pageerror", (error) => pageErrors.push(error.message)); |
| 145 | const cdp = await context.newCDPSession(page); |
| 146 | try { |
| 147 | await page.goto(`http://127.0.0.1:${PORT}/?mock=bench&bench=1&app-lifecycle-probe=1&bench-hydration=soak`, { waitUntil: "domcontentloaded" }); |
| 148 | await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor(); |
| 149 | await selectFixture(page, fixtures.geometry); |
| 150 | await selectFixture(page, fixtures.full); |
| 151 | await enterSafety(page); |
| 152 | await selectFixture(page, fixtures.full); |
| 153 | await page.mouse.move(0, 0); |
| 154 | await settleFrames(page); |
| 155 | // Baseline and checkpoints share a post-navigation resting state. Layout |
| 156 | // controls can still own transient listeners immediately after closing, so |
| 157 | // one early reading can sit above the resting value and make every later |
| 158 | // reading look displaced. Settle and require consecutive identical readings |
| 159 | // before accepting the baseline; an unsettled baseline is reported instead |
| 160 | // of being judged as drift. |
| 161 | // |
| 162 | // Warm every measured fixture the same way first. The safety excursion is |
| 163 | // the only earlier windowed visit and it runs with deliberately invalid |
| 164 | // geometry, so without this round trip the windowed surface reaches its |
| 165 | // first healthy render after the baseline and its one-time bounded setup |
| 166 | // is reported as drift. Accumulation is still measured: each phase keeps |
| 167 | // sampling every 32 round trips against this baseline. |
| 168 | await selectFixture(page, fixtures.windowed); |
| 169 | await selectFixture(page, fixtures.geometry); |
| 170 | await selectFixture(page, fixtures.full); |
| 171 | const samples = []; |
| 172 | const baselineReadings = []; |
| 173 | for (let attempt = 1; attempt <= BASELINE_ATTEMPTS; attempt++) { |
| 174 | await settleFrames(page, 12); |
| 175 | const reading = await forceGc(cdp, page); |
| 176 | baselineReadings.push({ nodes: reading.dom.nodes, jsEventListeners: reading.dom.jsEventListeners }); |
| 177 | const previous = baselineReadings.at(-2); |
| 178 | const stable = previous && previous.nodes === reading.dom.nodes && previous.jsEventListeners === reading.dom.jsEventListeners; |
| 179 | if (stable || attempt === BASELINE_ATTEMPTS) { |
| 180 | samples.push({ phase: "baseline", roundTrips: 0, baselineStable: Boolean(stable), baselineReadings, ...reading }); |
| 181 | process.stdout.write(`[app-memory] process=${index} phase=baseline stable=${Boolean(stable)} readings=${JSON.stringify(baselineReadings)}\n`); |
| 182 | break; |
| 183 | } |
| 184 | } |
| 185 | const snapshots = [await heapSnapshot(cdp, `${index}-baseline`)]; |
| 186 | for (const phase of ["full", "windowed", "safety", "mixed"]) { |
| 187 | const count = phase === "mixed" ? MIXED_CYCLES : CYCLES; |
| 188 | for (let round = 1; round <= count; round++) { |
| 189 | const safety = phase === "safety" || phase === "mixed" && round % 3 === 0; |
| 190 | if (safety) await enterSafety(page); |
| 191 | else await selectFixture(page, phase === "full" || phase === "mixed" && round % 3 === 1 ? fixtures.geometry : fixtures.windowed); |
| 192 | await selectFixture(page, fixtures.full); |
| 193 | if (round % 32 === 0 || round === count) { |
| 194 | const sample = { phase, roundTrips: round, ...await forceGc(cdp, page) }; |
| 195 | samples.push(sample); |
| 196 | writeFileSync(path.join(artifacts, `${index}-samples.json`), JSON.stringify(samples, null, 2)); |
| 197 | process.stdout.write(`[app-memory] process=${index} phase=${phase} roundTrips=${round} nodes=${sample.dom.nodes} listeners=${sample.dom.jsEventListeners} tokens=${sample.lifecycle.liveRenderTokens}\n`); |
| 198 | } |
| 199 | } |
| 200 | snapshots.push(await heapSnapshot(cdp, `${index}-${phase}`)); |
| 201 | writeFileSync(path.join(artifacts, "timings.json"), JSON.stringify(timings.snapshot(), null, 2)); |
| 202 | } |
| 203 | // The classifier blocks on a displaced final tail, so the tail must be |
| 204 | // measured at rest: mid-cleanup listener blips (614 vs the 512 baseline) |
| 205 | // resolve a few tasks after the last navigation. Settle, GC, and take the |
| 206 | // quiescent confirmation sample the verdict actually judges. |
| 207 | await settleFrames(page, 12); |
| 208 | const settled = { phase: "settled", roundTrips: MIXED_CYCLES, ...await forceGc(cdp, page) }; |
| 209 | samples.push(settled); |
| 210 | writeFileSync(path.join(artifacts, `${index}-samples.json`), JSON.stringify(samples, null, 2)); |
| 211 | process.stdout.write(`[app-memory] process=${index} phase=settled nodes=${settled.dom.nodes} listeners=${settled.dom.jsEventListeners} tokens=${settled.lifecycle.liveRenderTokens}\n`); |
| 212 | return { |
| 213 | process: index, |
| 214 | browser: browser.version(), |
| 215 | samples, |
| 216 | snapshots, |
| 217 | cohorts: retainedCohorts(samples), |
| 218 | attribution: "pending", |
| 219 | checks: { |
| 220 | evidenceIntegrity: evidenceIntegrity(samples), |
| 221 | instrumentedOperationsReleased: samples.every(sample => sample.lifecycle.activeOperations === 0), |
| 222 | noPageErrors: pageErrors.length === 0, |
| 223 | }, |
| 224 | metrics: { pageErrors }, |
| 225 | }; |
| 226 | } finally { |
| 227 | await context.close(); |
| 228 | await browser.close(); |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | await ensureBuild(); |
| 233 | const preview = await startPreviewServer(frontendDir, PORT); |
| 234 | const report = { identity: buildIdentity(frontendDir), fixtures, protocol: MEMORY_PROTOCOL, startedAt: new Date().toISOString(), cycles: CYCLES, mixedCycles: MIXED_CYCLES, |
| 235 | ...(SHARD === null ? {} : { shard: { id: SHARD, total: MEMORY_PROTOCOL.shards, executionId: prepared.executionId } }), processes: [] }; |
| 236 | try { |
| 237 | for (let index = 1; index <= PROCESSES; index += 1) { |
| 238 | const result = await runProcess(SHARD ?? index); |
| 239 | result.attribution = attributeRetention(result.samples, result.cohorts); |
| 240 | report.processes.push(result); |
| 241 | process.stdout.write(`[app-memory] process ${index}: ${JSON.stringify({ checks: result.checks, attribution: result.attribution, metrics: result.metrics })}\n`); |
| 242 | } |
| 243 | } catch (error) { |
| 244 | report.failure = error.message; |
| 245 | } finally { |
| 246 | await preview.close(); |
| 247 | } |
| 248 | report.finishedAt = new Date().toISOString(); |
| 249 | report.timings = timings.snapshot(); |
| 250 | writeFileSync(path.join(artifacts, "timings.json"), JSON.stringify(report.timings, null, 2)); |
| 251 | process.stdout.write(`[app-memory] timings ${JSON.stringify(report.timings)}\n`); |
| 252 | report.protocolComplete = CYCLES === MEMORY_PROTOCOL.cycles && MIXED_CYCLES === MEMORY_PROTOCOL.mixedCycles |
| 253 | && report.processes.length === MEMORY_PROTOCOL.shards && report.processes.every(run => protocolSamples(run.samples, MEMORY_PROTOCOL)); |
| 254 | report.shardComplete = SHARD !== null && completeShard(report, MEMORY_PROTOCOL); |
| 255 | // The automated gate passes on clean screening: protocol complete, every |
| 256 | // integrity/release/page-error check true, and no disqualifying attribution |
| 257 | // reason. Heap-retainer and control attribution stays an offline duty |
| 258 | // recorded in each run's attribution reasons. |
| 259 | report.verdict = !report.failure && (report.protocolComplete || report.shardComplete) |
| 260 | && report.processes.every((run) => Object.values(run.checks).every(Boolean) |
| 261 | && screeningBlockers(run.attribution?.reasons ?? ["missing-attribution"]).length === 0) |
| 262 | ? SHARD === null ? "PASS" : "SHARD_PASS" : report.failure ? "FAIL" : "NEEDS_ATTRIBUTION"; |
| 263 | writeFileSync(path.join(artifacts, "report.json"), JSON.stringify(report, null, 2)); |
| 264 | process.stdout.write(`[app-memory] verdict ${report.verdict}\n`); |
| 265 | process.exitCode = report.verdict === "PASS" || report.verdict === "SHARD_PASS" ? 0 : 1; |
| 266 |