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