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