返回 DeepSeek-Reasonix
app-memory.mjs
根目录 / desktop / frontend / bench / app-memory.mjs
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
266 lines Plain Text