Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ pub async fn run() {
.level_for("globset", log::LevelFilter::Off)
.level_for("hyper_util", log::LevelFilter::Info)
.level_for("h2", log::LevelFilter::Info)
.level_for("portable_pty", log::LevelFilter::Info)
.level_for("russh", log::LevelFilter::Info)
.targets(log_targets)
.rotation_strategy(RotationStrategy::KeepSome(30))
.max_file_size(10 * 1024 * 1024)
Expand Down
34 changes: 7 additions & 27 deletions src/web-ui/src/app/components/AppErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { Component, ReactNode } from 'react';
import { createLogger } from '@/shared/utils/logger';
import { i18nService } from '@/infrastructure/i18n';
import { buildReactCrashLogPayload } from '@/shared/utils/reactProductionError';

const log = createLogger('AppErrorBoundary');

// Crash log deduplication flag (shared with main.tsx)
const CRASH_LOGGED_FLAG = '__bitfun_frontend_crash_logged__';
function hasLoggedCrash(): boolean {
return Boolean((window as any)[CRASH_LOGGED_FLAG]);
}
function markCrashLogged(): void {
(window as any)[CRASH_LOGGED_FLAG] = true;
}

interface Props {
children: ReactNode;
}
Expand All @@ -23,17 +15,6 @@ interface State {
errorInfo?: any;
}

function serializeError(err: unknown): Record<string, unknown> {
if (err instanceof Error) {
return {
name: err.name,
message: err.message,
stack: err.stack,
};
}
return { value: String(err) };
}

export class AppErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
Expand All @@ -46,13 +27,12 @@ export class AppErrorBoundary extends Component<Props, State> {

componentDidCatch(error: Error, errorInfo: any) {
this.setState({ error, errorInfo });
if (!hasLoggedCrash()) {
markCrashLogged();
log.error('[CRASH] React error boundary caught exception', {
error: serializeError(error),
errorInfo,
});
}
// Log every boundary capture (do not share a session-wide flag with main.tsx:
// a second distinct error would otherwise be suppressed).
log.error(
'[CRASH] React error boundary caught exception',
buildReactCrashLogPayload(error, errorInfo)
);
}

handleReload = () => {
Expand Down
58 changes: 46 additions & 12 deletions src/web-ui/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,24 @@ import { initializeAllTools } from "./tools";
import { initContextMenuSystem } from "./shared/context-menu-system";
import { loader } from '@monaco-editor/react';
import { getMonacoPath, getMonacoWorkerPath, logMonacoResourceCheck } from './tools/editor/utils/monacoPathHelper';
import { createLogger } from './shared/utils/logger';
import { bootstrapLogger, createLogger, initLogger } from './shared/utils/logger';
import {
buildReactCrashLogPayload,
isMinifiedReactErrorMessage,
} from './shared/utils/reactProductionError';

// Install console forwarding before app startup so early console output is persisted too.
bootstrapLogger();

const log = createLogger('App');

// Crash log deduplication flag
const CRASH_LOGGED_FLAG = '__bitfun_frontend_crash_logged__';
function hasLoggedCrash(): boolean {
return Boolean((window as any)[CRASH_LOGGED_FLAG]);
/** Dedupe only for white-screen heuristic (empty #root), not for Error Boundary logs. */
const WHITE_SCREEN_LOGGED_FLAG = '__bitfun_white_screen_crash_logged__';
function hasLoggedWhiteScreenCrash(): boolean {
return Boolean((window as any)[WHITE_SCREEN_LOGGED_FLAG]);
}
function markCrashLogged(): void {
(window as any)[CRASH_LOGGED_FLAG] = true;
function markWhiteScreenCrashLogged(): void {
(window as any)[WHITE_SCREEN_LOGGED_FLAG] = true;
}

function serializeError(err: unknown): Record<string, unknown> {
Expand Down Expand Up @@ -60,8 +67,8 @@ function registerGlobalErrorHandlers() {
queueMicrotask(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (isRootEmpty() && !hasLoggedCrash()) {
markCrashLogged();
if (isRootEmpty() && !hasLoggedWhiteScreenCrash()) {
markWhiteScreenCrashLogged();
log.error('[CRASH] Application crashed', {
location: payload.location,
message: payload.message,
Expand All @@ -77,9 +84,23 @@ function registerGlobalErrorHandlers() {
'error',
(event: Event) => {
if (event instanceof ErrorEvent) {
const msg = event.message || '';
// Minified React errors often reach window.error even when #root is not empty;
// always persist so production builds get react.dev/errors/{code} in webview.log.
if (isMinifiedReactErrorMessage(msg)) {
const err =
event.error instanceof Error ? event.error : new Error(msg);
log.error('[CRASH] window:error (minified React)', {
location: 'window:error',
...buildReactCrashLogPayload(err),
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
});
}
scheduleCrashLog({
location: 'window:error',
message: event.message || 'window error',
message: msg || 'window error',
data: {
filename: event.filename,
lineno: event.lineno,
Expand All @@ -106,6 +127,20 @@ function registerGlobalErrorHandlers() {
);

window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
const reason = event.reason;
const msg =
reason instanceof Error
? reason.message
: typeof reason === 'string'
? reason
: '';
if (isMinifiedReactErrorMessage(msg)) {
const err = reason instanceof Error ? reason : new Error(msg);
log.error('[CRASH] unhandledrejection (minified React)', {
location: 'window:unhandledrejection',
...buildReactCrashLogPayload(err),
});
}
scheduleCrashLog({
location: 'window:unhandledrejection',
message: 'unhandled rejection',
Expand Down Expand Up @@ -181,8 +216,7 @@ const DEFAULT_WORKER = 'base/worker/workerMain.js';
// Initialize app.
async function initializeApp() {
try {
// Initialize logger first (attaches console in dev mode)
const { initLogger } = await import('./shared/utils/logger');
// Initialize logger state before startup logs.
await initLogger();

// Sync frontend logger with app.logging.level before startup logs.
Expand Down
124 changes: 114 additions & 10 deletions src/web-ui/src/shared/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
info as tauriInfo,
warn as tauriWarn,
error as tauriError,
attachConsole,
} from '@tauri-apps/plugin-log';

export enum LogLevel {
Expand All @@ -34,26 +33,131 @@ export interface LogEntry {
const isTauri = typeof window !== 'undefined' && '__TAURI__' in window;
const isDev = import.meta.env?.DEV ?? process.env.NODE_ENV === 'development';

const CONSOLE_FORWARD_INSTALLED = '__bitfun_console_forward_installed__';

function formatConsoleArg(value: unknown): string {
if (value === undefined) return 'undefined';
if (value === null) return 'null';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
if (typeof value === 'symbol') return value.toString();
if (value instanceof Error) return value.stack || `${value.name}: ${value.message}`;
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
try {
return Object.prototype.toString.call(value);
} catch {
return '[Object]';
}
}
}
return String(value);
}

function formatConsoleArgs(args: unknown[]): string {
return args.map(formatConsoleArg).join(' ');
}

/**
* Patch `console.*` so messages also go through `tauri_plugin_log` (webview target → webview.log).
*/
function installWebviewConsoleForward(): void {
if (!isTauri || typeof window === 'undefined') return;
const w = window as unknown as Record<string, boolean | undefined>;
if (w[CONSOLE_FORWARD_INSTALLED]) return;

const c = window.console;
const orig = {
log: c.log.bind(c),
debug: c.debug.bind(c),
info: c.info.bind(c),
warn: c.warn.bind(c),
error: c.error.bind(c),
trace: c.trace.bind(c),
};

const forward = (
kind: 'log' | 'debug' | 'info' | 'warn' | 'error' | 'trace',
args: unknown[]
) => {
const msg = `[console] ${formatConsoleArgs(args)}`;
switch (kind) {
case 'log':
case 'info':
void tauriInfo(msg).catch(() => {});
break;
case 'debug':
void tauriDebug(msg).catch(() => {});
break;
case 'trace':
void tauriTrace(msg).catch(() => {});
break;
case 'warn':
void tauriWarn(msg).catch(() => {});
break;
case 'error':
void tauriError(msg).catch(() => {});
break;
}
};

c.log = (...args: unknown[]) => {
forward('log', args);
orig.log(...args);
};
c.info = (...args: unknown[]) => {
forward('info', args);
orig.info(...args);
};
c.debug = (...args: unknown[]) => {
forward('debug', args);
orig.debug(...args);
};
c.trace = (...args: unknown[]) => {
forward('trace', args);
orig.trace(...args);
};
c.warn = (...args: unknown[]) => {
forward('warn', args);
orig.warn(...args);
};
c.error = (...args: unknown[]) => {
forward('error', args);
orig.error(...args);
};

(window as unknown as Record<string, boolean | undefined>)[CONSOLE_FORWARD_INSTALLED] = true;
}

/**
* Install console forwarding as early as possible so startup logs are persisted too.
*/
export function bootstrapLogger(): void {
if (!isTauri) return;
try {
installWebviewConsoleForward();
} catch (e) {
console.warn('[Logger] Failed to install console forwarding:', e);
}
}

// Logger initialization state
let initialized = false;
let initPromise: Promise<void> | null = null;

/**
* Initialize logger - attaches console listener in dev mode
* Call this once at app startup
* Initialize logger state and ensure console forwarding is installed.
*/
export async function initLogger(): Promise<void> {
if (initialized) return;
if (initPromise) return initPromise;

initPromise = (async () => {
if (isTauri && isDev) {
try {
await attachConsole();
} catch (e) {
console.warn('[Logger] Failed to attach console:', e);
}
}
bootstrapLogger();
initialized = true;
})();

Expand Down
59 changes: 59 additions & 0 deletions src/web-ui/src/shared/utils/reactProductionError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Decode minified React production errors for support logs (desktop webview.log).
*/

const REACT_MINIFIED = /Minified React error #(\d+)/;

const REACT_HINTS: Record<string, string> = {
'300':
'Rendered fewer hooks than expected; often an early return before hooks, or conditional hooks.',
'301': 'Too many re-renders; possible infinite setState/useEffect loop.',
'310': 'Rendered more hooks than during the previous render; conditional hooks or hook order change.',
};

export function parseMinifiedReactError(message: string): {
code: string;
decoderUrl: string;
hint?: string;
} | null {
const m = message.match(REACT_MINIFIED);
if (!m?.[1]) return null;
const code = m[1];
return {
code,
decoderUrl: `https://react.dev/errors/${code}`,
hint: REACT_HINTS[code],
};
}

export function isMinifiedReactErrorMessage(message: string): boolean {
return REACT_MINIFIED.test(message);
}

/** Only keep serializable fields from React's errorInfo. */
export function safeReactErrorInfo(info: unknown): { componentStack?: string } {
if (!info || typeof info !== 'object') return {};
const cs = (info as { componentStack?: unknown }).componentStack;
if (typeof cs === 'string') return { componentStack: cs };
return {};
}

export function buildReactCrashLogPayload(error: Error, errorInfo?: unknown): Record<string, unknown> {
const message = error.message ?? '';
const diag = parseMinifiedReactError(message);
return {
error: {
name: error.name,
message,
stack: error.stack,
},
...safeReactErrorInfo(errorInfo),
...(diag
? {
reactInvariant: diag.code,
reactDecoderUrl: diag.decoderUrl,
...(diag.hint ? { reactHint: diag.hint } : {}),
}
: {}),
};
}
Loading