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