Skip to content
Closed
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
159 changes: 92 additions & 67 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
import { createWakeIndicatorWindowController } from './wake-indicator-window'
import { readWindowBelow } from './window-below'
import { installWindowRendererLifecycle } from './window-renderer-lifecycle'
import { createWindowRevealController } from './window-reveal'
import {
bindGeometryPersistence,
Expand Down Expand Up @@ -1083,13 +1084,15 @@ const POOL_IDLE_MS = Math.max(60_000, Number(process.env.HERMES_DESKTOP_POOL_IDL
// killing one to honor the soft cap would abort a running agent.
const POOL_KEEPALIVE_FRESH_MS = 90_000
let poolIdleReaper = null
// Auto-reload budget for renderer crashes. A deterministic startup crash would
// otherwise loop forever (reload → crash → reload), pinning CPU and spamming
// logs. Allow a few reloads per rolling window, then stop and leave the dead
// window so the user can read the error / quit.
// Auto-reload budget for renderer crashes, shared by EVERY window (primary,
// secondary session, instance) so a crash loop anywhere is suppressed after
// the same budget instead of reloading per-window forever. A deterministic
// startup crash would otherwise loop forever (reload → crash → reload),
// pinning CPU and spamming logs. Allow a few reloads per rolling window, then
// stop and leave the dead window so the user can read the error / quit.
const RENDERER_RELOAD_WINDOW_MS = 60_000
const RENDERER_RELOAD_MAX = 3
let rendererReloadTimes = []
const rendererReloadTimesRef: { current: number[] } = { current: [] }
// Latched bootstrap failure: when the first-launch install fails, we hold
// onto the error so subsequent startHermes() calls (e.g. the renderer's
// ensureGatewayOpen retrying after the WS won't open) return the same error
Expand Down Expand Up @@ -6196,6 +6199,10 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
win.webContents.on('did-navigate', () => void checkCookie())
win.webContents.on('did-redirect-navigation', () => void checkCookie())
win.webContents.on('did-frame-navigate', () => void checkCookie())
// Log-only lifecycle diagnostics: a crashed sign-in renderer is invisible
// to the window's promise path (it never settles), so without this the
// failure leaves no trace in desktop.log (#81290 follow-up).
installWindowRendererLifecycle(win, { kind: 'oauth', callbacks: { log: rememberLog } })
pollTimer = setInterval(() => void checkCookie(), 750)

// Silent-mode reveal fallback: if the cascade hasn't settled shortly, the
Expand Down Expand Up @@ -6689,6 +6696,11 @@ function openPortalLoginWindow() {
win.webContents.on('did-navigate', () => void checkCookie())
win.webContents.on('did-redirect-navigation', () => void checkCookie())
win.webContents.on('did-frame-navigate', () => void checkCookie())
// Log-only lifecycle diagnostics, same rationale as the OAuth window:
// a crashed portal sign-in renderer never settles the promise, so the
// failure would otherwise leave no trace in desktop.log (#81290
// follow-up).
installWindowRendererLifecycle(win, { kind: 'portal', callbacks: { log: rememberLog } })
pollTimer = setInterval(() => void checkCookie(), 750)

win.on('closed', () => {
Expand Down Expand Up @@ -8889,6 +8901,23 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?
streamThrottle.register(win)
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))

// Renderer lifecycle diagnostics + recovery (#81290): a dead session-window
// renderer used to log nothing and stay black; now it logs with its window
// kind and reloads under the shared crash-loop budget, exactly like the
// primary window, without touching any other window.
installWindowRendererLifecycle(win, {
kind: 'secondary',
callbacks: {
log: rememberLog,
reload: () => {
win.webContents.reload()
}
},
reloadWindowMs: RENDERER_RELOAD_WINDOW_MS,
reloadMax: RENDERER_RELOAD_MAX,
recentReloadTimesRef: rendererReloadTimesRef
})

loadWindowUrl(
win,
buildSessionWindowUrl(sessionId, {
Expand Down Expand Up @@ -8969,6 +8998,22 @@ function createInstanceWindow() {
streamThrottle.register(win)
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))

// Renderer lifecycle diagnostics + recovery (#81290), same policy as the
// primary and session windows: a crashed instance renderer logs with its
// window kind and reloads under the shared crash-loop budget.
installWindowRendererLifecycle(win, {
kind: 'instance',
callbacks: {
log: rememberLog,
reload: () => {
win.webContents.reload()
}
},
reloadWindowMs: RENDERER_RELOAD_WINDOW_MS,
reloadMax: RENDERER_RELOAD_MAX,
recentReloadTimesRef: rendererReloadTimesRef
})

win.on('closed', () => {
instanceWindows.delete(win)
})
Expand All @@ -8984,6 +9029,7 @@ const wakeIndicatorController = createWakeIndicatorWindowController({
devServer: DEV_SERVER,
isMac: IS_MAC,
loadWindowUrl,
log: rememberLog,
preloadPath: PRELOAD_PATH,
rendererIndex: resolveRendererIndex,
wireWindow: window => wireCommonWindowHandlers(window, zoomWiringForWindowKind('wakeIndicator'))
Expand Down Expand Up @@ -9079,6 +9125,10 @@ function spawnPetOverlayWindow(bounds) {

wireWindowReveal(win, { show: () => win.showInactive() })

// Log-only renderer lifecycle (#81290): a dead overlay must never resurrect
// itself over the app, but its loss belongs in desktop.log.
installWindowRendererLifecycle(win, { kind: 'overlay', callbacks: { log: rememberLog } })

win.on('closed', () => {
if (petOverlayWindow === win) {
petOverlayWindow = null
Expand Down Expand Up @@ -9584,6 +9634,10 @@ function spawnQuickEntryWindow() {
// its own OS window and a zoomed composer would overflow it.
wireCommonWindowHandlers(win, zoomWiringForWindowKind('quickEntry'))

// Log-only renderer lifecycle (#81290): a dead quick-entry window must never
// resurrect itself over the app, but its loss belongs in desktop.log.
installWindowRendererLifecycle(win, { kind: 'quick', callbacks: { log: rememberLog } })

// Hide on blur. The window must never hold the user's focus captive — losing
// focus is the cheapest, least surprising dismiss (matches Spotlight).
win.on('blur', () => {
Expand Down Expand Up @@ -9821,88 +9875,59 @@ function createWindow() {
streamThrottle.register(mainWindow)
wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat'))

mainWindow.webContents.on('render-process-gone', (_event, details) => {
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)

if (details?.reason === 'crashed' || details?.reason === 'oom') {
const now = Date.now()
rendererReloadTimes = rendererReloadTimes.filter(t => now - t < RENDERER_RELOAD_WINDOW_MS)

if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
rememberLog(
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
)

// Per-window renderer lifecycle diagnostics + recovery (#81290). The reload
// policy (crashed/oom → bounded reload via the shared rolling budget, then
// the #38216 Windows sandbox relaunch check on suppression) is the same
// policy this window used before it moved into the shared helper, so a
// crashed peer renderer now logs and recovers exactly like the primary one.
installWindowRendererLifecycle(mainWindow, {
kind: 'main',
callbacks: {
log: rememberLog,
reload: () => {
mainWindow.webContents.reload()
},
onCrashLoopSuppressed: details => {
// #38216 renderer flavor (same recovery as #56726, credit @Sahil-SS9):
// a deterministic Windows renderer crash loop with the sandbox
// breakpoint signature gets one --no-sandbox relaunch instead of a
// dead window. Gated on the exit code so unrelated crash loops don't
// silently drop the sandbox.
if (
shouldRelaunchForRendererSandboxCrashLoop({
!shouldRelaunchForRendererSandboxCrashLoop({
reason: details?.reason,
exitCode: details?.exitCode,
alreadyNoSandbox: windowsSandboxFallbackActive || alreadyHasNoSandbox(process.argv, process.env),
relaunchAttempted: windowsNoSandboxRelaunchAttempted
})
) {
windowsNoSandboxRelaunchAttempted = true
windowsSandboxFallbackActive = true
windowsSandboxFallbackSticky = true
windowsSandboxFallbackReason = 'renderer-crash-loop'

try {
writeSandboxMarker(app.getPath('userData'), fallbackMarker('renderer-crash-loop', app.getVersion()))
} catch {
void 0
}

rememberLog('[renderer] Windows sandbox crash loop detected; relaunching once with --no-sandbox (#38216)')

try {
app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) })
app.exit(0)
} catch (err) {
rememberLog(`[renderer] --no-sandbox relaunch failed: ${err?.message || err}`)
}
return
}

return
}
windowsNoSandboxRelaunchAttempted = true
windowsSandboxFallbackActive = true
windowsSandboxFallbackSticky = true
windowsSandboxFallbackReason = 'renderer-crash-loop'

rendererReloadTimes.push(now)
setImmediate(() => {
if (!mainWindow || mainWindow.isDestroyed()) {
return
try {
writeSandboxMarker(app.getPath('userData'), fallbackMarker('renderer-crash-loop', app.getVersion()))
} catch {
void 0
}

rememberLog('[renderer] Windows sandbox crash loop detected; relaunching once with --no-sandbox (#38216)')

try {
mainWindow.webContents.reload()
app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) })
app.exit(0)
} catch (err) {
rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`)
rememberLog(`[renderer] --no-sandbox relaunch failed: ${err?.message || err}`)
}
})
}
})

mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive'))

// Electron always passes the event first. The canonical (Electron 36+) shape
// is (event, messageDetails); the deprecated positional shape is
// (event, level, message, line, sourceId). Handle both. `level` is numeric
// (0..3), where 3 === error.
mainWindow.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => {
const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null
const level = details ? details.level : detailsOrLevel

if (level !== 3) {
return
}

const text = details ? details.message : message
const src = details ? details.sourceUrl : sourceId
const lineNo = details ? details.lineNumber : line
rememberLog(`[renderer console] ${text} (${src}:${lineNo})`)
}
},
reloadWindowMs: RENDERER_RELOAD_WINDOW_MS,
reloadMax: RENDERER_RELOAD_MAX,
recentReloadTimesRef: rendererReloadTimesRef
})

loadWindowUrl(mainWindow, DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString(), 'Renderer')
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/electron/wake-indicator-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {
type WakeIndicatorState,
wakeIndicatorWindowBounds
} from './wake-indicator'
import { installWindowRendererLifecycle } from './window-renderer-lifecycle'

interface WakeIndicatorWindowOptions {
devServer?: string
isMac: boolean
loadWindowUrl: (window: BrowserWindow, url: string, label: string) => void
log: (message: string) => void
preloadPath: string
rendererIndex: () => string
wireWindow: (window: BrowserWindow) => void
Expand All @@ -23,6 +25,7 @@ export function createWakeIndicatorWindowController({
devServer,
isMac,
loadWindowUrl,
log,
preloadPath,
rendererIndex,
wireWindow
Expand Down Expand Up @@ -100,6 +103,10 @@ export function createWakeIndicatorWindowController({

wireWindow(next)

// Log-only renderer lifecycle (#81290): the wake cue is ambient and
// macOS-only; its loss belongs in desktop.log, never resurrected.
installWindowRendererLifecycle(next, { kind: 'wake', callbacks: { log } })

next.webContents.on('did-finish-load', sendState)
next.once('ready-to-show', () => {
if (!next.isDestroyed() && state !== 'hidden') {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/wake-indicator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe('wake indicator window controller', () => {
const controller = createWakeIndicatorWindowController({
isMac: true,
loadWindowUrl: vi.fn(),
log: () => {},
preloadPath: '/tmp/preload.cjs',
rendererIndex: () => '/tmp/index.html',
wireWindow: vi.fn()
Expand Down
Loading
Loading