From 8b45b9f611adb65589a8216549e21ab75eb05958 Mon Sep 17 00:00:00 2001 From: Bill Gardner Date: Fri, 10 Jul 2026 11:07:09 -0400 Subject: [PATCH 1/4] [MM-69685] Fix /call logs from expanded-view popout (v1 backport) When /call logs runs in the popout, delegate flush+read to the opener's realm via window.callsClientFlushAndGetLogs. On web, sessionStorage is per-window so the popout's local read missed the opener's accumulated logs; the popout's in-memory buffer also forwards every line to the opener so it holds almost nothing locally. Also wires window.addEventListener('error'/'unhandledrejection') into the calls log buffer so uncaught exceptions appear in /call logs uploads, and bounds the in-memory buffer with a 50 KB auto-flush threshold. --- webapp/src/index.tsx | 10 +++++ webapp/src/log.ts | 73 ++++++++++++++++++++++++++++++++++- webapp/src/slash_commands.tsx | 22 +++++++++-- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 6f7615e72..72004716f 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -1210,6 +1210,16 @@ declare global { registerPlugin(id: string, plugin: Plugin): void, callsClient?: CallsClient, + + // Appends a pre-formatted log line to this realm's in-memory client-log + // buffer. Exposed so the expanded-view popout can write through to its + // opener's buffer (single source of truth). See src/log.ts. + callsClientLogAppend?: (line: string) => void, + + // Flushes this realm's in-memory buffer to storage and returns the full + // accumulated log string. Exposed so a popout can delegate /call logs + // flush+read to the opener's realm in one call. See src/log.ts. + callsClientFlushAndGetLogs?: () => string, webkitAudioContext: AudioContext, basename: string, diff --git a/webapp/src/log.ts b/webapp/src/log.ts index d8851e006..d15a2ef77 100644 --- a/webapp/src/log.ts +++ b/webapp/src/log.ts @@ -13,6 +13,18 @@ let clientLogs = ''; const maxArgLength = 256; +// Flush the in-memory buffer to storage once it exceeds this size. Keeps +// memory bounded between calls and during plugin-inactive periods when the +// window error/unhandledrejection listeners are still writing to the buffer. +// String .length is O(1) in JS so this check is cheap on every write. +const maxInMemoryLogSize = 50 * 1024; + +function maybeFlush() { + if (clientLogs.length > maxInMemoryLogSize) { + flushLogsToAccumulated(); + } +} + function formatArg(a: unknown): string { if (a instanceof Error) { return a.message; @@ -28,8 +40,59 @@ function formatArg(a: unknown): string { return String(a); } +// Appends a fully-formatted log line to this realm's in-memory buffer. Exposed +// on `window` so the expanded-view popout can write through to its opener's +// buffer rather than persisting separately. +function appendLogLine(line: string) { + clientLogs += line; + maybeFlush(); +} + function appendClientLog(level: string, ...args: unknown[]) { - clientLogs += `${level} [${new Date().toISOString()}] ${args.map(formatArg).join(' ')}\n`; + // Serialize in the originating realm: Error/object args belong to this + // window's realm and would fail instanceof checks if passed to the opener. + const line = `${level} [${new Date().toISOString()}] ${args.map(formatArg).join(' ')}\n`; + + // In the expanded-view popout, route the line to the opener's buffer so + // popout-realm logs ride the main window's existing flush machinery (single + // source of truth, no cross-window storage read-modify-write race). + try { + const opener = window.opener as Window | null; + if (opener && opener !== window && typeof opener.callsClientLogAppend === 'function') { + opener.callsClientLogAppend(line); + return; + } + } catch { + // Cross-origin opener: fall through to this realm's local buffer. + } + + clientLogs += line; + maybeFlush(); +} + +// Expose this realm's appender and flush+getter so an expanded-view popout can +// write logs through to (and read them back from) the opener's realm. +if (typeof window !== 'undefined') { + window.callsClientLogAppend = appendLogLine; + window.callsClientFlushAndGetLogs = flushAndGetLogs; + + // Wire uncaught JS errors and unhandled promise rejections into the client- + // log buffer. Without this, exceptions that crash a handler go only to + // console.error and never appear in /call logs uploads. + window.addEventListener('error', (event: ErrorEvent) => { + const {message, filename, lineno, colno, error} = event; + const errStr = error instanceof Error ? + (error.stack || `${error.name}: ${error.message}`) : + String(error || message); + appendClientLog('error', `[uncaught] ${errStr} (${filename}:${lineno}:${colno})`); + }); + + window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { + const reason = event.reason instanceof Error ? + (event.reason.stack || `${event.reason.name}: ${event.reason.message}`) : + String(event.reason); + appendClientLog('error', `[unhandledrejection] ${reason}`); + }); } export function flushLogsToAccumulated(stats?: CallsClientStats | null) { @@ -74,6 +137,14 @@ export function getClientLogs() { return getPersistentStorage().getItem(STORAGE_CALLS_CLIENT_LOGS_KEY) || ''; } +// Flushes this realm's in-memory buffer to storage and returns the full +// accumulated log string. Exposed on `window` so a popout can delegate the +// entire flush+read to its opener's realm in one call. +export function flushAndGetLogs(): string { + flushLogsToAccumulated(); + return getClientLogs(); +} + export function logErr(...args: unknown[]) { console.error(`${pluginId}:`, ...args); try { diff --git a/webapp/src/slash_commands.tsx b/webapp/src/slash_commands.tsx index f1463e617..eb522ab64 100644 --- a/webapp/src/slash_commands.tsx +++ b/webapp/src/slash_commands.tsx @@ -24,7 +24,7 @@ import { import RestClient from 'src/rest_client'; import {modals} from 'src/webapp_globals'; -import {flushLogsToAccumulated, getClientLogs, logDebug} from './log'; +import {flushAndGetLogs, logDebug} from './log'; import { areGroupCallsAllowed, channelHasCall, @@ -177,8 +177,24 @@ export default async function slashCommandsHandler(store: Store, joinCall: joinC return {message: `/call stats ${btoa(data)}`, args}; } case 'logs': { - flushLogsToAccumulated(); - const allLogs = getClientLogs(); + // When running in the expanded-view popout, delegate flush+read to the + // opener's realm. The popout's own in-memory buffer is ~empty (every + // appended line is forwarded to opener.callsClientLogAppend), and on + // web sessionStorage is per-window so a local read would miss the + // opener's accumulated logs entirely. + let allLogs: string; + try { + const opener = window.opener as Window | null; + if (opener && opener !== window && typeof opener.callsClientFlushAndGetLogs === 'function') { + allLogs = opener.callsClientFlushAndGetLogs(); + } else { + allLogs = flushAndGetLogs(); + } + } catch { + // Cross-origin opener (SecurityError) or missing function — fall + // back to the local realm. + allLogs = flushAndGetLogs(); + } if (!allLogs || allLogs.trim().length === 0) { return {error: {message: 'No call logs available'}}; From f168314c0c16915587c3c777ccd451eca5313612 Mon Sep 17 00:00:00 2001 From: Bill Gardner Date: Fri, 10 Jul 2026 12:20:02 -0400 Subject: [PATCH 2/4] [MM-69685] Add unit tests for log.ts changes Covers the three features added by this backport: - flushAndGetLogs (including window.callsClientFlushAndGetLogs exposure) - in-memory auto-flush at 50 KB threshold - window error/unhandledrejection listener capture Also ports the popout write-through suite from v2 (opener forwarding in appendClientLog) since v1 now has the same mechanism. --- webapp/src/log.test.ts | 139 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/webapp/src/log.test.ts b/webapp/src/log.test.ts index c64f05a7a..966762d91 100644 --- a/webapp/src/log.test.ts +++ b/webapp/src/log.test.ts @@ -4,7 +4,7 @@ import {MAX_ACCUMULATED_LOG_SIZE, STORAGE_CALLS_CLIENT_LOGS_KEY} from 'src/constants'; import type {CallsClientStats} from 'src/types/types'; -import {flushLogsToAccumulated, getClientLogs, logDebug, logErr, logInfo, logWarn} from './log'; +import {flushAndGetLogs, flushLogsToAccumulated, getClientLogs, logDebug, logErr, logInfo, logWarn} from './log'; // Mock the manifest jest.mock('./manifest', () => ({ @@ -301,4 +301,141 @@ describe('log', () => { expect(logs).toBe('test logs'); }); }); + + describe('flushAndGetLogs', () => { + test('flushes in-memory buffer and returns accumulated logs', () => { + logInfo('hello from flushAndGetLogs'); + + const result = flushAndGetLogs(); + + expect(result).toContain('hello from flushAndGetLogs'); + + // In-memory is now clear; a second call returns the same storage content. + expect(flushAndGetLogs()).toBe(result); + }); + + test('returns empty string when no logs have been written', () => { + expect(flushAndGetLogs()).toBe(''); + }); + + test('is exposed as window.callsClientFlushAndGetLogs', () => { + expect(typeof window.callsClientFlushAndGetLogs).toBe('function'); + + logInfo('via window'); + const result = window.callsClientFlushAndGetLogs!(); + expect(result).toContain('via window'); + }); + }); + + describe('in-memory auto-flush at 50 KB', () => { + test('flushes to storage without an explicit call when the buffer exceeds 50 KB', () => { + // A log line is the message plus a ~36-char timestamp prefix. + // Logging 51 KB of data pushes the buffer over the threshold. + const bigMessage = 'x'.repeat(51 * 1024); + logDebug(bigMessage); + + // maybeFlush() ran automatically — storage should already contain the data. + expect(getClientLogs()).toContain(bigMessage.slice(0, 100)); + }); + + test('small writes do not flush prematurely', () => { + logDebug('small'); + + // Buffer is well under 50 KB — storage should still be empty. + expect(getClientLogs()).toBe(''); + }); + }); + + describe('popout write-through', () => { + const originalOpener = Object.getOwnPropertyDescriptor(window, 'opener'); + + const setOpener = (opener: unknown) => { + Object.defineProperty(window, 'opener', {value: opener, configurable: true, writable: true}); + }; + + afterEach(() => { + if (originalOpener) { + Object.defineProperty(window, 'opener', originalOpener); + } else { + setOpener(null); + } + }); + + test('routes logs to opener buffer when running as a popout', () => { + const append = jest.fn(); + setOpener({callsClientLogAppend: append}); + + logInfo('popout gesture'); + flushLogsToAccumulated(); + + // The formatted line went to the opener, not this realm's buffer. + expect(append).toHaveBeenCalledTimes(1); + expect(append.mock.calls[0][0]).toContain('popout gesture'); + expect(append.mock.calls[0][0]).toContain('info'); + expect(getClientLogs()).not.toContain('popout gesture'); + }); + + test('falls back to local buffer when opener has no appender', () => { + setOpener({}); + + logInfo('no appender'); + flushLogsToAccumulated(); + + expect(getClientLogs()).toContain('no appender'); + }); + + test('falls back to local buffer when opener access throws (cross-origin)', () => { + setOpener(new Proxy({}, { + get() { + throw new Error('Blocked a frame with origin'); + }, + })); + + logInfo('cross origin opener'); + flushLogsToAccumulated(); + + expect(getClientLogs()).toContain('cross origin opener'); + }); + + test('does not route to itself when there is no opener', () => { + setOpener(null); + + logInfo('main window'); + flushLogsToAccumulated(); + + expect(getClientLogs()).toContain('main window'); + }); + }); + + describe('window error listeners', () => { + test('uncaught error event is captured in the log buffer', () => { + window.dispatchEvent(new ErrorEvent('error', { + message: 'test uncaught error', + filename: 'app.js', + lineno: 10, + colno: 5, + error: new Error('test uncaught error'), + })); + flushLogsToAccumulated(); + + const logs = getClientLogs(); + expect(logs).toContain('[uncaught]'); + expect(logs).toContain('test uncaught error'); + expect(logs).toContain('app.js:10:5'); + }); + + test('unhandledrejection event is captured in the log buffer', () => { + // PromiseRejectionEvent is not available in JSDOM, so synthesize an + // event with the same shape our handler reads (event.reason). + const reason = new Error('rejected promise'); + const event = new Event('unhandledrejection') as PromiseRejectionEvent; + Object.defineProperty(event, 'reason', {value: reason}); + window.dispatchEvent(event); + flushLogsToAccumulated(); + + const logs = getClientLogs(); + expect(logs).toContain('[unhandledrejection]'); + expect(logs).toContain('rejected promise'); + }); + }); }); From 446574570b7f94e0e4b3aa5a814c1a7f89b87353 Mon Sep 17 00:00:00 2001 From: Bill Gardner Date: Fri, 10 Jul 2026 12:52:19 -0400 Subject: [PATCH 3/4] [MM-69685] Address CodeRabbit review suggestions - Wrap flushLogsToAccumulated() in try/catch inside maybeFlush() so storage quota/security errors don't interrupt callers; on failure, retain a bounded tail of the in-memory buffer instead. - Use formatArg() for non-Error unhandledrejection reasons so objects are serialized as JSON instead of reducing to [object Object]. - Add test for the non-Error rejection path. --- webapp/src/log.test.ts | 12 ++++++++++++ webapp/src/log.ts | 9 +++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/webapp/src/log.test.ts b/webapp/src/log.test.ts index 966762d91..b53b8866f 100644 --- a/webapp/src/log.test.ts +++ b/webapp/src/log.test.ts @@ -437,5 +437,17 @@ describe('log', () => { expect(logs).toContain('[unhandledrejection]'); expect(logs).toContain('rejected promise'); }); + + test('unhandledrejection with non-Error reason uses formatArg (not [object Object])', () => { + const event = new Event('unhandledrejection') as PromiseRejectionEvent; + Object.defineProperty(event, 'reason', {value: {code: 42, msg: 'oops'}}); + window.dispatchEvent(event); + flushLogsToAccumulated(); + + const logs = getClientLogs(); + expect(logs).toContain('[unhandledrejection]'); + expect(logs).toContain('"code":42'); + expect(logs).not.toContain('[object Object]'); + }); }); }); diff --git a/webapp/src/log.ts b/webapp/src/log.ts index d15a2ef77..abc2827f3 100644 --- a/webapp/src/log.ts +++ b/webapp/src/log.ts @@ -21,7 +21,12 @@ const maxInMemoryLogSize = 50 * 1024; function maybeFlush() { if (clientLogs.length > maxInMemoryLogSize) { - flushLogsToAccumulated(); + try { + flushLogsToAccumulated(); + } catch { + // Storage quota or security error — keep only the most recent portion in memory. + clientLogs = clientLogs.slice(-maxInMemoryLogSize); + } } } @@ -90,7 +95,7 @@ if (typeof window !== 'undefined') { window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { const reason = event.reason instanceof Error ? (event.reason.stack || `${event.reason.name}: ${event.reason.message}`) : - String(event.reason); + formatArg(event.reason); appendClientLog('error', `[unhandledrejection] ${reason}`); }); } From 3d9b1ac38a2940e91e73aad3a06af12b81127608 Mon Sep 17 00:00:00 2001 From: Bill Gardner Date: Fri, 10 Jul 2026 13:38:15 -0400 Subject: [PATCH 4/4] [MM-69685] Fix TS errors in standalone build Move Window interface augmentation (callsClientLogAppend, callsClientFlushAndGetLogs) from index.tsx into log.ts via declare global. The standalone tsconfig doesn't include index.tsx, so the properties were unknown to the compiler when log.ts was built as part of the standalone bundle. --- webapp/src/index.tsx | 9 --------- webapp/src/log.ts | 7 +++++++ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 72004716f..7ca7d9056 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -1211,15 +1211,6 @@ declare global { callsClient?: CallsClient, - // Appends a pre-formatted log line to this realm's in-memory client-log - // buffer. Exposed so the expanded-view popout can write through to its - // opener's buffer (single source of truth). See src/log.ts. - callsClientLogAppend?: (line: string) => void, - - // Flushes this realm's in-memory buffer to storage and returns the full - // accumulated log string. Exposed so a popout can delegate /call logs - // flush+read to the opener's realm in one call. See src/log.ts. - callsClientFlushAndGetLogs?: () => string, webkitAudioContext: AudioContext, basename: string, diff --git a/webapp/src/log.ts b/webapp/src/log.ts index abc2827f3..67efd2b13 100644 --- a/webapp/src/log.ts +++ b/webapp/src/log.ts @@ -9,6 +9,13 @@ import {getPersistentStorage} from 'src/utils'; import {pluginId} from './manifest'; +declare global { + interface Window { + callsClientLogAppend?: (line: string) => void; + callsClientFlushAndGetLogs?: () => string; + } +} + let clientLogs = ''; const maxArgLength = 256;