From 02997be43735ffca1208f78ba873e5fa481ec992 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 5 Aug 2026 13:18:53 -0300 Subject: [PATCH 1/4] fix: recover wedged webview boot instead of dying on injected script failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Meteor module registry is incomplete ('Cannot find module /app/utils/rocketchat.info'), the injected script used to throw an uncaught rejection and give up, leaving the workspace stuck on the loading throbber until a full app restart — a plain reload never recovers this state. - On require failure after retries, trigger reloadServer() (force reload with service worker + cache storage clearing) - Cap automatic recoveries at 2 per session via a sessionStorage counter (reset on successful boot); the pre-existing window.require recovery path previously reloaded without any limit and now shares the same guard - Catch rejections from start() and its setTimeout retries so failures are logged instead of surfacing as unhandled rejections in Bugsnag --- src/injected.ts | 94 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/src/injected.ts b/src/injected.ts index 0ba6306a12..64ff2da1d8 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -28,6 +28,59 @@ const resolveWithExponentialBackoff = ( const tryRequire = (path: string) => resolveWithExponentialBackoff(() => window.require(path)); +const BOOT_RECOVERY_ATTEMPTS_KEY = 'rocketChatDesktopBootRecoveryAttempts'; +const MAX_BOOT_RECOVERY_ATTEMPTS = 2; + +const getBootRecoveryAttempts = (): number => { + try { + return ( + Number(window.sessionStorage.getItem(BOOT_RECOVERY_ATTEMPTS_KEY)) || 0 + ); + } catch (error) { + return 0; + } +}; + +// Reloading in place never recovers a wedged webview (stale service worker or +// cache keeps serving a broken bundle), so recovery goes through +// reloadServer(), which clears the service worker and cache storage before +// reloading. The sessionStorage counter survives those reloads and caps the +// attempts, so a genuinely broken server degrades instead of reload-looping. +const attemptBootRecovery = (reason: string): void => { + const attempts = getBootRecoveryAttempts(); + + if (attempts >= MAX_BOOT_RECOVERY_ATTEMPTS) { + console.error( + `[Rocket.Chat Desktop] ${reason}. Boot recovery attempts exhausted (${attempts}); giving up. Restart the app or use "Reload Clearing Cache" to retry.` + ); + return; + } + + try { + window.sessionStorage.setItem( + BOOT_RECOVERY_ATTEMPTS_KEY, + String(attempts + 1) + ); + } catch (error) { + // sessionStorage unavailable; still attempt a single recovery + } + + console.error( + `[Rocket.Chat Desktop] ${reason}. Triggering force reload with cache clear to recover (attempt ${ + attempts + 1 + } of ${MAX_BOOT_RECOVERY_ATTEMPTS})...` + ); + window.RocketChatDesktop.reloadServer(); +}; + +const resetBootRecovery = (): void => { + try { + window.sessionStorage.removeItem(BOOT_RECOVERY_ATTEMPTS_KEY); + } catch (error) { + // sessionStorage unavailable; nothing to reset + } +}; + let startRetryCount = 0; let totalRetryTime = 0; const MAX_RETRY_TIME = 30000; // Maximum 30 seconds total retry time @@ -40,14 +93,9 @@ const start = async () => { console.log('[Rocket.Chat Desktop] window.require is not defined'); if (totalRetryTime >= MAX_RETRY_TIME) { - console.error( - `[Rocket.Chat Desktop] Maximum retry time (${MAX_RETRY_TIME}ms) reached. window.require is still not available.` + attemptBootRecovery( + `Maximum retry time (${MAX_RETRY_TIME}ms) reached. window.require is still not available` ); - console.log( - '[Rocket.Chat Desktop] Triggering force reload with cache clear to recover...' - ); - // Trigger force reload with cache clear to recover - window.RocketChatDesktop.reloadServer(); return; } @@ -64,7 +112,14 @@ const start = async () => { console.log( `[Rocket.Chat Desktop] Inject start - retry ${startRetryCount} in ${actualDelay}ms (total time: ${totalRetryTime}ms)` ); - setTimeout(start, actualDelay); + setTimeout(() => { + start().catch((error) => { + console.error( + '[Rocket.Chat Desktop] Injected.ts failed to start:', + error + ); + }); + }, actualDelay); return; } @@ -72,15 +127,28 @@ const start = async () => { startRetryCount = 0; totalRetryTime = 0; - const { Info: serverInfo = {} } = await tryRequire( - '/app/utils/rocketchat.info' - ); + let serverInfo: any = {}; + try { + ({ Info: serverInfo = {} } = await tryRequire( + '/app/utils/rocketchat.info' + )); + } catch (error) { + // window.require exists but the module registry is incomplete — the + // webapp boot is wedged (stuck throbber) and only a cache-clearing + // reload recovers it. + attemptBootRecovery( + "Failed to require '/app/utils/rocketchat.info' after retries" + ); + return; + } if (!serverInfo.version) { console.log('[Rocket.Chat Desktop] serverInfo.version is not defined'); return; } + resetBootRecovery(); + console.log('[Rocket.Chat Desktop] Injected.ts serverInfo', serverInfo); window.RocketChatDesktop.setServerInfo(serverInfo); @@ -693,4 +761,6 @@ const start = async () => { console.log('[Rocket.Chat Desktop] Injected'); }; -start(); +start().catch((error) => { + console.error('[Rocket.Chat Desktop] Injected.ts failed to start:', error); +}); From 1e098ec96197e50297594d96c0523bc91edecf7f Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 5 Aug 2026 13:44:16 -0300 Subject: [PATCH 2/4] feat: add dev-only boot watchdog with forensic reports for wedged webviews Every wedge occurrence now produces a self-contained forensic report in /boot-watchdog.jsonl instead of requiring a live autopsy. Enabled in development, opt-in elsewhere via ROCKETCHAT_BOOT_WATCHDOG=true. - Track each server webview boot cycle: a committed main-frame navigation (did-navigate) arms a 90s deadline cleared by the WEBVIEW_SERVER_VERSION_UPDATED signal; did-start-loading is recorded but does not reset the cycle (it fires for subframe/resource loads too) - On deadline exceeded, render-process-gone, unresponsive, or injected recovery exhaustion, append a JSONL report with an in-page probe (require/module/sessionStorage/service-worker state), running service workers, process metrics, event timeline, and the last 150 console messages - Wire the orphaned setVersion preload call into setServerInfo so WEBVIEW_SERVER_VERSION_UPDATED actually fires on webapp boot, as the reducer comment already assumed --- src/main.ts | 2 + src/servers/bootWatchdog.ts | 327 ++++++++++++++++++++++++++++++++ src/servers/preload/api.ts | 2 + src/ui/main/serverView/index.ts | 2 + 4 files changed, 333 insertions(+) create mode 100644 src/servers/bootWatchdog.ts diff --git a/src/main.ts b/src/main.ts index 54fdf107a5..6fe47f8a28 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,6 +38,7 @@ import { setupOutlookLogger } from './outlookCalendar/logger'; import { handleDesktopCapturerGetSources } from './screenSharing/desktopCapturerCache'; import { setupScreenSharing } from './screenSharing/main'; import { startServerViewScreenSharingHandler } from './screenSharing/serverViewScreenSharing'; +import { setupBootWatchdog } from './servers/bootWatchdog'; import { handleClearCacheDialog, handleUserLoggedOutDataClearing, @@ -159,6 +160,7 @@ const start = async (): Promise => { handleDesktopCapturerGetSources(); handleClearCacheDialog(); handleUserLoggedOutDataClearing(); + setupBootWatchdog(); startDocumentViewerHandler(); startBrowserHandler(); checkSupportedVersionServers(); diff --git a/src/servers/bootWatchdog.ts b/src/servers/bootWatchdog.ts new file mode 100644 index 0000000000..47948f4907 --- /dev/null +++ b/src/servers/bootWatchdog.ts @@ -0,0 +1,327 @@ +import fs from 'fs'; +import path from 'path'; + +import { app } from 'electron'; +import type { WebContents } from 'electron'; + +import { listen } from '../store'; +import { WEBVIEW_SERVER_VERSION_UPDATED } from '../ui/actions'; + +const BOOT_DEADLINE_MS = 90_000; +const CONSOLE_BUFFER_LIMIT = 150; +const TIMELINE_LIMIT = 300; +const MESSAGE_LENGTH_LIMIT = 1_000; +const PROBE_TIMEOUT_MS = 5_000; + +type TimelineEntry = { + ts: string; + event: string; + detail?: unknown; +}; + +type ConsoleEntry = { + ts: string; + level: unknown; + message: string; + sourceId?: string; + lineNumber?: number; +}; + +type WatchState = { + serverUrl: string; + webContents: WebContents; + consoleBuffer: ConsoleEntry[]; + timeline: TimelineEntry[]; + deadlineTimer: ReturnType | null; + booted: boolean; + reportedForCurrentLoad: boolean; +}; + +const watchStates = new Map(); + +export const isBootWatchdogEnabled = (): boolean => + process.env.NODE_ENV === 'development' || + process.env.ROCKETCHAT_BOOT_WATCHDOG === 'true'; + +const reportFilePath = (): string => + path.join(app.getPath('logs'), 'boot-watchdog.jsonl'); + +const now = (): string => new Date().toISOString(); + +const record = (state: WatchState, event: string, detail?: unknown): void => { + state.timeline.push({ + ts: now(), + event, + ...(detail !== undefined && { detail }), + }); + if (state.timeline.length > TIMELINE_LIMIT) { + state.timeline.shift(); + } +}; + +// Runs inside the (possibly wedged) webview to capture the state the boot got +// stuck in. Mirrors the manual CDP autopsy checklist. +const PROBE_EXPRESSION = `JSON.stringify({ + readyState: document.readyState, + title: document.title, + requireType: typeof window.require, + infoModule: (() => { + try { + return typeof window.require === 'function' + ? Boolean(window.require('/app/utils/rocketchat.info').Info) + : 'no-require'; + } catch (error) { + return 'broken: ' + (error && error.message); + } + })(), + recoveryAttempts: (() => { + try { + return window.sessionStorage.getItem('rocketChatDesktopBootRecoveryAttempts'); + } catch (error) { + return 'unavailable'; + } + })(), + serviceWorkerControlled: Boolean(navigator.serviceWorker && navigator.serviceWorker.controller), +})`; + +const runProbe = async (webContents: WebContents): Promise => { + const probe = webContents + .executeJavaScript(PROBE_EXPRESSION, true) + .then((value) => { + try { + return JSON.parse(value); + } catch (error) { + return value; + } + }); + const timeout = new Promise((resolve) => { + setTimeout( + () => resolve(`probe timed out after ${PROBE_TIMEOUT_MS}ms`), + PROBE_TIMEOUT_MS + ); + }); + return Promise.race([probe, timeout]); +}; + +const writeReport = async ( + state: WatchState, + reason: string +): Promise => { + const { webContents, serverUrl } = state; + const destroyed = webContents.isDestroyed(); + + let probe: unknown; + if (!destroyed) { + try { + probe = await runProbe(webContents); + } catch (error) { + probe = `probe failed: ${error instanceof Error ? error.message : error}`; + } + } + + let serviceWorkers: unknown; + try { + serviceWorkers = destroyed + ? 'webContents destroyed' + : webContents.session.serviceWorkers.getAllRunning(); + } catch (error) { + serviceWorkers = `unavailable: ${ + error instanceof Error ? error.message : error + }`; + } + + let processMetrics: unknown; + try { + const pid = destroyed ? undefined : webContents.getOSProcessId(); + processMetrics = app.getAppMetrics().filter((metric) => metric.pid === pid); + } catch (error) { + processMetrics = `unavailable: ${ + error instanceof Error ? error.message : error + }`; + } + + const report = { + ts: now(), + reason, + serverUrl, + appVersion: app.getVersion(), + electronVersion: process.versions.electron, + pageUrl: destroyed ? undefined : webContents.getURL(), + isLoading: destroyed ? undefined : webContents.isLoading(), + isCrashed: destroyed ? undefined : webContents.isCrashed(), + probe, + serviceWorkers, + processMetrics, + timeline: state.timeline, + console: state.consoleBuffer, + }; + + try { + await fs.promises.appendFile( + reportFilePath(), + `${JSON.stringify(report)}\n` + ); + console.warn( + `[bootWatchdog] ${reason} for ${serverUrl} — report appended to ${reportFilePath()}` + ); + } catch (error) { + console.error('[bootWatchdog] failed to write report:', error); + } +}; + +const clearDeadline = (state: WatchState): void => { + if (state.deadlineTimer !== null) { + clearTimeout(state.deadlineTimer); + state.deadlineTimer = null; + } +}; + +const startDeadline = (state: WatchState): void => { + clearDeadline(state); + state.deadlineTimer = setTimeout(() => { + state.deadlineTimer = null; + if (state.booted || state.webContents.isDestroyed()) { + return; + } + if (state.reportedForCurrentLoad) { + return; + } + state.reportedForCurrentLoad = true; + record(state, 'boot-deadline-exceeded', { deadlineMs: BOOT_DEADLINE_MS }); + writeReport(state, 'boot-deadline-exceeded'); + }, BOOT_DEADLINE_MS); +}; + +export const attachBootWatchdog = ( + serverUrl: string, + webContents: WebContents +): void => { + if (!isBootWatchdogEnabled()) { + return; + } + + const state: WatchState = { + serverUrl, + webContents, + consoleBuffer: [], + timeline: [], + deadlineTimer: null, + booted: false, + reportedForCurrentLoad: false, + }; + watchStates.set(serverUrl, state); + record(state, 'attached'); + startDeadline(state); + + webContents.on('console-message', (event) => { + const message = String(event.message ?? '').slice(0, MESSAGE_LENGTH_LIMIT); + state.consoleBuffer.push({ + ts: now(), + level: event.level, + message, + sourceId: + typeof event.sourceId === 'string' + ? event.sourceId.slice(0, 200) + : undefined, + lineNumber: + typeof event.lineNumber === 'number' ? event.lineNumber : undefined, + }); + if (state.consoleBuffer.length > CONSOLE_BUFFER_LIMIT) { + state.consoleBuffer.shift(); + } + + if (message.includes('Triggering force reload with cache clear')) { + record(state, 'injected-recovery-triggered', message); + } + if (message.includes('Boot recovery attempts exhausted')) { + record(state, 'injected-recovery-exhausted', message); + if (!state.reportedForCurrentLoad) { + state.reportedForCurrentLoad = true; + writeReport(state, 'injected-recovery-exhausted'); + } + } + }); + + // did-start-loading fires for subframe/resource loads too, so it must not + // reset the boot cycle — a healthy booted page would re-arm the deadline + // with no version signal left to clear it. Only a committed main-frame + // navigation (did-navigate) starts a new boot cycle. + webContents.on('did-start-loading', () => { + record(state, 'did-start-loading'); + }); + + webContents.on('did-navigate', (_event, url) => { + state.booted = false; + state.reportedForCurrentLoad = false; + record(state, 'did-navigate', { url }); + startDeadline(state); + }); + + webContents.on('did-finish-load', () => { + record(state, 'did-finish-load', { url: webContents.getURL() }); + }); + + webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + record(state, 'did-fail-load', { + errorCode, + errorDescription, + validatedURL, + isMainFrame, + }); + if (isMainFrame) { + // A visible navigation failure (ErrorView) is not a silent wedge — + // stop the deadline so it does not produce a redundant report. + clearDeadline(state); + } + } + ); + + webContents.on('render-process-gone', (_event, details) => { + record(state, 'render-process-gone', details); + clearDeadline(state); + if (!state.reportedForCurrentLoad) { + state.reportedForCurrentLoad = true; + writeReport(state, 'render-process-gone'); + } + }); + + webContents.on('unresponsive', () => { + record(state, 'unresponsive'); + if (!state.reportedForCurrentLoad) { + state.reportedForCurrentLoad = true; + writeReport(state, 'unresponsive'); + } + }); + + webContents.on('destroyed', () => { + clearDeadline(state); + watchStates.delete(serverUrl); + }); +}; + +export const setupBootWatchdog = (): void => { + if (!isBootWatchdogEnabled()) { + return; + } + + console.info( + `[bootWatchdog] enabled — wedged-boot reports will be appended to ${reportFilePath()}` + ); + + listen(WEBVIEW_SERVER_VERSION_UPDATED, (action) => { + const { url, version } = action.payload; + const state = + watchStates.get(url) ?? + Array.from(watchStates.values()).find( + (candidate) => new URL(candidate.serverUrl).href === new URL(url).href + ); + if (!state) { + return; + } + state.booted = true; + clearDeadline(state); + record(state, 'server-version-updated', version); + }); +}; diff --git a/src/servers/preload/api.ts b/src/servers/preload/api.ts index bb77ee43c2..2f0a6e5929 100644 --- a/src/servers/preload/api.ts +++ b/src/servers/preload/api.ts @@ -42,6 +42,7 @@ import { setTitle } from './title'; import { setUrlResolver } from './urls'; import { setUserLoggedIn } from './userLoggedIn'; import { setUserRoles } from './userRoles'; +import { setVersion } from './version'; type ServerInfo = { version: string; @@ -83,6 +84,7 @@ export const RocketChatDesktop: Window['RocketChatDesktop'] = { serverInfo = _serverInfo; cb(_serverInfo); setServerVersionToSidebar(_serverInfo.version); + setVersion(_serverInfo.version); }, setUrlResolver, setBadge, diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index 1edea3963f..b8660b6650 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -21,6 +21,7 @@ import { CERTIFICATES_CLEARED } from '../../../navigation/actions'; import { isProtocolAllowed } from '../../../navigation/main'; import { setupServerViewDisplayMedia } from '../../../screenSharing/serverViewScreenSharing'; import { SERVER_DOCUMENT_VIEWER_OPEN_URL } from '../../../servers/actions'; +import { attachBootWatchdog } from '../../../servers/bootWatchdog'; import type { Server } from '../../../servers/common'; import { dispatch, listen, select } from '../../../store'; import { openExternal } from '../../../utils/browserLauncher'; @@ -264,6 +265,7 @@ const initializeServerWebContentsAfterAttach = ( rootWindow: BrowserWindow ): void => { webContentsByServerUrl.set(serverUrl, guestWebContents); + attachBootWatchdog(serverUrl, guestWebContents); const webviewSession = guestWebContents.session; From 090de61148551964eec2869a172d50a534359d18 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 5 Aug 2026 13:48:25 -0300 Subject: [PATCH 3/4] docs: add boot-wedge-debug skill for the webview boot wedge investigation Agent/dev workflow: read boot-watchdog.jsonl reports first, run live CDP autopsies on wedged webviews (cdp-eval.mjs helper included), walk the manual cure ladder to localize the broken layer, and map findings to the next fix on this PR. --- .claude/skills/boot-wedge-debug/SKILL.md | 120 +++++++++++++++++++ .claude/skills/boot-wedge-debug/cdp-eval.mjs | 53 ++++++++ 2 files changed, 173 insertions(+) create mode 100644 .claude/skills/boot-wedge-debug/SKILL.md create mode 100644 .claude/skills/boot-wedge-debug/cdp-eval.mjs diff --git a/.claude/skills/boot-wedge-debug/SKILL.md b/.claude/skills/boot-wedge-debug/SKILL.md new file mode 100644 index 0000000000..c7f7d3d579 --- /dev/null +++ b/.claude/skills/boot-wedge-debug/SKILL.md @@ -0,0 +1,120 @@ +--- +name: boot-wedge-debug +description: Debug the intermittent Rocket.Chat Desktop webview boot wedge (workspace tab stuck on the loading throbber forever; "loading infinito"). Reads boot-watchdog forensic reports, runs live CDP autopsies on wedged webviews, and maps findings to the next fix. Trigger when a workspace hangs on the throbber, when boot-watchdog.jsonl has entries, when the console shows "Cannot find module '/app/utils/rocketchat.info'", or when the user mentions wedge/throbber/boot travado. +--- + +# Boot Wedge Debug + +Workflow for diagnosing the intermittent webview boot wedge and driving it to a +definitive fix. Fixes land iteratively on PR +[#3436](https://github.com/RocketChat/Rocket.Chat.Electron/pull/3436) +(`fix/injected-boot-recovery`). + +## The bug in one paragraph + +Sometimes a workspace webview never finishes booting: the Rocket.Chat throbber +spins forever. Its console shows `Cannot find module '/app/utils/rocketchat.info'` +(Meteor module registry incomplete) and often a ServiceWorker +`InvalidStateError`. A plain reload (⌘R = `webContents.reload()`) never cures +it; historically only a full app restart did. `src/injected.ts` now +auto-recovers via force reload with service worker + cache clearing (max 2 +attempts, sessionStorage-guarded), but at least one observed wedge survived +that — the broken state may live in the guest renderer process itself. The +planned escalation is recreating the `` (fresh renderer process). + +## Step 1 — Read the watchdog report first + +The dev-only boot watchdog (`src/servers/bootWatchdog.ts`, enabled when +`NODE_ENV=development` or `ROCKETCHAT_BOOT_WATCHDOG=true`) appends one JSON +line per incident to: + +``` +~/Library/Logs/Rocket.Chat/boot-watchdog.jsonl (macOS) +``` + +Summarize incidents: + +```bash +python3 -c " +import json +for l in open('$HOME/Library/Logs/Rocket.Chat/boot-watchdog.jsonl'): + r = json.loads(l) + print(r['ts'], r['reason'], r['serverUrl'], '| probe:', json.dumps(r.get('probe'))[:120]) +" +``` + +Then inspect the interesting incident in full: `probe`, `timeline`, +`serviceWorkers`, `console` (last 150 webview messages), `processMetrics`. + +### Reading the report + +| Field | Wedge signature | +|---|---| +| `reason` | `boot-deadline-exceeded` = 90s without the version signal after a main-frame navigation. `injected-recovery-exhausted` = both auto-recoveries failed. `render-process-gone` / `unresponsive` = process-level death. | +| `probe.requireType` | `"function"` + `probe.infoModule: "broken: …"` = module registry incomplete (the classic wedge). `"undefined"` = page never got to Meteor at all. | +| `probe.recoveryAttempts` | How many auto-recoveries ran this session (`null` = none). | +| `probe.serviceWorkerControlled` | Whether a SW controls the page. Compare with `serviceWorkers` (running SWs in the session). | +| `timeline` | Look for `injected-recovery-triggered` → `did-navigate` → whether `server-version-updated` ever follows. | + +A healthy boot never produces a report. Navigation failures with a visible +ErrorView (e.g. invalid TLS cert) are deliberately excluded. + +## Step 2 — Live autopsy (wedge currently on screen) + +Do NOT restart the app. If it was launched with +`npx electron . --remote-debugging-port=9222` (preferred during this +investigation), probe the wedged webview from outside: + +```bash +node .claude/skills/boot-wedge-debug/cdp-eval.mjs "JSON.stringify({ + readyState: document.readyState, + title: document.title, + requireType: typeof window.require, + infoModule: (()=>{try{return !!window.require('/app/utils/rocketchat.info').Info}catch(e){return 'broken: '+e.message}})(), + recoveryAttempts: sessionStorage.getItem('rocketChatDesktopBootRecoveryAttempts'), + swControlled: !!navigator.serviceWorker?.controller +})" +``` + +Also useful live: + +- List targets: `curl -s http://127.0.0.1:9222/json | python3 -m json.tool` +- SW registrations: eval `navigator.serviceWorker.getRegistrations().then(rs => JSON.stringify(rs.map(r => r.scope)))` +- Test the manual cure ladder (record which rung works — it localizes the broken layer): + 1. menu **View → Reload** (plain reload — historically never cures) + 2. menu **View → Reload Clearing Cache** (= what auto-recovery does) + 3. remove + re-add the workspace (recreates the `` → fresh renderer process) + 4. full app restart (historically always cures) + +## Step 3 — Map findings to action + +| Finding | Conclusion / next fix | +|---|---| +| Rung 2 cures | SW/cache layer — auto-recovery in `injected.ts` should have handled it; check why it didn't fire (counter exhausted? recovery log lines in report console?). | +| Only rung 3 or 4 cures | Broken state lives in the renderer process → implement/verify the escalation: recreate the webview after recovery attempts exhaust (see PR #3436 "next steps"). | +| `render-process-gone` reports | Different class of bug — check `details` in timeline (reason: crashed/oom/killed) and processMetrics. | +| Report shows recovery triggered then `server-version-updated` | Auto-recovery worked; no action, the system healed itself. | + +## Step 4 — Land the fix + +1. Reproduce/justify with report evidence; quote the relevant timeline lines in the PR. +2. Fix on branch `fix/injected-boot-recovery` (worktree + `../Rocket.Chat.Electron-worktrees/fix-injected-boot-recovery`). +3. Validate: `npx tsc --noEmit`, `yarn lint`, `yarn test`, `yarn build`, plus a + watchdog smoke test: run the app ≥150s with healthy servers → zero new + reports (guards against false positives). +4. Push and update PR #3436 with a comment describing what the report revealed + and what changed. Log the incident + fix in `.wolf/buglog.json`. + +## Known gotchas + +- `did-start-loading` fires for subframes/resources — never use it to reset a + boot cycle (caused the watchdog's first false positive; cycles key on + `did-navigate`). +- Injected-script `console.log`s do NOT reach the main process log; only + warn/error are forwarded. The watchdog's console buffer captures all levels. +- `WEBVIEW_SERVER_VERSION_UPDATED` is the boot-success signal, dispatched by + `setVersion` from `setServerInfo` (wired in PR #3436 — it was dead code + before). +- The re-render loop fix (PR #3435) is a separate branch; a `Maximum update + depth exceeded` storm in a build without it is that bug, not a regression. diff --git a/.claude/skills/boot-wedge-debug/cdp-eval.mjs b/.claude/skills/boot-wedge-debug/cdp-eval.mjs new file mode 100644 index 0000000000..55014fa593 --- /dev/null +++ b/.claude/skills/boot-wedge-debug/cdp-eval.mjs @@ -0,0 +1,53 @@ +// Evaluate a JS expression inside a running app webview via CDP. +// The app must be launched with --remote-debugging-port=9222. +// +// Usage: node cdp-eval.mjs +// Example: +// node cdp-eval.mjs open.rocket.chat "typeof window.require" +const [, , urlPart, expression] = process.argv; + +if (!urlPart || !expression) { + console.error('usage: node cdp-eval.mjs '); + process.exit(1); +} + +const targets = await fetch('http://127.0.0.1:9222/json').then((r) => + r.json() +); +const target = targets.find( + (t) => t.type === 'webview' && t.url.includes(urlPart) +); +if (!target) { + console.error( + 'target not found; available:', + targets.map((t) => `${t.type}:${t.url.slice(0, 60)}`) + ); + process.exit(1); +} + +const ws = new WebSocket(target.webSocketDebuggerUrl); +const result = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout')), 15000); + ws.onopen = () => { + ws.send( + JSON.stringify({ + id: 1, + method: 'Runtime.evaluate', + params: { expression, returnByValue: true, awaitPromise: true }, + }) + ); + }; + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.id === 1) { + clearTimeout(timer); + resolve(msg.result); + } + }; + ws.onerror = (e) => { + clearTimeout(timer); + reject(new Error(`ws error: ${e.message}`)); + }; +}); +ws.close(); +console.log(JSON.stringify(result, null, 2)); From 21699c8edc708e7b4e4b3bcf82c41bd48a7ff5eb Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 5 Aug 2026 14:15:49 -0300 Subject: [PATCH 4/4] fix: address CodeRabbit review on boot recovery and watchdog - injected.ts: abort automatic recovery when the sessionStorage counter cannot be persisted (an unpersisted counter would allow an unlimited reload loop), and trigger recovery when the server-info module resolves without a version instead of silently leaving the view wedged - main.ts: register the boot watchdog right after the store is created so WEBVIEW_SERVER_VERSION_UPDATED cannot be dispatched before the listener exists (would cause false boot-deadline-exceeded reports) - cdp-eval.mjs: reject ambiguous webview matches, add fetch timeout and response.ok check, settle the WebSocket promise exactly once and close the socket in a finally block - SKILL.md: document the watchdog log path for macOS, Windows and Linux --- .claude/skills/boot-wedge-debug/SKILL.md | 8 +- .claude/skills/boot-wedge-debug/cdp-eval.mjs | 104 +++++++++++++------ src/injected.ts | 11 +- src/main.ts | 5 +- 4 files changed, 93 insertions(+), 35 deletions(-) diff --git a/.claude/skills/boot-wedge-debug/SKILL.md b/.claude/skills/boot-wedge-debug/SKILL.md index c7f7d3d579..35dc0eb77c 100644 --- a/.claude/skills/boot-wedge-debug/SKILL.md +++ b/.claude/skills/boot-wedge-debug/SKILL.md @@ -29,9 +29,15 @@ The dev-only boot watchdog (`src/servers/bootWatchdog.ts`, enabled when line per incident to: ``` -~/Library/Logs/Rocket.Chat/boot-watchdog.jsonl (macOS) +macOS: ~/Library/Logs/Rocket.Chat/boot-watchdog.jsonl +Windows: %USERPROFILE%\AppData\Roaming\Rocket.Chat\logs\boot-watchdog.jsonl +Linux: ~/.config/Rocket.Chat/logs/boot-watchdog.jsonl ``` +(The directory is Electron's `app.getPath('logs')`; in dev the app name may be +`Electron` instead of `Rocket.Chat`. The watchdog prints the exact path at +startup: `[bootWatchdog] enabled — … appended to `.) + Summarize incidents: ```bash diff --git a/.claude/skills/boot-wedge-debug/cdp-eval.mjs b/.claude/skills/boot-wedge-debug/cdp-eval.mjs index 55014fa593..b448faa53e 100644 --- a/.claude/skills/boot-wedge-debug/cdp-eval.mjs +++ b/.claude/skills/boot-wedge-debug/cdp-eval.mjs @@ -11,43 +11,85 @@ if (!urlPart || !expression) { process.exit(1); } -const targets = await fetch('http://127.0.0.1:9222/json').then((r) => - r.json() -); -const target = targets.find( +let targets; +try { + const response = await fetch('http://127.0.0.1:9222/json', { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + console.error(`CDP endpoint returned ${response.status}`); + process.exit(1); + } + targets = await response.json(); +} catch (error) { + console.error( + 'CDP endpoint unreachable (is the app running with --remote-debugging-port=9222?):', + error.message + ); + process.exit(1); +} + +const matches = targets.filter( (t) => t.type === 'webview' && t.url.includes(urlPart) ); -if (!target) { +if (matches.length !== 1) { console.error( - 'target not found; available:', - targets.map((t) => `${t.type}:${t.url.slice(0, 60)}`) + matches.length === 0 + ? 'target not found; available:' + : `ambiguous: ${matches.length} webviews match "${urlPart}" — be more specific:`, + (matches.length === 0 ? targets : matches).map( + (t) => `${t.type}:${t.url.slice(0, 60)}` + ) ); process.exit(1); } +const [target] = matches; const ws = new WebSocket(target.webSocketDebuggerUrl); -const result = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('timeout')), 15000); - ws.onopen = () => { - ws.send( - JSON.stringify({ - id: 1, - method: 'Runtime.evaluate', - params: { expression, returnByValue: true, awaitPromise: true }, - }) - ); - }; - ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (msg.id === 1) { +try { + const result = await new Promise((resolve, reject) => { + let settled = false; + const settle = (fn, value) => { + if (settled) return; + settled = true; clearTimeout(timer); - resolve(msg.result); - } - }; - ws.onerror = (e) => { - clearTimeout(timer); - reject(new Error(`ws error: ${e.message}`)); - }; -}); -ws.close(); -console.log(JSON.stringify(result, null, 2)); + fn(value); + }; + const timer = setTimeout( + () => settle(reject, new Error('timeout')), + 15000 + ); + ws.onopen = () => { + try { + ws.send( + JSON.stringify({ + id: 1, + method: 'Runtime.evaluate', + params: { expression, returnByValue: true, awaitPromise: true }, + }) + ); + } catch (error) { + settle(reject, error); + } + }; + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.id === 1) { + settle(resolve, msg.result); + } + } catch (error) { + settle(reject, error); + } + }; + ws.onerror = (e) => settle(reject, new Error(`ws error: ${e.message}`)); + ws.onclose = () => + settle(reject, new Error('ws closed before a result arrived')); + }); + console.log(JSON.stringify(result, null, 2)); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} finally { + ws.close(); +} diff --git a/src/injected.ts b/src/injected.ts index 64ff2da1d8..258709588b 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -62,7 +62,12 @@ const attemptBootRecovery = (reason: string): void => { String(attempts + 1) ); } catch (error) { - // sessionStorage unavailable; still attempt a single recovery + // Without a persisted counter every reload would read zero attempts and + // recover again, forever — safer to not recover at all. + console.error( + `[Rocket.Chat Desktop] ${reason}. Boot recovery is disabled because sessionStorage is unavailable.` + ); + return; } console.error( @@ -143,7 +148,9 @@ const start = async () => { } if (!serverInfo.version) { - console.log('[Rocket.Chat Desktop] serverInfo.version is not defined'); + attemptBootRecovery( + "Required '/app/utils/rocketchat.info' returned no server version" + ); return; } diff --git a/src/main.ts b/src/main.ts index 6fe47f8a28..29823b64ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -93,6 +93,10 @@ const start = async (): Promise => { createMainReduxStore(); + // Must be listening before any server view can boot and dispatch + // WEBVIEW_SERVER_VERSION_UPDATED — listen() does not replay actions. + setupBootWatchdog(); + setupOutlookLogger(); setupDebugLoggingWatch(); @@ -160,7 +164,6 @@ const start = async (): Promise => { handleDesktopCapturerGetSources(); handleClearCacheDialog(); handleUserLoggedOutDataClearing(); - setupBootWatchdog(); startDocumentViewerHandler(); startBrowserHandler(); checkSupportedVersionServers();