From 359518beacf18f251cdad11af24d752639ab3a8d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 3 Jul 2026 05:13:23 -0500 Subject: [PATCH] fix(desktop): guard link-title readTitle against destroyed windows Grace and timeout timers in runRenderTitleJob can call getTitle after finish() tears down the hidden BrowserWindow, throwing in the main process when the Artifacts page resolves many link titles concurrently. --- apps/desktop/electron/link-title-window.cjs | 21 +++++++- .../electron/link-title-window.test.cjs | 48 ++++++++++++++++++- apps/desktop/electron/main.cjs | 12 +++-- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron/link-title-window.cjs b/apps/desktop/electron/link-title-window.cjs index 3aeabcfe6..c6792bf98 100644 --- a/apps/desktop/electron/link-title-window.cjs +++ b/apps/desktop/electron/link-title-window.cjs @@ -49,4 +49,23 @@ function guardLinkTitleSession(partitionSession) { } } -module.exports = { createLinkTitleWindow, guardLinkTitleSession, linkTitleWindowOptions } +// Read the page title from a title-fetch window. Callers schedule this from +// timers that can fire after finish() destroys the window, so every access must +// guard isDestroyed and swallow Electron's "Object has been destroyed" throws. +function readLinkTitleWindowTitle(window) { + try { + if (!window || window.isDestroyed()) return '' + const contents = window.webContents + if (!contents || contents.isDestroyed()) return '' + return contents.getTitle() || '' + } catch { + return '' + } +} + +module.exports = { + createLinkTitleWindow, + guardLinkTitleSession, + linkTitleWindowOptions, + readLinkTitleWindowTitle +} diff --git a/apps/desktop/electron/link-title-window.test.cjs b/apps/desktop/electron/link-title-window.test.cjs index 64228ce4a..1682e5abb 100644 --- a/apps/desktop/electron/link-title-window.test.cjs +++ b/apps/desktop/electron/link-title-window.test.cjs @@ -1,7 +1,12 @@ const assert = require('node:assert/strict') const test = require('node:test') -const { createLinkTitleWindow, guardLinkTitleSession, linkTitleWindowOptions } = require('./link-title-window.cjs') +const { + createLinkTitleWindow, + guardLinkTitleSession, + linkTitleWindowOptions, + readLinkTitleWindowTitle +} = require('./link-title-window.cjs') function makeFakeBrowserWindow() { const calls = { audioMuted: [] } @@ -66,3 +71,44 @@ test('guardLinkTitleSession cancels downloads triggered by the title-fetch windo test('guardLinkTitleSession is a no-op when session.on throws', () => { assert.doesNotThrow(() => guardLinkTitleSession({ on() { throw new Error() } })) }) + +test('readLinkTitleWindowTitle returns empty for missing or destroyed windows', () => { + assert.equal(readLinkTitleWindowTitle(null), '') + assert.equal(readLinkTitleWindowTitle(undefined), '') + assert.equal(readLinkTitleWindowTitle({ isDestroyed: () => true }), '') +}) + +test('readLinkTitleWindowTitle returns empty when webContents is destroyed', () => { + const window = { + isDestroyed: () => false, + webContents: { isDestroyed: () => true, getTitle: () => 'Should Not Read' } + } + + assert.equal(readLinkTitleWindowTitle(window), '') +}) + +test('readLinkTitleWindowTitle swallows getTitle throws after teardown', () => { + const window = { + isDestroyed: () => false, + webContents: { + isDestroyed: () => false, + getTitle: () => { + throw new Error('Object has been destroyed') + } + } + } + + assert.equal(readLinkTitleWindowTitle(window), '') +}) + +test('readLinkTitleWindowTitle returns trimmed page title', () => { + const window = { + isDestroyed: () => false, + webContents: { + isDestroyed: () => false, + getTitle: () => 'Example Domain' + } + } + + assert.equal(readLinkTitleWindowTitle(window), 'Example Domain') +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 29b2891d7..da642b33a 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -36,7 +36,11 @@ const { SESSION_WINDOW_MIN_WIDTH } = require('./session-windows.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') -const { createLinkTitleWindow, guardLinkTitleSession } = require('./link-title-window.cjs') +const { + createLinkTitleWindow, + guardLinkTitleSession, + readLinkTitleWindowTitle +} = require('./link-title-window.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { adoptServedDashboardToken } = require('./dashboard-token.cjs') const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs') @@ -3557,13 +3561,13 @@ function runRenderTitleJob(rawUrl) { return finish('') } - const readTitle = () => window?.webContents?.getTitle?.() || '' + const finishWithTitle = () => finish(readLinkTitleWindowTitle(window)) const scheduleGrace = () => { if (graceTimer) clearTimeout(graceTimer) - graceTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_GRACE_MS) + graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS) } - hardTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_TIMEOUT_MS) + hardTimer = setTimeout(finishWithTitle, RENDER_TITLE_TIMEOUT_MS) window.webContents.setUserAgent(TITLE_USER_AGENT) window.webContents.on('page-title-updated', scheduleGrace)