diff --git a/apps/code/src/main/bootstrap.ts b/apps/code/src/main/bootstrap.ts index cdb038f4a5..b5cbf56343 100644 --- a/apps/code/src/main/bootstrap.ts +++ b/apps/code/src/main/bootstrap.ts @@ -18,7 +18,7 @@ import dns from "node:dns"; import { mkdirSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { app, protocol } from "electron"; +import { app, crashReporter, protocol } from "electron"; import { fixPath } from "./utils/fixPath"; const isDev = !app.isPackaged; @@ -57,6 +57,8 @@ app.commandLine.appendSwitch("enable-logging", "file"); app.commandLine.appendSwitch("log-file", chromiumLogPath); app.commandLine.appendSwitch("log-level", "0"); +crashReporter.start({ uploadToServer: false }); + // Force IPv4 resolution when "localhost" is used so the agent hits 127.0.0.1 // instead of ::1. This matches how the renderer already reaches the PostHog API. dns.setDefaultResultOrder("ipv4first"); diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 0a6967925e..57f06f7658 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -1,6 +1,6 @@ import "reflect-metadata"; import os from "node:os"; -import { app } from "electron"; +import { app, BrowserWindow } from "electron"; import log from "electron-log/main"; import "./utils/logger"; import "./services/index.js"; @@ -19,6 +19,7 @@ import type { NotificationService } from "./services/notification/service"; import type { OAuthService } from "./services/oauth/service"; import { captureException, + getPostHogClient, initializePostHog, trackAppEvent, } from "./services/posthog-analytics"; @@ -43,6 +44,102 @@ if (!gotTheLock) { process.exit(0); } +const RECOVERABLE_RENDER_REASONS = new Set([ + "abnormal-exit", + "killed", + "crashed", + "oom", + "integrity-failure", + "memory-eviction", +]); +const CRASH_LOOP_WINDOW_MS = 30_000; +const CRASH_LOOP_THRESHOLD = 3; +const recentCrashTimestamps: number[] = []; + +function isCrashLoop(): boolean { + const now = Date.now(); + while ( + recentCrashTimestamps.length > 0 && + now - recentCrashTimestamps[0] > CRASH_LOOP_WINDOW_MS + ) { + recentCrashTimestamps.shift(); + } + recentCrashTimestamps.push(now); + return recentCrashTimestamps.length >= CRASH_LOOP_THRESHOLD; +} + +app.on("render-process-gone", (_event, webContents, details) => { + const props = { + source: "main", + type: "render-process-gone", + reason: details.reason, + exitCode: String(details.exitCode), + url: webContents.getURL(), + title: webContents.getTitle(), + webContentsId: String(webContents.id), + }; + log.error("Renderer process gone", { + ...props, + chromiumLogTail: readChromiumLogTail(), + }); + captureException( + new Error(`Renderer process gone: ${details.reason}`), + props, + ); + getPostHogClient() + ?.flush() + .catch(() => {}); + + if (RECOVERABLE_RENDER_REASONS.has(details.reason)) { + if (isCrashLoop()) { + log.error("Crash loop detected, stopping auto-recovery", { + crashesInWindow: recentCrashTimestamps.length, + windowMs: CRASH_LOOP_WINDOW_MS, + }); + return; + } + log.info("Recovering from renderer crash", { reason: details.reason }); + const win = BrowserWindow.fromWebContents(webContents); + if (!win || win.isDestroyed()) { + log.warn("No window to recover"); + return; + } + setImmediate(() => { + if (win.isDestroyed()) return; + log.info("Reloading webContents"); + win.webContents.reload(); + log.info("Bringing window to foreground"); + win.show(); + win.moveTop(); + win.focus(); + app.focus({ steal: true }); + }); + } +}); + +app.on("child-process-gone", (_event, details) => { + const props = { + source: "main", + type: "child-process-gone", + processType: details.type, + reason: details.reason, + exitCode: String(details.exitCode), + serviceName: details.serviceName ?? "", + name: details.name ?? "", + }; + log.error("Child process gone", { + ...props, + chromiumLogTail: readChromiumLogTail(), + }); + captureException( + new Error(`Child process gone (${details.type}): ${details.reason}`), + props, + ); + getPostHogClient() + ?.flush() + .catch(() => {}); +}); + async function initializeServices(): Promise { container.get(MAIN_TOKENS.DatabaseService); container.get(MAIN_TOKENS.OAuthService); @@ -111,17 +208,6 @@ app.on("window-all-closed", () => { app.quit(); }); -app.on("child-process-gone", (_event, details) => { - log.error("Child process gone", { - type: details.type, - reason: details.reason, - exitCode: details.exitCode, - serviceName: details.serviceName, - name: details.name, - chromiumLogTail: readChromiumLogTail(), - }); -}); - app.on("before-quit", async (event) => { let lifecycleService: AppLifecycleService; try { diff --git a/apps/code/src/main/menu.ts b/apps/code/src/main/menu.ts index 3474ba5531..18b4a9fa67 100644 --- a/apps/code/src/main/menu.ts +++ b/apps/code/src/main/menu.ts @@ -1,4 +1,6 @@ +import { readdirSync, statSync } from "node:fs"; import os from "node:os"; +import path from "node:path"; import { app, BrowserWindow, @@ -17,6 +19,31 @@ import type { UpdatesService } from "./services/updates/service"; import { isDevBuild } from "./utils/env"; import { getLogFilePath } from "./utils/logger"; +function findLatestCrashDump(): string | null { + const pendingDir = path.join(app.getPath("crashDumps"), "pending"); + let entries: string[]; + try { + entries = readdirSync(pendingDir); + } catch { + return null; + } + let latest: { file: string; mtimeMs: number } | null = null; + for (const name of entries) { + if (!name.endsWith(".dmp")) continue; + const full = path.join(pendingDir, name); + let mtimeMs: number; + try { + mtimeMs = statSync(full).mtimeMs; + } catch { + continue; + } + if (!latest || mtimeMs > latest.mtimeMs) { + latest = { file: full, mtimeMs }; + } + } + return latest?.file ?? null; +} + function getSystemInfo(): string { const commit = __BUILD_COMMIT__ ?? "dev"; const buildDate = __BUILD_DATE__ ?? "dev"; @@ -124,6 +151,64 @@ function buildFileMenu(): MenuItemConstructorOptions { shell.showItemInFolder(getLogFilePath()); }, }, + { + label: + process.platform === "darwin" + ? "Show crash dumps in Finder" + : "Show crash dumps in file manager", + click: () => { + const latest = findLatestCrashDump(); + if (latest) { + shell.showItemInFolder(latest); + return; + } + const pendingDir = path.join( + app.getPath("crashDumps"), + "pending", + ); + void shell.openPath(pendingDir).then((err) => { + if (err) void shell.openPath(app.getPath("crashDumps")); + }); + }, + }, + ...(isDevBuild() + ? [ + { + label: "Test: terminate renderer (forced shutdown, no fault)", + click: () => { + const win = BrowserWindow.getFocusedWindow(); + if (!win) return; + win.webContents.forcefullyCrashRenderer(); + }, + }, + { + label: "Test: crash renderer (in-process, EXC_BAD_ACCESS)", + click: () => { + const win = BrowserWindow.getFocusedWindow(); + if (!win) return; + void win.webContents.executeJavaScript( + "window.__posthogCodeTest.crash()", + ); + }, + }, + { + label: "Test: abort renderer (in-process, SIGABRT)", + click: () => { + const win = BrowserWindow.getFocusedWindow(); + if (!win) return; + void win.webContents.executeJavaScript( + "window.__posthogCodeTest.abort()", + ); + }, + }, + { + label: "Test: crash main process (SIGABRT)", + click: () => { + process.crash(); + }, + }, + ] + : []), { type: "separator" }, { label: "Invalidate OAuth token", diff --git a/apps/code/src/main/preload.ts b/apps/code/src/main/preload.ts index 51b354da7d..b90c21f89e 100644 --- a/apps/code/src/main/preload.ts +++ b/apps/code/src/main/preload.ts @@ -6,6 +6,17 @@ contextBridge.exposeInMainWorld("electronUtils", { getPathForFile: (file: File) => webUtils.getPathForFile(file), }); +if (process.argv.includes("--posthog-code-dev")) { + contextBridge.exposeInMainWorld("__posthogCodeTest", { + crash: () => { + process.crash(); + }, + abort: () => { + process.abort(); + }, + }); +} + process.once("loaded", async () => { exposeElectronTRPC(); }); diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 40a8fba7c1..667b62e81d 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createIPCHandler } from "@posthog/electron-trpc/main"; import { + app, BrowserWindow, Menu, type MenuItemConstructorOptions, @@ -53,7 +54,7 @@ function getSavedWindowState(): WindowStateSchema { return state; } -function saveWindowState(window: BrowserWindow): void { +export function saveWindowState(window: BrowserWindow): void { const isMaximized = window.isMaximized(); windowStateStore.set("isMaximized", isMaximized); @@ -192,6 +193,7 @@ export function createWindow(): void { preload: path.join(__dirname, "preload.js"), enableBlinkFeatures: "GetDisplayMedia", partition: "persist:main", + additionalArguments: isDev ? ["--posthog-code-dev"] : [], ...(isDev && { webSecurity: false }), }, }); @@ -205,6 +207,9 @@ export function createWindow(): void { mainWindow?.maximize(); } mainWindow?.show(); + mainWindow?.moveTop(); + mainWindow?.focus(); + app.focus({ steal: true }); }; mainWindow.once("ready-to-show", showWindow); diff --git a/mprocs.yaml b/mprocs.yaml index da56ee4d2f..0b6b1d45f8 100644 --- a/mprocs.yaml +++ b/mprocs.yaml @@ -28,3 +28,6 @@ procs: 6-mobile-ios: shell: 'node scripts/pnpm-run.mjs --filter @posthog/mobile run ios' autostart: false + + 7-chromium-log: + shell: 'tail -F ~/.posthog-code/logs-dev/chromium.log'