返回 DeepSeek-Reasonix
crash.ts
根目录 / desktop / frontend / src / lib / crash.ts
1 // Last-resort crash surface: a React render error with no boundary unmounts the
2 // whole tree (blank window), and global errors/rejections leave no trace either.
3 import { addBreadcrumb, dumpBreadcrumbs, snapshotBreadcrumbs, type Breadcrumb } from "./breadcrumbs";
4 import { writeClipboardText } from "./clipboard";
5 import { desktopHost } from "./desktopHost";
6 import { fmtNumber, formatPerformanceContext } from "./performanceReportFormat";
7 export { formatPerformanceContext } from "./performanceReportFormat";
8 import { boundedDiagnostics, type ProcessDiagnosticsSnapshot, type RendererProfileResult } from "./processDiagnostics";
9 import { t } from "./i18n";
10 import { sessionPipelineDiagnostics, type SessionPipelineDiagnostics } from "./sessionDiagnostics";
11 declare const __BUILD_COMMIT__: string;
12 declare const __BUILD_CHANNEL__: string;
13
14 export type CrashKind = "crash" | "exception" | "feedback" | "performance" | "bot";
15
16 export type PerformanceSnapshot = {
17 reason: string;
18 uptimeMs: number;
19 visibility: string;
20 focused: boolean;
21 online: boolean;
22 hardwareConcurrency: number;
23 deviceMemoryGb?: number;
24 jsHeap?: {
25 usedMb: number;
26 totalMb: number;
27 limitMb: number;
28 usagePercent?: number;
29 };
30 eventLoopLag?: {
31 currentMs: number;
32 maxMs: number;
33 avgMs: number;
34 samples: number;
35 };
36 longTasks?: {
37 count: number;
38 totalMs: number;
39 maxMs: number;
40 recent: { startMs: number; durationMs: number; attribution?: string }[];
41 };
42 longTaskFrames?: { label: string; samples: number }[];
43 cpuProfile?: RendererProfileResult;
44 processes?: ProcessDiagnosticsSnapshot;
45 connection?: {
46 effectiveType?: string;
47 downlinkMbps?: number;
48 rttMs?: number;
49 saveData?: boolean;
50 };
51 // Session-switch/history pipeline diagnostics (Phase F): last activation
52 // timings, last HistorySlice page stats with index hit/miss, virtual mounted
53 // rows, markdown worker counters, transcript cache weights. All optional —
54 // absent before the first switch/page or when a provider never registered.
55 sessionPipeline?: SessionPipelineDiagnostics;
56 };
57
58 export type CrashPayload = {
59 schemaVersion: 2;
60 source: "frontend" | "frontend.react" | "frontend.global" | "frontend.performance" | "bot.runtime";
61 kind: CrashKind;
62 label: string;
63 message: string;
64 errorType: string;
65 errorMessage: string;
66 stack?: string;
67 componentStack?: string;
68 topFrame?: string;
69 // Optional, non-display grouping context for otherwise opaque WebView errors.
70 // It is deliberately restricted to build/view/breadcrumb categories and never
71 // contains breadcrumb messages, tab IDs, paths, or user content.
72 fingerprintHint?: string;
73 errorFamily?: string;
74 buildCommit: string;
75 channel: string;
76 language: string;
77 view: string;
78 breadcrumbs: Breadcrumb[];
79 occurredAt: string;
80 };
81
82 type NormalizedError = {
83 errorType: string;
84 errorMessage: string;
85 stack?: string;
86 };
87
88 type LongTaskSample = {
89 startMs: number;
90 durationMs: number;
91 attribution?: string;
92 };
93
94 type BrowserPerformanceMemory = {
95 usedJSHeapSize?: number;
96 totalJSHeapSize?: number;
97 jsHeapSizeLimit?: number;
98 };
99
100 type BrowserNavigator = Navigator & {
101 deviceMemory?: number;
102 connection?: {
103 effectiveType?: string;
104 downlink?: number;
105 rtt?: number;
106 saveData?: boolean;
107 };
108 };
109
110 const LONG_TASK_WINDOW_MS = 60_000;
111 const LONG_TASK_PROMPT_MS = 800;
112 // Streaming renders routinely accumulate ~1.5s of 70-240ms tasks per minute without
113 // user-visible jank, so the cumulative prompt only fires past half of that budget spent blocked.
114 const LONG_TASK_TOTAL_PROMPT_MS = 3_000;
115 const EVENT_LOOP_LAG_PROMPT_MS = 1_200;
116 const EVENT_LOOP_LAG_CONSECUTIVE_SAMPLES = 2;
117 const STARTUP_GRACE_MS = 15_000;
118 const PROMPT_COOLDOWN_MS = 10 * 60_000;
119 const MAX_LAG_SAMPLES = 60;
120 const VISIBILITY_RESUME_GRACE_MS = 5_000;
121
122 const longTasks: LongTaskSample[] = [];
123 const lagSamples: number[] = [];
124 let performanceMonitorInstalled = false;
125 let lastPerformancePromptAt = 0;
126 let heapSnapshotInProgress = false;
127 let diagnosticQuietUntil = 0;
128 let activeCaptureId: string | undefined;
129 function cancelCapture(requestId = activeCaptureId): void {
130 if (!requestId || activeCaptureId !== requestId) return;
131 void desktopHost().native.cancelRendererProfile?.(requestId).catch(() => {});
132 }
133
134 const PERF_REPORTED_STORAGE_KEY = "reasonix:perf-reported";
135
136 // Idempotent per pressure label: once a category is reported (persisted per build) or
137 // dismissed (session only), stop re-surfacing it so a steady slowdown can't spam prompts.
138 const dismissedPerfLabels = new Set<string>();
139 let reportedPerfLabels: Set<string> | null = null;
140
141 function currentBuildCommit(): string {
142 return typeof __BUILD_COMMIT__ === "string" ? __BUILD_COMMIT__ : "dev";
143 }
144
145 export function parseReportedPerf(raw: string | null, build: string): Set<string> {
146 if (!raw) return new Set();
147 try {
148 const parsed = JSON.parse(raw) as { build?: string; labels?: unknown };
149 if (parsed.build !== build || !Array.isArray(parsed.labels)) return new Set();
150 return new Set(parsed.labels.filter((label): label is string => typeof label === "string"));
151 } catch {
152 return new Set();
153 }
154 }
155
156 export function serializeReportedPerf(labels: ReadonlySet<string>, build: string): string {
157 return JSON.stringify({ build, labels: [...labels] });
158 }
159
160 function getReportedPerfLabels(): Set<string> {
161 if (reportedPerfLabels) return reportedPerfLabels;
162 let raw: string | null = null;
163 try {
164 raw = typeof localStorage !== "undefined" ? localStorage.getItem(PERF_REPORTED_STORAGE_KEY) : null;
165 } catch {
166 raw = null;
167 }
168 reportedPerfLabels = parseReportedPerf(raw, currentBuildCommit());
169 return reportedPerfLabels;
170 }
171
172 function markPerfReported(label: string): void {
173 const set = getReportedPerfLabels();
174 if (set.has(label)) return;
175 set.add(label);
176 try {
177 if (typeof localStorage !== "undefined") {
178 localStorage.setItem(PERF_REPORTED_STORAGE_KEY, serializeReportedPerf(set, currentBuildCommit()));
179 }
180 } catch {
181 // localStorage can throw (private mode / quota); the session-level set still dedups.
182 }
183 }
184
185 function clip(s: string, n: number): string {
186 return s.length > n ? s.slice(0, n) : s;
187 }
188
189 function safeStringify(value: unknown): string {
190 try {
191 return JSON.stringify(value);
192 } catch {
193 return String(value);
194 }
195 }
196
197 export function normalizeCrashError(err: unknown): NormalizedError {
198 if (err instanceof Error) {
199 return {
200 errorType: err.name || "Error",
201 errorMessage: err.message || String(err),
202 stack: err.stack,
203 };
204 }
205 if (typeof err === "string") {
206 return { errorType: "string", errorMessage: err };
207 }
208 if (err && typeof err === "object") {
209 const obj = err as { name?: unknown; message?: unknown; stack?: unknown; constructor?: { name?: string } };
210 const errorType = typeof obj.name === "string" && obj.name ? obj.name : obj.constructor?.name || "object";
211 const errorMessage =
212 typeof obj.message === "string" && obj.message ? obj.message : clip(safeStringify(err), 1000);
213 return {
214 errorType,
215 errorMessage,
216 stack: typeof obj.stack === "string" ? obj.stack : undefined,
217 };
218 }
219 return { errorType: typeof err, errorMessage: String(err) };
220 }
221
222 export function topFrameFromStack(stack?: string): string {
223 if (!stack) return "";
224 const lines = stack
225 .split("\n")
226 .map((l) => l.trim())
227 .filter(Boolean);
228 return lines.find((l) => /\b(src|assets|frontend)\b|\.tsx?:|\.jsx?:/.test(l)) ?? lines[1] ?? lines[0] ?? "";
229 }
230
231 function currentView(): string {
232 if (typeof window === "undefined") return "";
233 const { protocol, host, pathname, hash } = window.location;
234 const safeHash = hash && hash.length < 80 ? hash : "";
235 return clip(`${protocol}//${host}${pathname}${safeHash}`, 180);
236 }
237
238 function kindForLabel(label: string): CrashKind {
239 return label === "unhandledrejection" ? "exception" : "crash";
240 }
241
242 function sourceForLabel(label: string): CrashPayload["source"] {
243 if (label === "react") return "frontend.react";
244 if (label === "window.error" || label === "unhandledrejection") return "frontend.global";
245 return "frontend";
246 }
247
248 function formatText(label: string, normalized: NormalizedError, extra?: string): string {
249 const detail = normalized.stack || normalized.errorMessage;
250 const crumbs = dumpBreadcrumbs();
251 const buildCommit = currentBuildCommit();
252 const transcriptText = (globalThis as { __reasonixTranscriptDiagnostics?: string }).__reasonixTranscriptDiagnostics ?? "";
253 return [`[${label}]`, detail, extra?.trim(), transcriptText && `--- transcript failures ---\n${transcriptText}`,
254 crumbs && `--- breadcrumbs ---\n${crumbs}`, `occurred ${new Date().toISOString()}`, `build ${buildCommit}`]
255 .filter(Boolean).join("\n\n");
256 }
257
258 export function crashErrorFamily(errorMessage: string): string | undefined { return /maximum update depth exceeded|too many re-renders/i.test(errorMessage) ? "react.maximum_update_depth" : undefined; }
259
260 function readHeapSnapshot(): PerformanceSnapshot["jsHeap"] | undefined {
261 if (typeof performance === "undefined") return undefined;
262 const memory = (performance as Performance & { memory?: BrowserPerformanceMemory }).memory;
263 if (!memory?.usedJSHeapSize || !memory.totalJSHeapSize || !memory.jsHeapSizeLimit) return undefined;
264 const usedMb = memory.usedJSHeapSize / 1024 / 1024;
265 const totalMb = memory.totalJSHeapSize / 1024 / 1024;
266 const limitMb = memory.jsHeapSizeLimit / 1024 / 1024;
267 return {
268 usedMb,
269 totalMb,
270 limitMb,
271 usagePercent: limitMb > 0 ? (usedMb / limitMb) * 100 : undefined,
272 };
273 }
274
275 function pruneLongTasks(now = performance.now()): void {
276 while (longTasks.length && now - longTasks[0].startMs > LONG_TASK_WINDOW_MS) longTasks.shift();
277 }
278
279 function longTaskSummary(now = performance.now()): PerformanceSnapshot["longTasks"] {
280 pruneLongTasks(now);
281 if (!longTasks.length) return undefined;
282 const totalMs = longTasks.reduce((sum, t) => sum + t.durationMs, 0);
283 const maxMs = Math.max(...longTasks.map((t) => t.durationMs));
284 return {
285 count: longTasks.length,
286 totalMs,
287 maxMs,
288 recent: longTasks.slice(-5),
289 };
290 }
291
292 function eventLoopLagSummary(currentMs = 0): PerformanceSnapshot["eventLoopLag"] {
293 const samples = lagSamples.filter((n) => n > 0);
294 if (!samples.length && currentMs <= 0) return undefined;
295 const all = currentMs > 0 ? [...samples, currentMs] : samples;
296 const total = all.reduce((sum, n) => sum + n, 0);
297 return {
298 currentMs,
299 maxMs: Math.max(...all),
300 avgMs: total / all.length,
301 samples: all.length,
302 };
303 }
304
305 function networkSnapshot(): PerformanceSnapshot["connection"] {
306 if (typeof navigator === "undefined") return undefined;
307 const connection = (navigator as BrowserNavigator).connection;
308 if (!connection) return undefined;
309 return {
310 effectiveType: connection.effectiveType,
311 downlinkMbps: connection.downlink,
312 rttMs: connection.rtt,
313 saveData: connection.saveData,
314 };
315 }
316
317 function performanceSnapshot(reason: string, currentLagMs = 0): PerformanceSnapshot {
318 const nav = typeof navigator === "undefined" ? undefined : (navigator as BrowserNavigator);
319 const doc = typeof document === "undefined" ? undefined : document;
320 const pipeline = sessionPipelineDiagnostics();
321 return {
322 reason,
323 uptimeMs: typeof performance !== "undefined" ? performance.now() : 0,
324 visibility: doc?.visibilityState ?? "",
325 focused: doc?.hasFocus?.() ?? false,
326 online: nav?.onLine ?? true,
327 hardwareConcurrency: nav?.hardwareConcurrency ?? 0,
328 deviceMemoryGb: nav?.deviceMemory,
329 jsHeap: readHeapSnapshot(),
330 eventLoopLag: eventLoopLagSummary(currentLagMs),
331 longTasks: typeof performance !== "undefined" ? longTaskSummary() : undefined,
332 connection: networkSnapshot(),
333 sessionPipeline: Object.keys(pipeline).length > 0 ? pipeline : undefined,
334 };
335 }
336
337 export function performanceLabelForReason(reason: string): string {
338 const normalized = reason.trim().toLowerCase();
339 if (normalized.startsWith("event loop lag")) return "performance.lag";
340 if (normalized.startsWith("long task")) return "performance.longtask";
341 if (normalized.startsWith("js heap")) return "performance.heap";
342 if (normalized.startsWith("process memory")) return "performance.memory";
343 return "performance.pressure";
344 }
345
346 export function performanceFingerprintHintForReason(reason: string): string | undefined {
347 const normalized = reason.trim().toLowerCase();
348 if (!normalized.startsWith("js heap")) return undefined;
349 const match = normalized.match(/(\d+(?:\.\d+)?)%/);
350 const percent = match ? Number(match[1]) : Number.NaN;
351 if (!Number.isFinite(percent)) return "frontend.performance.heap.unknown";
352 return percent >= 95
353 ? "frontend.performance.heap.critical"
354 : "frontend.performance.heap.high";
355 }
356
357 export function shouldRecordLongTaskSample(
358 startMs: number,
359 durationMs: number,
360 graceUntilMs: number,
361 visibilityHidden = false,
362 visibleSinceMs = 0,
363 focused = true,
364 ): boolean {
365 if (!focused) return false;
366 if (visibilityHidden) return false;
367 return durationMs >= 50 && startMs >= graceUntilMs && startMs - visibleSinceMs >= VISIBILITY_RESUME_GRACE_MS;
368 }
369
370 export function shouldPromptForLongTasks(summary: { count: number; totalMs: number; maxMs: number }): boolean {
371 return summary.maxMs >= LONG_TASK_PROMPT_MS || (summary.count >= 3 && summary.totalMs >= LONG_TASK_TOTAL_PROMPT_MS);
372 }
373
374 export function shouldPromptForEventLoopLag(
375 samples: readonly number[],
376 longTask?: { count: number; totalMs: number; maxMs: number },
377 ): boolean {
378 const recent = samples.slice(-EVENT_LOOP_LAG_CONSECUTIVE_SAMPLES);
379 const sustained =
380 recent.length === EVENT_LOOP_LAG_CONSECUTIVE_SAMPLES &&
381 recent.every((sample) => sample >= EVENT_LOOP_LAG_PROMPT_MS);
382 const current = samples.length ? samples[samples.length - 1] : 0;
383 const corroborated = current >= EVENT_LOOP_LAG_PROMPT_MS && Boolean(longTask && shouldPromptForLongTasks(longTask));
384 return sustained || corroborated;
385 }
386
387 type TaskAttributionLike = {
388 containerType?: string;
389 containerName?: string;
390 containerId?: string;
391 containerSrc?: string;
392 };
393
394 // Longtask entries carry no stacks, only a culprit descriptor ("self", "same-origin",
395 // iframe container, ...). "self" and "unknown" are the expected no-signal cases, so
396 // only anomalies (cross-context culprits, named containers) make it into the report.
397 export function formatLongTaskAttribution(entryName?: string, attribution?: TaskAttributionLike[]): string {
398 const parts: string[] = [];
399 if (entryName && entryName !== "unknown" && entryName !== "self") parts.push(entryName);
400 const culprit = attribution?.[0];
401 if (culprit) {
402 const container = culprit.containerName || culprit.containerId || culprit.containerSrc || "";
403 const containerType = culprit.containerType && culprit.containerType !== "window" ? culprit.containerType : "";
404 const detail = [containerType, container].filter(Boolean).join(":");
405 if (detail) parts.push(detail);
406 }
407 return parts.join(" ");
408 }
409
410 export function shouldRecordEventLoopLagSample(
411 visibilityHidden: boolean,
412 msSinceVisible: number,
413 focused = true,
414 msSinceFocused = msSinceVisible,
415 ): boolean {
416 if (!focused) return false;
417 if (visibilityHidden) return false;
418 return msSinceVisible >= VISIBILITY_RESUME_GRACE_MS && msSinceFocused >= VISIBILITY_RESUME_GRACE_MS;
419 }
420
421 export function buildPerformancePayload(snapshot: PerformanceSnapshot): CrashPayload {
422 const buildCommit = currentBuildCommit();
423 const context = formatPerformanceContext(snapshot);
424 const crumbs = dumpBreadcrumbs();
425 const label = performanceLabelForReason(snapshot.reason);
426 const errorMessage = label === "performance.memory"
427 ? "App process memory remained elevated across multiple samples; this does not establish a leak."
428 : "UI responsiveness degraded because the app observed long tasks, event-loop lag, or high JS heap pressure.";
429 return {
430 schemaVersion: 2,
431 source: "frontend.performance",
432 kind: "performance",
433 label,
434 message: [
435 `[${label}]`,
436 errorMessage,
437 `--- performance context ---\n${context}`,
438 crumbs && `--- breadcrumbs ---\n${crumbs}`,
439 `build ${buildCommit}`,
440 ]
441 .filter(Boolean)
442 .join("\n\n"),
443 errorType: "PerformancePressure",
444 errorMessage,
445 topFrame: "frontend.performance",
446 fingerprintHint: performanceFingerprintHintForReason(snapshot.reason),
447 buildCommit,
448 channel: typeof __BUILD_CHANNEL__ === "string" ? __BUILD_CHANNEL__ : "",
449 language: typeof navigator !== "undefined" ? navigator.language || "" : "",
450 view: currentView(),
451 breadcrumbs: snapshotBreadcrumbs(),
452 occurredAt: new Date().toISOString(),
453 };
454 }
455
456 export function buildCrashPayload(label: string, err: unknown, extra?: string): CrashPayload {
457 const normalized = normalizeCrashError(err);
458 const buildCommit = currentBuildCommit();
459 return {
460 schemaVersion: 2,
461 source: sourceForLabel(label),
462 kind: kindForLabel(label),
463 label,
464 message: formatText(label, normalized, extra),
465 errorType: normalized.errorType,
466 errorMessage: normalized.errorMessage,
467 errorFamily: crashErrorFamily(normalized.errorMessage),
468 stack: normalized.stack,
469 componentStack: extra?.trim() || undefined,
470 topFrame: topFrameFromStack(normalized.stack || extra),
471 buildCommit,
472 channel: typeof __BUILD_CHANNEL__ === "string" ? __BUILD_CHANNEL__ : "",
473 language: typeof navigator !== "undefined" ? navigator.language || "" : "",
474 view: currentView(),
475 breadcrumbs: snapshotBreadcrumbs(),
476 occurredAt: new Date().toISOString(),
477 };
478 }
479
480 export function opaqueScriptFingerprintHint(
481 rawView = currentView(),
482 breadcrumbs = snapshotBreadcrumbs(),
483 buildCommit = currentBuildCommit(),
484 ): string {
485 const view = rawView
486 .replace(/[?#].*$/, "")
487 .replace(/\b[0-9a-f]{8,}\b/gi, "_")
488 .replace(/\/\d+(?=\/|$)/g, "/_");
489 const categories = breadcrumbs
490 .slice(-8)
491 .map((crumb) => crumb.cat?.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "_") ?? "")
492 .filter(Boolean)
493 .join(">");
494 return clip(`build:${buildCommit.slice(0, 16)}|view:${view}|cats:${categories || "none"}`, 300);
495 }
496
497 function sendButton(
498 payload: CrashPayload | (() => CrashPayload),
499 className = "crash-overlay__send",
500 onSent?: () => void,
501 ): HTMLButtonElement | null {
502 // Resolved at click time through the host adapter, not the bridge module: this
503 // overlay must stay usable even when the rest of the app (and its imports) is broken.
504 const report = desktopHost().app?.ReportCrash;
505 if (!report) return null;
506 const send = document.createElement("button");
507 send.className = className;
508 send.textContent = t("crash.send");
509 send.onclick = async () => {
510 send.disabled = true;
511 send.textContent = t("crash.sending");
512 try {
513 const current = typeof payload === "function" ? payload() : payload;
514 await report(current.kind, JSON.stringify(current));
515 send.textContent = t("crash.sent");
516 onSent?.();
517 } catch (err) {
518 send.textContent = t("crash.sendFailed");
519 send.title = err instanceof Error ? err.message : String(err);
520 send.disabled = false;
521 }
522 };
523 return send;
524 }
525
526 const COPY_FEEDBACK_MS = 2_000;
527
528 function copyButton(text: string | (() => string), className: string): HTMLButtonElement {
529 const copy = document.createElement("button");
530 copy.className = className;
531 copy.textContent = t("crash.copy");
532 copy.onclick = async () => {
533 copy.disabled = true;
534 let copied = false;
535 // The crash overlay is the last-resort surface, so the button must re-enable
536 // even if the clipboard path throws unexpectedly — a stuck disabled Copy is
537 // exactly the #6388 unresponsive symptom. Catch so a rejection can't escape as
538 // an unhandledrejection into the global crash handler either.
539 try {
540 copied = await writeClipboardText(typeof text === "function" ? text() : text);
541 } catch {
542 copied = false;
543 } finally {
544 copy.textContent = copied ? t("crash.copied") : t("crash.copyFailed");
545 copy.disabled = false;
546 window.setTimeout(() => {
547 copy.textContent = t("crash.copy");
548 }, COPY_FEEDBACK_MS);
549 }
550 };
551 return copy;
552 }
553
554 let performancePromptGeneration = 0;
555 function paintPerformancePrompt(payload: CrashPayload, snapshot: PerformanceSnapshot, captureId?: string) {
556 if (typeof document === "undefined") return;
557 const generation = ++performancePromptGeneration;
558 let currentPayload = payload;
559 let host = document.getElementById("performance-report-prompt");
560 if (!host) {
561 host = document.createElement("div");
562 host.id = "performance-report-prompt";
563 document.body.appendChild(host);
564 }
565 const title = document.createElement("div");
566 title.className = "performance-report__title";
567 title.textContent = t(payload.label === "performance.memory" ? "performanceReport.memoryTitle" : "performanceReport.title");
568 const body = document.createElement("pre");
569 body.className = "performance-report__body";
570 body.textContent = formatPerformanceContext(snapshot);
571 const actions = document.createElement("div");
572 actions.className = "performance-report__actions";
573 const send = sendButton(() => currentPayload, "performance-report__send", () => markPerfReported(payload.label));
574 const copy = copyButton(() => currentPayload.message, "performance-report__copy");
575 const dismiss = document.createElement("button");
576 dismiss.className = "performance-report__dismiss";
577 dismiss.textContent = t("performanceReport.dismiss");
578 dismiss.onclick = () => {
579 if (generation !== performancePromptGeneration) return;
580 dismissedPerfLabels.add(payload.label);
581 performancePromptGeneration++;
582 if (captureId) cancelCapture(captureId);
583 host?.remove();
584 };
585 if (send) actions.append(send);
586 actions.append(copy, dismiss);
587 const exportHeap = desktopHost().native.exportHeapSnapshot;
588 if (exportHeap) {
589 const heap = document.createElement("button");
590 heap.className = "performance-report__copy";
591 heap.textContent = t("performanceReport.saveHeap");
592 heap.onclick = async () => {
593 if (generation !== performancePromptGeneration || !host?.isConnected) return;
594 heap.disabled = true;
595 heapSnapshotInProgress = true;
596 try {
597 const result = await exportHeap();
598 heap.textContent = result.status === "saved" ? t("performanceReport.heapSaved") : result.status === "busy" ? t("performanceReport.diagnosticBusy") : result.status === "failed" ? t("performanceReport.heapFailed") : t("performanceReport.saveHeap");
599 } catch { heap.textContent = t("performanceReport.heapFailed"); }
600 finally {
601 heap.disabled = false;
602 heapSnapshotInProgress = false;
603 diagnosticQuietUntil = performance.now() + VISIBILITY_RESUME_GRACE_MS;
604 }
605 };
606 actions.append(heap);
607 }
608 const note = document.createElement("div");
609 note.className = "performance-report__note";
610 note.textContent = t("performanceReport.privacyNote");
611 host.replaceChildren(title, body, actions, note);
612 return () => {
613 if (generation !== performancePromptGeneration || !host?.isConnected) return;
614 currentPayload = buildPerformancePayload(snapshot);
615 body.textContent = formatPerformanceContext(snapshot);
616 };
617 }
618
619 export function paintCrashOverlay(payload: CrashPayload) {
620 let host = document.getElementById("crash-overlay");
621 if (!host) {
622 host = document.createElement("div");
623 host.id = "crash-overlay";
624 document.body.appendChild(host);
625 }
626 const title = document.createElement("div");
627 title.className = "crash-overlay__title";
628 title.textContent = t("crash.title");
629 const body = document.createElement("pre");
630 body.className = "crash-overlay__body";
631 body.textContent = payload.message;
632 const copy = copyButton(payload.message, "crash-overlay__copy");
633 const actions = document.createElement("div");
634 actions.className = "crash-overlay__actions";
635 const send = sendButton(payload);
636 if (send) actions.append(send);
637 actions.append(copy);
638 const note = document.createElement("div");
639 note.className = "crash-overlay__note";
640 note.textContent = t("crash.privacyNote");
641 host.replaceChildren(title, body, actions, ...(send ? [note] : []));
642 }
643
644 export function reportCrash(label: string, err: unknown, extra?: string) {
645 paintCrashOverlay(buildCrashPayload(label, err, extra));
646 }
647
648 type GlobalCrashEventLike = Pick<Event, "defaultPrevented"> & {
649 message?: unknown;
650 error?: unknown;
651 reason?: unknown;
652 filename?: unknown;
653 lineno?: unknown;
654 colno?: unknown;
655 };
656
657 const RESIZE_OBSERVER_LOOP_MESSAGE_RE = /^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications\.?)$/;
658 const OPAQUE_SCRIPT_ERROR_MESSAGE = "Script error.";
659 function globalCrashEventMessages(e: GlobalCrashEventLike): string[] {
660 const messages: string[] = [];
661 const pushMessage = (message: string) => {
662 const trimmed = message.trim();
663 if (trimmed) messages.push(trimmed);
664 };
665 if (typeof e.message === "string") pushMessage(e.message);
666 const error = e.error ?? e.reason;
667 if (typeof error === "string") pushMessage(error);
668 if (error && typeof error === "object" && "message" in error) {
669 const msg = (error as { message?: unknown }).message;
670 if (typeof msg === "string") pushMessage(msg);
671 }
672 return messages;
673 }
674
675 export function shouldReportGlobalCrashEvent(e: GlobalCrashEventLike): boolean {
676 if (e.defaultPrevented) return false;
677 if (globalCrashEventMessages(e).some((message) => RESIZE_OBSERVER_LOOP_MESSAGE_RE.test(message) ||
678 /Minified React error #520\b/.test(message) || message.includes("status was superseded by"))) return false;
679 return true;
680 }
681
682 export function isOpaqueScriptErrorEvent(e: GlobalCrashEventLike): boolean {
683 return (
684 (e.error === undefined || e.error === null) &&
685 typeof e.message === "string" &&
686 e.message.trim() === OPAQUE_SCRIPT_ERROR_MESSAGE &&
687 globalScriptErrorLocation(e) === ""
688 );
689 }
690
691 function globalScriptErrorLocation(e: GlobalCrashEventLike): string {
692 const parts: string[] = [];
693 if (typeof e.filename === "string" && e.filename.trim()) parts.push(`filename=${e.filename.trim()}`);
694 if (typeof e.lineno === "number" && Number.isFinite(e.lineno) && e.lineno > 0) parts.push(`lineno=${e.lineno}`);
695 if (typeof e.colno === "number" && Number.isFinite(e.colno) && e.colno > 0) parts.push(`colno=${e.colno}`);
696 return parts.join(" ");
697 }
698
699 export function globalCrashReportReason(e: GlobalCrashEventLike): unknown {
700 if (e.error !== undefined && e.error !== null) return e.error;
701 const message = typeof e.message === "string" ? e.message.trim() : e.message;
702 if (message === OPAQUE_SCRIPT_ERROR_MESSAGE) {
703 const location = globalScriptErrorLocation(e);
704 if (location) return `${OPAQUE_SCRIPT_ERROR_MESSAGE}\n${location}`;
705 }
706 return e.message;
707 }
708
709 export function shouldPromptForPerformanceLabel(
710 alreadyHandled: boolean,
711 msSinceLastPrompt: number,
712 visibilityHidden: boolean,
713 focused = true,
714 ): boolean {
715 if (alreadyHandled) return false;
716 if (msSinceLastPrompt < PROMPT_COOLDOWN_MS) return false;
717 if (visibilityHidden) return false;
718 if (!focused) return false;
719 return true;
720 }
721
722 function isPerfLabelHandled(label: string): boolean {
723 return dismissedPerfLabels.has(label) || getReportedPerfLabels().has(label);
724 }
725
726 function shouldPromptForPerformance(now: number, label: string): boolean {
727 const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
728 const focused = typeof document === "undefined" || document.hasFocus?.() !== false;
729 return shouldPromptForPerformanceLabel(isPerfLabelHandled(label), now - lastPerformancePromptAt, hidden, focused);
730 }
731
732 function promptPerformanceReport(reason: string, currentLagMs = 0, processes?: ProcessDiagnosticsSnapshot): void {
733 if (heapSnapshotInProgress || performance.now() < diagnosticQuietUntil) return;
734 const now = Date.now();
735 const label = performanceLabelForReason(reason);
736 if (!shouldPromptForPerformance(now, label)) return;
737 lastPerformancePromptAt = now;
738 addBreadcrumb("performance", reason);
739 const snapshot = performanceSnapshot(reason, currentLagMs);
740 snapshot.processes = processes;
741 const native = desktopHost().native;
742 const capture = label === "performance.longtask" || label === "performance.lag";
743 const requestId = capture && native.captureRendererProfile
744 ? globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` : undefined;
745 if (requestId) activeCaptureId = requestId;
746 if (capture) snapshot.cpuProfile = { status: native.captureRendererProfile ? "recording" : "unavailable" };
747 // Show the original evidence immediately. Slow diagnostics only enrich this
748 // same prompt; they cannot recreate a dismissed/replaced report.
749 const update = paintPerformancePrompt(buildPerformancePayload(snapshot), snapshot, requestId);
750 if (!processes && native.processDiagnostics) {
751 void boundedDiagnostics(() => native.processDiagnostics!()).then((sample) => {
752 if (sample) { snapshot.processes = sample; update?.(); }
753 });
754 }
755 if (requestId && native.captureRendererProfile) {
756 void boundedDiagnostics(() => native.captureRendererProfile!(requestId), 12_000).then((result) => {
757 snapshot.cpuProfile = result ?? { status: "failed" };
758 if (!result) cancelCapture(requestId);
759 if (activeCaptureId === requestId) activeCaptureId = undefined;
760 update?.();
761 });
762 }
763 }
764
765 function maybePromptForHeapPressure(): void {
766 const heap = readHeapSnapshot();
767 if (!heap?.usagePercent) return;
768 if (heap.usedMb >= 512 && heap.usagePercent >= 85) {
769 promptPerformanceReport(`js heap ${fmtNumber(heap.usagePercent)}% of limit`);
770 }
771 }
772
773 export function installPerformancePressureMonitor() {
774 if (performanceMonitorInstalled || typeof window === "undefined" || typeof performance === "undefined") return;
775 if (desktopHost().kind === "none") return;
776 performanceMonitorInstalled = true;
777 const startedAt = performance.now();
778 const graceUntil = startedAt + STARTUP_GRACE_MS;
779 const isHidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
780 const isFocused = () => typeof document === "undefined" || document.hasFocus?.() !== false;
781 let visibleSince = isHidden() ? Number.POSITIVE_INFINITY : startedAt;
782 let focusedSince = isFocused() ? startedAt : Number.POSITIVE_INFINITY;
783 let expected = performance.now() + 1000;
784 let eventLoopLagPrimed = false;
785 // When the view is shown or focused again, overdue timer callbacks can run before
786 // the queued visibilitychange/focus task, so visibleSince/focusedSince may still
787 // describe the previous settled period at that point. The sampler tracks hidden and
788 // unfocused observations itself and restarts both windows on the first settled tick
789 // instead of trusting the listener-maintained timestamps.
790 let pendingResume = isHidden() || !isFocused();
791
792 const pastGrace = () => performance.now() >= graceUntil;
793 const inspectLongTasks = () => {
794 if (!pastGrace()) return;
795 const summary = longTaskSummary();
796 if (!summary) return;
797 if (shouldPromptForLongTasks(summary)) {
798 promptPerformanceReport(`long task ${fmtNumber(summary.maxMs)}ms`);
799 }
800 };
801
802 // Blur/hide park the timestamps at +Infinity so a stale read before the matching
803 // resume listener has run can never satisfy the grace windows.
804 const resetSamples = () => {
805 const now = performance.now();
806 longTasks.length = 0;
807 lagSamples.length = 0;
808 expected = now + 1000;
809 eventLoopLagPrimed = false;
810 visibleSince = isHidden() ? Number.POSITIVE_INFINITY : now;
811 focusedSince = isFocused() ? now : Number.POSITIVE_INFINITY;
812 pendingResume = isHidden() || !isFocused();
813 if (pendingResume) cancelCapture();
814 };
815
816 if (typeof document !== "undefined") {
817 document.addEventListener("visibilitychange", resetSamples);
818 }
819 window.addEventListener("focus", resetSamples);
820 window.addEventListener("blur", resetSamples);
821
822 if (typeof PerformanceObserver !== "undefined") {
823 try {
824 const observer = new PerformanceObserver((list) => {
825 for (const entry of list.getEntries()) {
826 if (heapSnapshotInProgress || entry.startTime < diagnosticQuietUntil) continue;
827 if (!shouldRecordLongTaskSample(entry.startTime, entry.duration, graceUntil, isHidden(), visibleSince, isFocused())) continue;
828 const attribution = formatLongTaskAttribution(
829 entry.name,
830 (entry as PerformanceEntry & { attribution?: TaskAttributionLike[] }).attribution,
831 );
832 longTasks.push({
833 startMs: Math.round(entry.startTime),
834 durationMs: Math.round(entry.duration),
835 ...(attribution ? { attribution } : {}),
836 });
837 }
838 pruneLongTasks();
839 inspectLongTasks();
840 });
841 observer.observe({ entryTypes: ["longtask"] });
842 } catch {
843 // Some WebViews expose PerformanceObserver without the longtask entry type.
844 }
845 }
846
847 let processSampleAt = performance.now();
848 let processSamplePending = false;
849 window.setInterval(() => {
850 const now = performance.now();
851 if (heapSnapshotInProgress || now < diagnosticQuietUntil) {
852 longTasks.length = 0;
853 lagSamples.length = 0;
854 expected = now + 1000;
855 eventLoopLagPrimed = false;
856 return;
857 }
858 if (isHidden() || !isFocused()) {
859 pendingResume = true;
860 } else if (pendingResume) {
861 pendingResume = false;
862 visibleSince = now;
863 focusedSince = now;
864 longTasks.length = 0;
865 lagSamples.length = 0;
866 expected = now + 1000;
867 eventLoopLagPrimed = false;
868 return;
869 }
870 if (!pastGrace()) {
871 expected = now + 1000;
872 return;
873 }
874 if (!eventLoopLagPrimed) {
875 expected = now + 1000;
876 eventLoopLagPrimed = true;
877 return;
878 }
879 const lagMs = Math.max(0, now - expected);
880 expected = now + 1000;
881 if (!shouldRecordEventLoopLagSample(isHidden(), now - visibleSince, isFocused(), now - focusedSince)) return;
882 lagSamples.push(lagMs);
883 if (lagSamples.length > MAX_LAG_SAMPLES) lagSamples.shift();
884 if (shouldPromptForEventLoopLag(lagSamples, longTaskSummary(now))) {
885 promptPerformanceReport(`event loop lag ${fmtNumber(lagMs)}ms`, lagMs);
886 }
887 maybePromptForHeapPressure();
888 const readProcesses = desktopHost().native.processDiagnostics;
889 if (readProcesses && !processSamplePending && now - processSampleAt >= 30_000) {
890 processSampleAt = now;
891 processSamplePending = true;
892 void boundedDiagnostics(readProcesses).then((sample) => {
893 if (sample?.growth?.length && !isHidden() && isFocused()) promptPerformanceReport("process memory growth", 0, sample);
894 }).finally(() => { processSamplePending = false; });
895 }
896 }, 1000);
897 }
898
898 lines TYPESCRIPT