From b1c4114730cf13e3824885b4c6ccd51b57eb0130 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 14 Jul 2026 16:29:48 +0100 Subject: [PATCH 1/2] fix(code): escape repeated renderer crash routes Avoid reloading the same task route after a repeated renderer crash, while retaining bounded automatic recovery. Generated-By: PostHog Code Task-Id: f0303120-22c3-4c5a-b134-2590d01fdc6e --- apps/code/src/main/index.ts | 38 ++++++++++--------- .../utils/renderer-crash-recovery.test.ts | 38 +++++++++++++++++++ .../src/main/utils/renderer-crash-recovery.ts | 37 ++++++++++++++++++ 3 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 apps/code/src/main/utils/renderer-crash-recovery.test.ts create mode 100644 apps/code/src/main/utils/renderer-crash-recovery.ts diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 9fa4d958ca..35ffd4f991 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -101,6 +101,10 @@ import { import { isMacosPackagedUnsafeBundleLocation } from "./utils/macos-packaged-install-guard"; import { installMainFetchLogging } from "./utils/network-fetch-logger"; import { installRendererNetworkLogging } from "./utils/network-webrequest-logger"; +import { + RendererCrashRecovery, + toSafeRendererUrl, +} from "./utils/renderer-crash-recovery"; import { createWindow } from "./window"; type FileWatcherEventsByKind = { @@ -167,19 +171,10 @@ const RECOVERABLE_RENDER_REASONS = new Set([ ]); 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; -} +const rendererCrashRecovery = new RendererCrashRecovery({ + crashLoopThreshold: CRASH_LOOP_THRESHOLD, + crashLoopWindowMs: CRASH_LOOP_WINDOW_MS, +}); function crashDiagnostics() { return { @@ -211,9 +206,10 @@ app.on("render-process-gone", (_event, webContents, details) => { posthogNodeAnalytics.flush().catch(() => {}); if (RECOVERABLE_RENDER_REASONS.has(details.reason)) { - if (isCrashLoop()) { + const recoveryAction = rendererCrashRecovery.nextAction(); + if (recoveryAction === "stop") { log.error("Crash loop detected, stopping auto-recovery", { - crashesInWindow: recentCrashTimestamps.length, + crashesInWindow: rendererCrashRecovery.recentCrashCount, windowMs: CRASH_LOOP_WINDOW_MS, }); return; @@ -226,8 +222,16 @@ app.on("render-process-gone", (_event, webContents, details) => { } setImmediate(() => { if (win.isDestroyed()) return; - log.info("Reloading webContents"); - win.webContents.reload(); + if (recoveryAction === "reset-route") { + const safeUrl = toSafeRendererUrl(win.webContents.getURL()); + log.info("Reloading renderer without the crashing route", { safeUrl }); + void win.loadURL(safeUrl).catch((error) => { + log.error("Failed to load renderer recovery URL", { error }); + }); + } else { + log.info("Reloading webContents"); + win.webContents.reload(); + } log.info("Bringing window to foreground"); win.show(); win.moveTop(); diff --git a/apps/code/src/main/utils/renderer-crash-recovery.test.ts b/apps/code/src/main/utils/renderer-crash-recovery.test.ts new file mode 100644 index 0000000000..81a36523f7 --- /dev/null +++ b/apps/code/src/main/utils/renderer-crash-recovery.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + RendererCrashRecovery, + toSafeRendererUrl, +} from "./renderer-crash-recovery"; + +describe("RendererCrashRecovery", () => { + it("escapes the crashing route before stopping automatic recovery", () => { + const recovery = new RendererCrashRecovery({ + crashLoopThreshold: 3, + crashLoopWindowMs: 30_000, + }); + + expect(recovery.nextAction(0)).toBe("reload"); + expect(recovery.nextAction(13_000)).toBe("reset-route"); + expect(recovery.nextAction(20_000)).toBe("stop"); + }); + + it("starts with a normal reload after the crash window expires", () => { + const recovery = new RendererCrashRecovery({ + crashLoopThreshold: 3, + crashLoopWindowMs: 30_000, + }); + + expect(recovery.nextAction(0)).toBe("reload"); + expect(recovery.nextAction(31_000)).toBe("reload"); + }); +}); + +describe("toSafeRendererUrl", () => { + it("removes the route while preserving the renderer entrypoint", () => { + expect( + toSafeRendererUrl( + "file:///Applications/PostHog%20Code.app/index.html#/website/site/tasks/task", + ), + ).toBe("file:///Applications/PostHog%20Code.app/index.html"); + }); +}); diff --git a/apps/code/src/main/utils/renderer-crash-recovery.ts b/apps/code/src/main/utils/renderer-crash-recovery.ts new file mode 100644 index 0000000000..f4588d04bb --- /dev/null +++ b/apps/code/src/main/utils/renderer-crash-recovery.ts @@ -0,0 +1,37 @@ +export type RendererCrashRecoveryAction = "reload" | "reset-route" | "stop"; + +type RendererCrashRecoveryOptions = { + crashLoopThreshold: number; + crashLoopWindowMs: number; +}; + +export class RendererCrashRecovery { + private readonly recentCrashTimestamps: number[] = []; + + constructor(private readonly options: RendererCrashRecoveryOptions) {} + + nextAction(now = Date.now()): RendererCrashRecoveryAction { + while ( + this.recentCrashTimestamps.length > 0 && + now - this.recentCrashTimestamps[0] > this.options.crashLoopWindowMs + ) { + this.recentCrashTimestamps.shift(); + } + + this.recentCrashTimestamps.push(now); + if (this.recentCrashTimestamps.length >= this.options.crashLoopThreshold) { + return "stop"; + } + return this.recentCrashTimestamps.length === 1 ? "reload" : "reset-route"; + } + + get recentCrashCount(): number { + return this.recentCrashTimestamps.length; + } +} + +export function toSafeRendererUrl(rendererUrl: string): string { + const url = new URL(rendererUrl); + url.hash = ""; + return url.toString(); +} From f54722115fbd238f94a0b9039f24e43c770a32bf Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 14 Jul 2026 16:55:40 +0100 Subject: [PATCH 2/2] fix(ui): cap chat diff worker pool Share the bounded conversation diff worker configuration across both thread renderers so diff-heavy task histories cannot start the default eight highlighter workers. Generated-By: PostHog Code Task-Id: f0303120-22c3-4c5a-b134-2590d01fdc6e --- .../sessions/components/ConversationView.tsx | 10 ++-------- .../components/chat-thread/ChatThread.tsx | 6 ++---- .../sessions/conversationDiffPoolOptions.test.ts | 15 +++++++++++++++ .../sessions/conversationDiffPoolOptions.ts | 11 +++++++++++ 4 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/features/sessions/conversationDiffPoolOptions.test.ts create mode 100644 packages/ui/src/features/sessions/conversationDiffPoolOptions.ts diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index c3f39d0f9c..d5046aa8dc 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -40,6 +40,7 @@ import { type VirtualizedListHandle, } from "@posthog/ui/features/sessions/components/VirtualizedList"; import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; +import { createConversationDiffPoolOptions } from "@posthog/ui/features/sessions/conversationDiffPoolOptions"; import { DIFFS_HIGHLIGHTER_OPTIONS } from "@posthog/ui/features/sessions/diffHighlighterOptions"; import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; @@ -102,14 +103,7 @@ export function ConversationView({ }: ConversationViewProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); const diffsPoolOptions = useMemo( - () => ({ - workerFactory: () => diffWorkerFactory(), - totalASTLRUCacheSize: 200, - // Each pooled highlighter worker is a full V8 isolate with shiki - // grammars loaded (~40MB RSS); the library default of 8 costs hundreds - // of MB for parallelism conversation diffs don't need. - poolSize: 2, - }), + () => createConversationDiffPoolOptions(diffWorkerFactory), [diffWorkerFactory], ); diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 6e82d78d3d..80999597fa 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -61,6 +61,7 @@ import { import { SessionUpdateView } from "@posthog/ui/features/sessions/components/session-update/SessionUpdateView"; import { UserShellExecuteView } from "@posthog/ui/features/sessions/components/session-update/UserShellExecuteView"; import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; +import { createConversationDiffPoolOptions } from "@posthog/ui/features/sessions/conversationDiffPoolOptions"; import { DIFFS_HIGHLIGHTER_OPTIONS } from "@posthog/ui/features/sessions/diffHighlighterOptions"; import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; import { @@ -990,10 +991,7 @@ export function ChatThread({ }: ConversationViewProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); const diffsPoolOptions = useMemo( - () => ({ - workerFactory: () => diffWorkerFactory(), - totalASTLRUCacheSize: 200, - }), + () => createConversationDiffPoolOptions(diffWorkerFactory), [diffWorkerFactory], ); diff --git a/packages/ui/src/features/sessions/conversationDiffPoolOptions.test.ts b/packages/ui/src/features/sessions/conversationDiffPoolOptions.test.ts new file mode 100644 index 0000000000..ef68a07591 --- /dev/null +++ b/packages/ui/src/features/sessions/conversationDiffPoolOptions.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from "vitest"; +import { createConversationDiffPoolOptions } from "./conversationDiffPoolOptions"; + +describe("createConversationDiffPoolOptions", () => { + it("limits syntax-highlighting workers for diff-heavy conversations", () => { + const worker = {} as Worker; + const diffWorkerFactory = vi.fn(() => worker); + const options = createConversationDiffPoolOptions(diffWorkerFactory); + + expect(options.poolSize).toBe(2); + expect(options.totalASTLRUCacheSize).toBe(200); + expect(options.workerFactory()).toBe(worker); + expect(diffWorkerFactory).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/features/sessions/conversationDiffPoolOptions.ts b/packages/ui/src/features/sessions/conversationDiffPoolOptions.ts new file mode 100644 index 0000000000..b530f3d757 --- /dev/null +++ b/packages/ui/src/features/sessions/conversationDiffPoolOptions.ts @@ -0,0 +1,11 @@ +import type { DiffWorkerFactory } from "@posthog/ui/shell/diffWorkerHost"; + +export function createConversationDiffPoolOptions( + diffWorkerFactory: DiffWorkerFactory, +) { + return { + workerFactory: () => diffWorkerFactory(), + totalASTLRUCacheSize: 200, + poolSize: 2, + }; +}