From 60e536486629098b5644d9c219a58cbc56ae7939 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:15:54 +1100 Subject: [PATCH 01/32] feat(plot): make viewers session-aware and restorable after reload - Bind standard plot viewers to their originating R sessions - Add JGD session selection and isolate resize requests by device - Restore httpgd connections and request standard plots from live sessions - Persist JGD frames and selection for reload restoration - Add session isolation and restoration tests --- html/httpgd/smallPlot.ejs | 4 +- src/extension.ts | 2 +- src/plotViewer/httpgdTypes.d.ts | 4 +- src/plotViewer/httpgdViewer.ts | 117 +++++- src/plotViewer/index.ts | 106 ++++- src/plotViewer/jgdPlotHistory.ts | 124 +++++- src/plotViewer/jgdSocketServer.ts | 26 +- src/plotViewer/jgdViewer.ts | 80 +++- src/plotViewer/standardViewer.ts | 54 ++- src/plotViewer/types.ts | 17 +- src/plotViewer/webview/smallPlot.ejs | 4 +- src/session.ts | 27 +- src/test/common/mockvscode.ts | 6 +- src/test/suite/jgdPlotHistory.test.ts | 65 +++ src/test/suite/jgdSocketServer.test.ts | 19 + src/test/suite/plotCommands.test.ts | 10 +- src/test/suite/plotRestoration.test.ts | 374 ++++++++++++++++++ src/test/suite/session.test.ts | 2 +- .../suite/sessionConnectionLifecycle.test.ts | 37 +- src/test/suite/standardPlotViewer.test.ts | 24 ++ src/viewerSession.ts | 15 + 21 files changed, 1045 insertions(+), 72 deletions(-) create mode 100644 src/test/suite/plotRestoration.test.ts diff --git a/html/httpgd/smallPlot.ejs b/html/httpgd/smallPlot.ejs index 7d8337661..378646ab8 100644 --- a/html/httpgd/smallPlot.ejs +++ b/html/httpgd/smallPlot.ejs @@ -1,5 +1,5 @@ diff --git a/src/extension.ts b/src/extension.ts index d584bd5a2..38412c0dd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -381,6 +381,6 @@ export function ensureWorkspaceViewer(): void { export async function deactivate(): Promise { await shutdownHtmlWidgetViewers(true); - (globalPlotManager as plotViewer.CommonPlotManager)?.dispose(); + await (globalPlotManager as plotViewer.CommonPlotManager)?.dispose(); await session.shutdownSessionWatcher(); } diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts index db601ed0e..9d17e028f 100644 --- a/src/plotViewer/httpgdTypes.d.ts +++ b/src/plotViewer/httpgdTypes.d.ts @@ -1,6 +1,7 @@ import { HttpgdClient, HttpgdPlotId } from './httpgdClient'; import * as vscode from 'vscode'; -import { HttpgdManager } from '.'; +import { HttpgdManager } from './httpgdViewer'; +import type { ViewerSessionSource } from '../viewerSession'; import { PreviewPlotLayout } from './webviewMessages'; export type MaybePromise = T | Promise; @@ -30,6 +31,7 @@ export interface HttpgdViewerOptions { previewPlotLayout?: PreviewPlotLayout; resizeTimeoutLength?: number; refreshTimeoutLength?: number; + source?: ViewerSessionSource; } // Roughly combines the functionality of HttpgdNavigator and HttpgdViewer diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 332696246..6a6ca555e 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -4,6 +4,8 @@ import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; import * as path from 'path'; import * as fs from 'fs'; import ejs from 'ejs'; +import { randomUUID } from 'crypto'; +import { formatSessionLabel, isViewerSessionSource, ViewerSessionSource } from '../viewerSession'; import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util'; import { extensionContext } from '../extension'; @@ -19,12 +21,91 @@ import { PreviewPlotLayoutMessage, ToggleFullWindowMessage, } from './webviewMessages'; -import { PlotViewer } from './types'; +import { PlotViewer, plotPanelPath } from './types'; + +interface HttpgdConnection { + url: string; + source?: ViewerSessionSource; +} + +function isHttpgdConnection(value: unknown): value is HttpgdConnection { + if (!value || typeof value !== 'object') { + return false; + } + const connection = value as Partial; + try { + return ( + typeof connection.url === 'string' && + ['http:', 'https:'].includes(new URL(connection.url).protocol) && + (connection.source === undefined || isViewerSessionSource(connection.source)) + ); + } catch { + return false; + } +} export class HttpgdManager { viewers: HttpgdViewer[] = []; viewerOptions: HttpgdViewerOptions; recentlyActiveViewers: HttpgdViewer[] = []; + private savedConnections: Promise = Promise.resolve([]); + private restoring: Promise | undefined; + private secretKey: string | undefined; + private disposed = false; + + dispose(): void { + this.disposed = true; + for (const viewer of this.viewers) { + viewer.dispose(); + } + } + + initialize(): void { + this.secretKey = extensionContext.workspaceState.get('r.plot.httpgdConnectionsKey'); + if (this.secretKey) { + // URLs contain device tokens, so keep them in SecretStorage. + this.savedConnections = Promise.resolve(extensionContext.secrets.get(this.secretKey)) + .then((value) => { + const connections: unknown = value ? JSON.parse(value) : []; + return Array.isArray(connections) ? connections.filter(isHttpgdConnection) : []; + }) + .catch(() => []); + } + } + + restoreConnections(): Promise { + this.restoring ??= this.savedConnections.then(async (connections) => { + for (const connection of connections) { + const origin = new URL(connection.url).origin; + // A session may already have attached with newer credentials. + if (!this.viewers.some((viewer) => viewer.origin === origin)) { + await this.showViewer(connection.url, connection.source); + } + } + }); + return this.restoring; + } + + async saveConnections(): Promise { + const connections = new Map( + (await this.savedConnections).map((connection) => [ + new URL(connection.url).origin, + connection, + ]), + ); + for (const viewer of this.viewers) { + connections.set(viewer.origin, viewer.connection); + } + if (!connections.size) { + return; + } + this.secretKey ??= `r.plot.httpgd.${randomUUID()}`; + await extensionContext.secrets.store( + this.secretKey, + JSON.stringify([...connections.values()]), + ); + await extensionContext.workspaceState.update('r.plot.httpgdConnectionsKey', this.secretKey); + } constructor() { const htmlRoot = extensionContext.asAbsolutePath('dist/webviews/httpgd'); @@ -35,8 +116,11 @@ export class HttpgdManager { }; } - public async showViewer(urlString: string): Promise { + public async showViewer(urlString: string, source?: ViewerSessionSource): Promise { await Promise.resolve(); + if (this.disposed) { + return; + } const url = new URL(urlString); const token = url.searchParams.get('token') || undefined; const ind = this.viewers.findIndex((viewer) => viewer.origin === url.origin); @@ -66,6 +150,7 @@ export class HttpgdManager { this.viewerOptions.resizeTimeoutLength = conf.get('plot.timing.resizeInterval', 100); this.viewerOptions.fullWindow = conf.get('plot.defaults.fullWindowMode', false); this.viewerOptions.token = token; + this.viewerOptions.source = source; const viewer = new HttpgdViewer(url.origin, this.viewerOptions); this.viewers.unshift(viewer); } @@ -128,6 +213,15 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { webviewPanel?: vscode.WebviewPanel; readonly api: HttpgdClient; private disposed = false; + private readonly source?: ViewerSessionSource; + + get connection(): HttpgdConnection { + const url = new URL(this.origin); + if (this.token) { + url.searchParams.set('token', this.token); + } + return { url: url.toString(), source: this.source }; + } plots: HttpgdPlot[] = []; activePlot?: HttpgdPlotId; hiddenPlots: HttpgdPlotId[] = []; @@ -177,6 +271,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.origin = url.origin; this.id = this.host; this.token = options.token; + this.source = options.source; this.parent = options.parent; this.api = new HttpgdClient(this.origin, this.token); @@ -428,13 +523,11 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } public getPanelPath(): string | undefined { - if (!this.webviewPanel) { - return undefined; - } - const dummyUri = this.webviewPanel.webview.asWebviewUri(vscode.Uri.file('')); - const m = /^[^.]*/.exec(dummyUri.authority); - const webviewId = m?.[0] || ''; - return `webview-panel/webview-${webviewId}`; + return plotPanelPath(this.webviewPanel); + } + + public get isActive(): boolean { + return this.webviewPanel?.active ?? false; } protected getIndex(id: HttpgdPlotId): number { @@ -631,7 +724,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { plots: this.plots, largePlot: this.plots[this.activeIndex], activePlot: this.activePlot, - host: this.host, + host: this.id, asLocalPath: asLocalPath, asWebViewPath: asWebViewPath, makeCommandUri: makeWebviewCommandUriString, @@ -642,7 +735,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { protected makeNewWebview(showOptions?: ShowOptions): vscode.WebviewPanel { const webviewPanel = vscode.window.createWebviewPanel( 'RPlot', - 'R Plot', + this.source + ? `R Plot (${formatSessionLabel(this.source.rVer, this.source.pid)})` + : 'R Plot', showOptions || this.showOptions, this.webviewOptions, ); diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index 89ccc4f87..eeef86da4 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -7,6 +7,9 @@ import { JgdManager } from './jgdViewer'; import { extensionContext } from '../extension'; import { config } from '../util'; import { getMigratedSetting } from '../configuration'; +import { activeSession, resolvePlotSession, Session } from '../session'; +import { sessionProcessIdentity } from '../sessionIdentity'; +import { isViewerSessionSource, ViewerSessionSource } from '../viewerSession'; export type PlotBackend = 'auto' | 'standard' | 'httpgd' | 'jgd' | 'native'; @@ -51,6 +54,9 @@ export class CommonPlotManager implements PlotManager { public httpgdManager: HttpgdManager; public standardPlotViewer: StandardPlotViewer; public jgdManager: JgdManager; + private standardViewers = new Map(); + private restoredStandardSessions: ViewerSessionSource[] = []; + private disposed = false; constructor() { this.httpgdManager = new HttpgdManager(); @@ -65,10 +71,15 @@ export class CommonPlotManager implements PlotManager { viewers.push(jgdViewer); } viewers.push(this.standardPlotViewer); + viewers.push(...this.standardViewers.values()); return viewers; } get activeViewer(): PlotViewer | undefined { + const focused = this.viewers.find((viewer) => viewer.isActive); + if (focused) { + return focused; + } if (jgdEnabled()) { return ( this.jgdManager.getViewer() || @@ -81,6 +92,12 @@ export class CommonPlotManager implements PlotManager { public initialize(): void { this.jgdManager.initialize(extensionContext.extensionUri); + this.jgdManager.history.restore(extensionContext.workspaceState.get('r.plot.jgdState')); + const sources = extensionContext.workspaceState.get('r.plot.standardSessions'); + this.restoredStandardSessions = Array.isArray(sources) + ? sources.filter(isViewerSessionSource) + : []; + this.httpgdManager.initialize(); for (const cmd of commands) { const fullCommand = `r.plot.${cmd}`; @@ -88,7 +105,7 @@ export class CommonPlotManager implements PlotManager { vscode.commands.registerCommand( fullCommand, (hostOrWebviewUri?: string | vscode.Uri, ...args: unknown[]) => { - void this.handleCommand(cmd, hostOrWebviewUri, ...args); + return this.handleCommand(cmd, hostOrWebviewUri, ...args); }, ), ); @@ -125,20 +142,73 @@ export class CommonPlotManager implements PlotManager { } } - public async showStandardPlot(): Promise { - await this.standardPlotViewer.update(); + public async showStandardPlot(target = activeSession): Promise { + if (!target) { + return; + } + const key = sessionProcessIdentity(target); + let viewer = this.standardViewers.get(key); + if (!viewer) { + viewer = new StandardPlotViewer(target); + this.standardViewers.set(key, viewer); + } else { + viewer.bindSession(target); + } + await viewer.update(); } - public async showHttpgdPlot(url: string): Promise { - await this.httpgdManager.showViewer(url); + public async showHttpgdPlot(url: string, target?: Session): Promise { + const source = target + ? { + sessionId: target.sessionId, + host: target.host, + pid: target.pid, + rVer: target.rVer, + processExited: target.processExited, + } + : undefined; + await this.httpgdManager.showViewer(url, source); } public getJgdEnvVars(): Record { return this.jgdManager.getEnvVars(); } - public dispose(): void { + public async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + const standardSources = new Map( + this.restoredStandardSessions.map((source) => [sessionProcessIdentity(source), source]), + ); + for (const viewer of this.standardViewers.values()) { + if (viewer.sessionSource) { + standardSources.set( + sessionProcessIdentity(viewer.sessionSource), + viewer.sessionSource, + ); + } + } + const saved = Promise.all([ + extensionContext.workspaceState.update( + 'r.plot.jgdState', + this.jgdManager.history.snapshot(), + ), + extensionContext.workspaceState.update('r.plot.standardSessions', [ + ...standardSources.values(), + ]), + this.httpgdManager.saveConnections(), + ]); this.jgdManager.stop(); + for (const viewer of this.standardViewers.values()) { + viewer.dispose(); + } + this.standardPlotViewer.dispose(); + this.httpgdManager.dispose(); + await saved.catch((error) => + console.warn('[Plot Viewer] Could not save reload state', error), + ); } private async handleCommand( @@ -147,6 +217,24 @@ export class CommonPlotManager implements PlotManager { ...args: unknown[] ): Promise { if (command === 'showViewers') { + await this.httpgdManager.restoreConnections(); + if (this.disposed) { + return; + } + let unavailable = false; + for (const source of this.restoredStandardSessions) { + const target = resolvePlotSession(source); + if (target) { + await this.showStandardPlot(target); + } else if (!this.standardViewers.has(sessionProcessIdentity(source))) { + unavailable = true; + } + } + if (unavailable) { + void vscode.window.showInformationMessage( + 'Attach the original R session to restore its standard plot viewer.', + ); + } for (const viewer of this.viewers) { viewer.show(true); } @@ -162,12 +250,10 @@ export class CommonPlotManager implements PlotManager { let viewer: PlotViewer | undefined; if (typeof hostOrWebviewUri === 'string') { viewer = this.httpgdManager.viewers.find( - (v: HttpgdViewer) => v.host === hostOrWebviewUri, + (v: HttpgdViewer) => v.id === hostOrWebviewUri || v.host === hostOrWebviewUri, ); } else if (hostOrWebviewUri instanceof vscode.Uri) { - viewer = this.httpgdManager.viewers.find( - (v: HttpgdViewer) => v.getPanelPath() === hostOrWebviewUri.path, - ); + viewer = this.viewers.find((v) => v.getPanelPath?.() === hostOrWebviewUri.path); } // Fallback to active viewer diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts index a7679d470..1b6e242f5 100644 --- a/src/plotViewer/jgdPlotHistory.ts +++ b/src/plotViewer/jgdPlotHistory.ts @@ -18,16 +18,106 @@ interface SessionHistory { plots: PlotFrame[]; currentIndex: number; latestDeleted: boolean; + label?: string; +} + +export interface PlotHistoryState { + activeSessionId: string; + selectedSessionId?: string; + sessions: Array; } export class PlotHistory { private sessions: Map = new Map(); private activeSessionId: string = ''; + private selectedSessionId: string | undefined; private maxPlots: number; private emitter = new EventEmitter(); constructor(maxPlots: number = 50) { - this.maxPlots = maxPlots; + this.maxPlots = Math.max(1, Math.floor(maxPlots) || 50); + } + + sessionIds(): string[] { + return [...this.sessions.keys()]; + } + + sessionLabel(sessionId: string): string | undefined { + return this.sessions.get(sessionId)?.label; + } + + setSessionLabel(sessionId: string, label: string): void { + const history = this.sessions.get(sessionId); + if (history && history.label !== label) { + history.label = label.slice(0, 256); + this.emitter.emit('change'); + } + } + + snapshot(): PlotHistoryState { + return { + activeSessionId: this.activeSessionId, + selectedSessionId: this.selectedSessionId, + sessions: [...this.sessions].map(([id, history]) => ({ id, ...history })), + }; + } + + restore(value: unknown): void { + if (!value || typeof value !== 'object') { + return; + } + const state = value as Partial; + if (!Array.isArray(state.sessions)) { + return; + } + for (const entry of state.sessions) { + if ( + !entry || + typeof entry.id !== 'string' || + !entry.id || + !Array.isArray(entry.plots) || + !entry.plots.every(isPlotFrame) || + !Number.isInteger(entry.currentIndex) || + entry.currentIndex < -1 || + entry.currentIndex >= entry.plots.length || + (entry.plots.length > 0 && entry.currentIndex < 0) || + typeof entry.latestDeleted !== 'boolean' + ) { + continue; + } + const removed = Math.max(0, entry.plots.length - this.maxPlots); + this.sessions.set(entry.id, { + plots: entry.plots.slice(removed), + currentIndex: entry.plots.length ? Math.max(0, entry.currentIndex - removed) : -1, + latestDeleted: entry.latestDeleted, + label: typeof entry.label === 'string' ? entry.label.slice(0, 256) : undefined, + }); + } + this.activeSessionId = + state.activeSessionId && this.sessions.has(state.activeSessionId) + ? state.activeSessionId + : (this.sessionIds().at(-1) ?? ''); + this.selectedSessionId = + state.selectedSessionId && this.sessions.has(state.selectedSessionId) + ? state.selectedSessionId + : undefined; + this.emitter.emit('change'); + } + + selectSession(sessionId: string): boolean { + if (!this.sessions.has(sessionId)) { + return false; + } + this.selectedSessionId = sessionId; + this.activeSessionId = sessionId; + this.emitter.emit('change'); + return true; + } + + private activate(sessionId: string): void { + if (!this.selectedSessionId) { + this.activeSessionId = sessionId; + } } onDidChange(listener: () => void): { dispose(): void } { @@ -52,7 +142,7 @@ export class PlotHistory { session.plots.shift(); } session.currentIndex = session.plots.length - 1; - this.activeSessionId = sessionId; + this.activate(sessionId); this.emitter.emit('change'); } @@ -66,7 +156,7 @@ export class PlotHistory { plot.rIndex = old.rIndex; } session.plots[session.currentIndex] = plot; - this.activeSessionId = sessionId; + this.activate(sessionId); this.emitter.emit('change'); } @@ -88,7 +178,7 @@ export class PlotHistory { if (plot.frameExt) { latest.frameExt = plot.frameExt; } - this.activeSessionId = sessionId; + this.activate(sessionId); this.emitter.emit('change'); return true; } @@ -104,7 +194,6 @@ export class PlotHistory { } plot.rIndex = rIndex; session.plots[idx] = plot; - this.activeSessionId = sessionId; this.emitter.emit('change'); return true; } @@ -132,7 +221,6 @@ export class PlotHistory { plot.rIndex = expectedRIndex; } session.plots[session.plots.length - 1] = plot; - this.activeSessionId = sessionId; this.emitter.emit('change'); return true; } @@ -225,3 +313,27 @@ export class PlotHistory { return session.plots[session.currentIndex]; } } + +function isPlotFrame(value: unknown): value is PlotFrame { + if (!value || typeof value !== 'object') { + return false; + } + const frame = value as Partial; + const device = frame.device; + return ( + typeof frame.version === 'number' && + typeof frame.sessionId === 'string' && + Array.isArray(frame.ops) && + Boolean( + device && + Number.isFinite(device.width) && + device.width > 0 && + Number.isFinite(device.height) && + device.height > 0 && + Number.isFinite(device.dpi) && + device.dpi > 0 && + (device.bg === null || typeof device.bg === 'string'), + ) && + (frame.rIndex === undefined || (Number.isInteger(frame.rIndex) && frame.rIndex >= 0)) + ); +} diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts index 29e7f100b..4db1c7f23 100644 --- a/src/plotViewer/jgdSocketServer.ts +++ b/src/plotViewer/jgdSocketServer.ts @@ -34,6 +34,7 @@ interface RSession { lastResizeH: number; lastResizeHadPlotIndex: boolean; dpi: number; + deviceClosed?: boolean; } const isWindows = process.platform === 'win32'; @@ -52,7 +53,6 @@ export class JgdSocketServer { private socketDir: string = ''; private sessions: Map = new Map(); private connectionListeners: ConnectionChangeListener[] = []; - private sessionCounter = 0; private readyListeners: (() => void)[] = []; private resizeListener: ((w: number, h: number) => void) | null = null; @@ -100,21 +100,24 @@ export class JgdSocketServer { } handleResize(w: number, h: number) { + const sessionId = this.history.getActiveSessionId(); const idx = this.history.currentIndex(); const total = this.history.count(); if ((total > 0 && idx < total) || (total > 0 && this.history.isLatestDeleted())) { const rIndex = this.history.currentRIndex(); if (rIndex !== undefined) { - const sessionId = this.history.getActiveSessionId(); this.broadcastResize(w, h, rIndex, sessionId); - } else { - this.broadcastResize(w, h); } } else { - this.broadcastResize(w, h); + this.broadcastResize(w, h, undefined, sessionId); } } + isConnected(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + return !!session && !session.deviceClosed; + } + private notifyConnectionChange() { const count = this.sessions.size; for (const l of this.connectionListeners) { @@ -191,7 +194,8 @@ export class JgdSocketServer { } private handleConnection(socket: net.Socket) { - const sessionId = `session-${++this.sessionCounter}`; + // A new device must never acquire a restored device's replay indices. + const sessionId = `session-${crypto.randomUUID()}`; // Metrics requests omit DPI. Use JGD's default until a frame announces // the device's resolution, then keep it specific to this connection. const session: RSession = { @@ -267,6 +271,7 @@ export class JgdSocketServer { if (Number.isFinite(plot.device?.dpi) && plot.device.dpi > 0) { session.dpi = plot.device.dpi; } + const deviceLabel = plot.sessionId; plot.sessionId = session.id; plot.frameExt = msg.ext ?? null; @@ -308,6 +313,9 @@ export class JgdSocketServer { this.history.replaceLatest(session.id, plot as PlotFrame); } if (accepted) { + if (typeof deviceLabel === 'string' && deviceLabel) { + this.history.setSessionLabel(session.id, deviceLabel); + } this.onFrameFn?.(session.id, msg); } } @@ -329,6 +337,7 @@ export class JgdSocketServer { case 'close': console.log(`jgd: session ${session.id} device closed`); + session.deviceClosed = true; this.onDeviceClosedFn?.(session.id); break; @@ -353,7 +362,7 @@ export class JgdSocketServer { return; } const session = this.sessions.get(sessionId); - if (!session) { + if (!session || session.deviceClosed) { return; } session.lastResizeW = w; @@ -366,6 +375,9 @@ export class JgdSocketServer { const data = JSON.stringify({ type: 'resize', width: w, height: h }) + '\n'; for (const session of this.sessions.values()) { + if (session.id !== sessionId || session.deviceClosed) { + continue; + } if (session.lastResizeW === w && session.lastResizeH === h) { if (!session.lastResizeHadPlotIndex) { continue; diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 5714c835c..135c9b733 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { PlotViewer } from './types'; +import { PlotViewer, plotPanelPath } from './types'; import { PlotHistory, PlotFrame } from './jgdPlotHistory'; import { JgdSocketServer, JgdMessage } from './jgdSocketServer'; import { config, UriIcon } from '../util'; @@ -80,6 +80,8 @@ interface WebviewDeleteCurrent { } type WebviewMessage = + | { type: 'ready' } + | { type: 'selectSession'; sessionId: string } | WebviewMetricsResponse | WebviewMetricsWarmup | WebviewExportData @@ -111,18 +113,17 @@ export class JgdManager { return; } this.started = true; - this.server.setOnFrame((_sessionId, msg: JgdMessage) => { + this.server.setOnFrame(() => { const current = this.history.currentPlot(); if (current) { this.getOrCreateViewer().showPlot(current); - } else if (msg.plot) { - this.getOrCreateViewer().showPlot(msg.plot as PlotFrame); } }); this.server.setOnDeviceClosed((_sessionId) => { this.viewer?.updateToolbar(); }); + this.server.onConnectionChange(() => this.viewer?.updateToolbar()); this.server.setMeasureText((request: JgdMessage) => { return this.getOrCreateViewer().measureText(request as unknown as MetricsRequest); @@ -135,6 +136,7 @@ export class JgdManager { this.server.start(); this.historyChangeDisposable = this.history.onDidChange(() => { + this.viewer?.updateToolbar(); void vscode.commands.executeCommand( 'setContext', 'r.plot.canGoBack', @@ -157,6 +159,9 @@ export class JgdManager { } getViewer(): JgdViewer | null { + if (!this.viewer && this.history.sessionIds().length && this.extensionUri) { + return this.getOrCreateViewer(); + } return this.viewer; } @@ -197,6 +202,15 @@ export class JgdViewer implements PlotViewer { if (plot) { this.sendPlotToWebview(plot); } + this.updateToolbar(); + } + + getPanelPath(): string | undefined { + return plotPanelPath(this.panel); + } + + get isActive(): boolean { + return this.panel?.active ?? false; } dispose(): void { @@ -312,10 +326,24 @@ export class JgdViewer implements PlotViewer { } updateToolbar() { + const selected = this.history.getActiveSessionId(); + const sessions = this.history.sessionIds().map((id, index) => { + const deviceLabel = this.history.sessionLabel(id); + const process = deviceLabel && /^r-(\d+)-(\d+)$/.exec(deviceLabel); + const label = process + ? `R PID ${process[1]} ยท device ${process[2]}` + : deviceLabel || `JGD session ${index + 1}`; + return { id, label: `${label}${this.server.isConnected(id) ? '' : ' (disconnected)'}` }; + }); + if (this.panel) { + this.panel.title = `R Plot (${sessions.find((item) => item.id === selected)?.label ?? 'JGD'})`; + } void this.panel?.webview.postMessage({ type: 'toolbar', current: this.history.currentIndex(), total: this.history.count(), + sessions, + selected, }); } @@ -459,10 +487,29 @@ export class JgdViewer implements PlotViewer { ); this.panel.iconPath = new UriIcon('graph'); - this.panel.webview.html = this.getWebviewHtml(); - this.panel.webview.onDidReceiveMessage((raw: WebviewMessage) => { switch (raw.type) { + case 'ready': { + const plot = this.history.currentPlot(); + if (plot) { + this.sendPlotToWebview(plot); + } + this.updateToolbar(); + break; + } + case 'selectSession': { + if (this.history.selectSession(raw.sessionId)) { + const plot = this.history.currentPlot(); + if (plot) { + this.sendPlotToWebview(plot); + } else { + void this.panel?.webview.postMessage({ type: 'clear' }); + } + this.updateToolbar(); + this.server.handleResize(this.panelWidth, this.panelHeight); + } + break; + } case 'metrics_response': { const resolver = this.pendingMetrics.get(raw.id); if (resolver) { @@ -522,6 +569,7 @@ export class JgdViewer implements PlotViewer { this.panel = null; this.metricsCache.clear(); }); + this.panel.webview.html = this.getWebviewHtml(); } private async handleExportRequest(format: 'png' | 'svg') { @@ -607,6 +655,11 @@ body { background: var(--vscode-editor-background); overflow: hidden; display: f } #toolbar button:hover { background: var(--vscode-button-secondaryHoverBackground); } #toolbar button:disabled { opacity: 0.4; cursor: default; } +#toolbar select { + color: var(--vscode-dropdown-foreground); background: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); padding: 2px; min-width: 0; +} +#session-select { max-width: 45%; } #plot-info { flex: 1; text-align: center; } #canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; } canvas { display: block; } @@ -614,6 +667,7 @@ canvas { display: block; }
+ @@ -646,6 +700,10 @@ const metricsCtx = metricsCanvas.getContext('2d'); let currentPlot = null; +document.getElementById('session-select').addEventListener('change', (event) => { + vscode.postMessage({ type: 'selectSession', sessionId: event.target.value }); +}); + document.getElementById('btn-prev').addEventListener('click', () => { vscode.postMessage({ type: 'navigate', direction: 'previous' }); }); @@ -724,6 +782,14 @@ window.addEventListener('message', (event) => { ctx.clearRect(0, 0, canvas.width, canvas.height); break; case 'toolbar': + const select = document.getElementById('session-select'); + select.replaceChildren(...msg.sessions.map(session => { + const option = document.createElement('option'); + option.value = session.id; + option.textContent = session.label; + return option; + })); + select.value = msg.selected; document.getElementById('plot-info').textContent = msg.total > 0 ? msg.current + ' / ' + msg.total : 'No plots'; document.getElementById('btn-prev').disabled = msg.current <= 1; @@ -739,6 +805,8 @@ window.addEventListener('message', (event) => { } }); +vscode.postMessage({ type: 'ready' }); + function applyGc(ctx, gc) { ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1; diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index 7b4efa239..e40737c48 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; import { asViewColumn, config, UriIcon } from '../util'; import * as session from '../session'; -import { PlotViewer } from './types'; +import { PlotViewer, plotPanelPath } from './types'; +import { formatSessionLabel, ViewerSessionSource } from '../viewerSession'; interface PlotResponse { data: string; @@ -29,6 +30,37 @@ export class StandardPlotViewer implements PlotViewer { private pendingPlotRequest: PendingPlotRequest | undefined; private plotRequest: Promise | undefined; + private source: ViewerSessionSource | undefined; + + get sessionSource(): ViewerSessionSource | undefined { + return this.source; + } + + constructor(private owner?: session.Session) { + if (owner) { + this.bindSession(owner); + } + } + + bindSession(owner: session.Session): void { + this.owner = owner; + this.source = { + sessionId: owner.sessionId, + host: owner.host, + pid: owner.pid, + rVer: owner.rVer, + processExited: owner.processExited, + }; + } + + get isActive(): boolean { + return this.panel?.active ?? false; + } + + getPanelPath(): string | undefined { + return plotPanelPath(this.panel); + } + public async update(): Promise { const viewColumn = asViewColumn( config().get('session.viewers.viewColumn.plot'), @@ -43,6 +75,14 @@ export class StandardPlotViewer implements PlotViewer { } public show(preserveFocus?: boolean): void { + if (!this.panel && this.owner && !this.owner.processExited) { + this.createPanel( + asViewColumn( + config().get('session.viewers.viewColumn.plot'), + vscode.ViewColumn.Two, + ), + ); + } if (this.panel) { this.panel.reveal(undefined, preserveFocus); } @@ -64,7 +104,9 @@ export class StandardPlotViewer implements PlotViewer { private createPanel(viewColumn: vscode.ViewColumn) { const panel = vscode.window.createWebviewPanel( 'r.standardPlot', - 'R Plot', + this.source + ? `R Plot (${formatSessionLabel(this.source.rVer, this.source.pid)})` + : 'R Plot', { viewColumn, preserveFocus: true, @@ -110,14 +152,14 @@ export class StandardPlotViewer implements PlotViewer { private requestPlot(): Promise { const panel = this.panel; - if (!session.globalPipePath || !panel) { + if (!panel || this.owner?.processExited || (!this.owner && !session.globalPipePath)) { return Promise.resolve(); } this.pendingPlotRequest = { panel, panelGeneration: this.panelGeneration, - targetSession: session.activeSession, + targetSession: this.owner ?? session.activeSession, width: this.viewWidth, height: this.viewHeight, format: config().get('plot.format', 'svglite'), @@ -217,10 +259,10 @@ export class StandardPlotViewer implements PlotViewer { private isCurrentRequest(request: PendingPlotRequest): boolean { return ( - Boolean(session.globalPipePath) && + (this.owner ? !this.owner.processExited : Boolean(session.globalPipePath)) && this.panel === request.panel && this.panelGeneration === request.panelGeneration && - session.activeSession === request.targetSession + (this.owner ?? session.activeSession) === request.targetSession ); } diff --git a/src/plotViewer/types.ts b/src/plotViewer/types.ts index 7bb352250..8ed6254ce 100644 --- a/src/plotViewer/types.ts +++ b/src/plotViewer/types.ts @@ -1,5 +1,18 @@ +import type { Session } from '../session'; +import { Uri, WebviewPanel } from 'vscode'; + +export function plotPanelPath(panel: WebviewPanel | undefined | null): string | undefined { + if (!panel) { + return undefined; + } + const uri = panel.webview.asWebviewUri(Uri.file('')); + return `webview-panel/webview-${uri.authority.split('.')[0]}`; +} + export interface PlotViewer { readonly id: string; + readonly isActive?: boolean; + getPanelPath?(): string | undefined; show(preserveFocus?: boolean): void; dispose(): void; handleCommand(command: string, ...args: unknown[]): void | Promise; @@ -9,6 +22,6 @@ export interface PlotManager { viewers: PlotViewer[]; activeViewer: PlotViewer | undefined; initialize(): void; - showStandardPlot(): Promise; - showHttpgdPlot(url: string): Promise; + showStandardPlot(target?: Session): Promise; + showHttpgdPlot(url: string, target?: Session): Promise; } diff --git a/src/plotViewer/webview/smallPlot.ejs b/src/plotViewer/webview/smallPlot.ejs index 7d8337661..378646ab8 100644 --- a/src/plotViewer/webview/smallPlot.ejs +++ b/src/plotViewer/webview/smallPlot.ejs @@ -1,5 +1,5 @@ diff --git a/src/session.ts b/src/session.ts index ecc11cb73..c5c42af84 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2382,13 +2382,6 @@ export function removeSessionFiles(): void { console.info('[removeSessionFiles] Done'); } -async function updatePlot() { - if (!globalPipePath) { - return; - } - await globalPlotManager?.showStandardPlot(); -} - export function deferWorkspaceRefresh(terminal?: vscode.Terminal): void { const target = terminal ? terminalRegistry.ownerOf(terminal) : activeSession; if (target) { @@ -2706,6 +2699,16 @@ export function getViewerSessionContext( return owner ? createViewerSessionContext(owner, sessionProcessMonitor) : undefined; } +export function resolvePlotSession(source: ViewerSessionSource): Session | undefined { + const target = sessions.get(source.sessionId); + return target && + !target.processExited && + isConnectedSession(target) && + sessionProcessIdentity(target) === sessionProcessIdentity(source) + ? target + : undefined; +} + export async function getTableHtml( webview: Webview, file: string | undefined, @@ -3952,7 +3955,7 @@ async function handleNotification(message: Record, socket: IpcS return; } if (typeof params.plot_url === 'string' && params.plot_url) { - await globalPlotManager?.showHttpgdPlot(params.plot_url); + await globalPlotManager?.showHttpgdPlot(params.plot_url, session); } if (isLiveSessionSocket(socket) && isCurrentSocket(socket) && mayApplyUiEffects()) { scheduleWorkspaceRefresh(0); @@ -3978,7 +3981,8 @@ async function handleNotification(message: Record, socket: IpcS } case 'httpgd': { if (typeof params.url === 'string' && params.url) { - await globalPlotManager?.showHttpgdPlot(params.url); + const target = socket._sessionId ? sessions.get(socket._sessionId) : undefined; + await globalPlotManager?.showHttpgdPlot(params.url, target); } break; } @@ -4077,7 +4081,10 @@ async function handleNotification(message: Record, socket: IpcS break; } case 'plot_updated': { - void updatePlot(); + const target = socket._sessionId && sessions.get(socket._sessionId); + if (target && target.socket === socket) { + void globalPlotManager?.showStandardPlot(target); + } break; } case 'restart_r': { diff --git a/src/test/common/mockvscode.ts b/src/test/common/mockvscode.ts index d889a4c9a..ee5a1f2ad 100644 --- a/src/test/common/mockvscode.ts +++ b/src/test/common/mockvscode.ts @@ -29,7 +29,11 @@ export function mockExtensionContext(extension_root: string, sandbox: sinon.Sino path.join(os.tmpdir(), 'vscode-r-test-global-storage', String(process.pid)), ), logUri: sandbox.stub(), - secrets: sandbox.stub(), + secrets: { + get: sandbox.stub().resolves(undefined), + store: sandbox.stub().resolves(), + delete: sandbox.stub().resolves(), + }, storageUri: sandbox.stub(), subscriptions: [], workspaceState: { diff --git a/src/test/suite/jgdPlotHistory.test.ts b/src/test/suite/jgdPlotHistory.test.ts index e528e8466..ddaf93984 100644 --- a/src/test/suite/jgdPlotHistory.test.ts +++ b/src/test/suite/jgdPlotHistory.test.ts @@ -327,6 +327,31 @@ suite('JGD PlotHistory', () => { }); suite('multi-session', () => { + test('selects an existing session without drawing and retains its navigation', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.navigatePrevious(); + history.addPlot('s2', makePlot('C')); + assert.deepStrictEqual(history.sessionIds(), ['s1', 's2']); + assert.ok(history.selectSession('s1')); + assert.strictEqual(history.currentPlot()?.device.bg, 'A'); + history.addPlot('s2', makePlot('D')); + assert.strictEqual(history.currentPlot()?.device.bg, 'A'); + assert.ok(history.selectSession('s2')); + assert.strictEqual(history.currentPlot()?.device.bg, 'D'); + assert.strictEqual(history.selectSession('missing'), false); + assert.strictEqual(history.getActiveSessionId(), 's2'); + }); + + test('a late resize reply cannot switch the selected session', () => { + history.addPlot('s1', { ...makePlot('A'), rIndex: 0 }); + history.addPlot('s2', makePlot('B')); + history.replaceAtIndex('s1', 0, makePlot('A resized')); + assert.strictEqual(history.getActiveSessionId(), 's2'); + history.selectSession('s1'); + assert.strictEqual(history.currentPlot()?.device.bg, 'A resized'); + }); + test('tracks plots independently per session', () => { history.addPlot('s1', makePlot('S1-A')); history.addPlot('s2', makePlot('S2-A')); @@ -340,6 +365,46 @@ suite('JGD PlotHistory', () => { }); suite('events', () => { + test('restores selection and per-session navigation from reload state', () => { + history.addPlot('a', makePlot('A1')); + history.addPlot('a', makePlot('A2')); + history.removeCurrent(); + history.setSessionLabel('a', 'r-123-1'); + history.addPlot('b', makePlot('B1')); + history.addPlot('b', makePlot('B2')); + history.navigatePrevious(); + history.selectSession('a'); + const restored = new PlotHistory(); + restored.restore(JSON.parse(JSON.stringify(history.snapshot())) as unknown); + assert.strictEqual(restored.getActiveSessionId(), 'a'); + assert.strictEqual(restored.sessionLabel('a'), 'r-123-1'); + assert.strictEqual(restored.isLatestDeleted(), true); + restored.addPlot('new-device', makePlot('new')); + assert.strictEqual(restored.getActiveSessionId(), 'a'); + restored.selectSession('b'); + assert.strictEqual(restored.currentPlot()?.device.bg, 'B1'); + assert.strictEqual(restored.navigateNext()?.device.bg, 'B2'); + }); + + test('restoration ignores invalid frames and applies the current history limit', () => { + const restored = new PlotHistory(1); + restored.restore({ + activeSessionId: 'bad', + sessions: [ + { id: 'bad', plots: [null], currentIndex: 0, latestDeleted: false }, + { + id: 'valid', + plots: [makePlot('old'), makePlot('latest')], + currentIndex: 0, + latestDeleted: false, + }, + ], + }); + assert.deepStrictEqual(restored.sessionIds(), ['valid']); + assert.strictEqual(restored.count(), 1); + assert.strictEqual(restored.currentPlot()?.device.bg, 'latest'); + }); + test('emits change on addPlot', () => { let fired = 0; history.onDidChange(() => fired++); diff --git a/src/test/suite/jgdSocketServer.test.ts b/src/test/suite/jgdSocketServer.test.ts index 10de74cc2..7594fe724 100644 --- a/src/test/suite/jgdSocketServer.test.ts +++ b/src/test/suite/jgdSocketServer.test.ts @@ -164,6 +164,25 @@ suite('JGD SocketServer', () => { } suite('frame routing', () => { + test('resizes only the selected device, including its latest plot', async () => { + const first = await connect(); + const second = await connect(); + first.send(makePlotMsg('A')); + second.send(makePlotMsg('B')); + await waitMs(50); + const firstId = history + .sessionIds() + .find((id) => history.latestPlot(id)?.device.bg === 'A')!; + history.selectSession(firstId); + server.handleResize(1000, 700); + assert.strictEqual((JSON.parse(await first.readLine()) as JgdMessage).type, 'resize'); + second.send({ type: 'metrics_request', id: 42, kind: 'strWidth', str: 'barrier' }); + assert.strictEqual( + (JSON.parse(await second.readLine()) as JgdMessage).type, + 'metrics_response', + ); + }); + test('routes normal frame to addPlot', async () => { const client = await connect(); diff --git a/src/test/suite/plotCommands.test.ts b/src/test/suite/plotCommands.test.ts index c0438a9a6..ecfc42c6c 100644 --- a/src/test/suite/plotCommands.test.ts +++ b/src/test/suite/plotCommands.test.ts @@ -66,7 +66,7 @@ suite('Contextual plot commands', () => { ); }); - test('plot commands remain registered and route to the appropriate viewers', () => { + test('plot commands remain registered and route to the appropriate viewers', async () => { const sandbox = sinon.createSandbox(); try { mockExtensionContext(extensionRoot, sandbox); @@ -96,11 +96,11 @@ suite('Contextual plot commands', () => { const invoke = (command: string, ...args: unknown[]) => { const registration = register.getCalls().find((call) => call.args[0] === command); assert.ok(registration, `${command} must remain registered`); - (registration.args[1] as (...args: unknown[]) => void)(...args); + return (registration.args[1] as (...args: unknown[]) => Promise)(...args); }; for (const command of toolbarCommands) { viewer.handleCommand.resetHistory(); - invoke(command, vscode.Uri.parse('webview:/plot-viewer')); + await invoke(command, vscode.Uri.parse('webview:/plot-viewer')); if (command === 'r.plot.openUrl') { sinon.assert.calledOnce(openUrl); } else { @@ -112,11 +112,11 @@ suite('Contextual plot commands', () => { } for (const command of ['showIndex', 'hidePlot']) { viewer.handleCommand.resetHistory(); - invoke(`r.plot.${command}`, 'localhost:1234', 'plot-id'); + await invoke(`r.plot.${command}`, 'localhost:1234', 'plot-id'); assert.strictEqual(viewer.handleCommand.callCount, 1); assert.deepStrictEqual(viewer.handleCommand.firstCall.args, [command, 'plot-id']); } - invoke('r.plot.showViewers'); + await invoke('r.plot.showViewers'); assert.strictEqual(viewer.show.callCount, 1); assert.deepStrictEqual(viewer.show.firstCall.args, [true]); sinon.assert.calledOnceWithExactly(showJgdViewer, true); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts new file mode 100644 index 000000000..e7b825020 --- /dev/null +++ b/src/test/suite/plotRestoration.test.ts @@ -0,0 +1,374 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { CommonPlotManager } from '../../plotViewer'; +import { JgdManager } from '../../plotViewer/jgdViewer'; +import { HttpgdClient } from '../../plotViewer/httpgdClient'; +import { extensionContext } from '../../extension'; +import * as session from '../../session'; +import * as util from '../../util'; +import { mockExtensionContext } from '../common/mockvscode'; +import { waitForValue } from '../common/sessionConnections'; + +const createRealWebviewPanel = vscode.window.createWebviewPanel; + +suite('Standalone plot restoration', () => { + let sandbox: sinon.SinonSandbox; + let state: Map; + let secrets: Map; + let managers: CommonPlotManager[]; + let connect: sinon.SinonStub; + let callbacks: Map Promise>; + let panels: Array<{ + panel: vscode.WebviewPanel; + receive: (message: unknown) => Promise; + post: sinon.SinonStub; + }>; + + setup(() => { + sandbox = sinon.createSandbox(); + state = new Map(); + secrets = new Map(); + managers = []; + callbacks = new Map(); + panels = []; + mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox); + (extensionContext.workspaceState.get as sinon.SinonStub).callsFake( + (key: string, fallback?: unknown) => state.get(key) ?? fallback, + ); + (extensionContext.workspaceState.update as sinon.SinonStub).callsFake( + (key: string, value: unknown) => { + state.set(key, structuredClone(value)); + return Promise.resolve(); + }, + ); + (extensionContext.secrets.get as sinon.SinonStub).callsFake((key: string) => + Promise.resolve(secrets.get(key)), + ); + (extensionContext.secrets.store as sinon.SinonStub).callsFake( + (key: string, value: string) => { + secrets.set(key, value); + return Promise.resolve(); + }, + ); + sandbox.stub(vscode.commands, 'registerCommand').callsFake((command, callback) => { + callbacks.set(command, callback as (...args: unknown[]) => Promise); + return new vscode.Disposable(() => {}); + }); + sandbox.stub(vscode.commands, 'executeCommand').resolves(); + sandbox.stub(JgdManager.prototype, 'start'); + connect = sandbox.stub(HttpgdClient.prototype, 'connect').resolves(); + sandbox.stub(util, 'config').returns({ + get: (_key: string, fallback: unknown) => fallback, + } as vscode.WorkspaceConfiguration); + sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((viewType, title) => { + const disposed = new vscode.EventEmitter(); + let receive = (_message: unknown): unknown => undefined; + const post = sandbox.stub().resolves(true); + const panel = { + title, + viewType, + active: true, + reveal: sandbox.stub(), + webview: { + html: '', + postMessage: post, + asWebviewUri: (uri: vscode.Uri) => + uri.with({ + scheme: 'https', + authority: `panel${panels.length}.vscode-resource.vscode-cdn.net`, + }), + onDidReceiveMessage: (callback: typeof receive) => { + receive = callback; + return new vscode.Disposable(() => {}); + }, + }, + onDidDispose: disposed.event, + onDidChangeViewState: () => new vscode.Disposable(() => {}), + dispose: () => disposed.fire(), + } as unknown as vscode.WebviewPanel; + panels.push({ + panel, + post, + receive: async (message) => { + await receive(message); + }, + }); + return panel; + }); + }); + + teardown(async () => { + for (const manager of managers) { + await manager.dispose(); + } + sandbox.restore(); + }); + + function manager(): CommonPlotManager { + const value = new CommonPlotManager(); + value.initialize(); + managers.push(value); + return value; + } + + test('Show Viewers requests the standard plot from its original attached session after reload', async () => { + const first = manager(); + const owner = { + sessionId: 'a', + host: 'host', + pid: '123', + rVer: '4.6.0', + processExited: false, + } as session.Session; + const request = sandbox + .stub(session, 'sessionRequest') + .resolves({ data: 'original', format: 'svg' }); + const resolve = sandbox.stub(session, 'resolvePlotSession').returns(owner); + await first.showStandardPlot(owner); + await panels[0].receive({ type: 'ready' }); + await first.dispose(); + assert.ok( + !JSON.stringify([...state.values()]).includes('original'), + 'standard images are not persisted', + ); + panels = []; + request.resetHistory(); + request.resolves({ data: 'fresh-from-R', format: 'svg' }); + manager(); + assert.strictEqual(panels.length, 0); + await callbacks.get('r.plot.showViewers')!(); + sinon.assert.calledOnce(resolve); + await panels[0].receive({ type: 'ready' }); + assert.strictEqual(request.lastCall.args[1], owner); + sinon.assert.calledWith(panels[0].post, { + type: 'update', + data: 'fresh-from-R', + format: 'svg', + }); + }); + + test('standard restoration waits for the owning session instead of showing an archived image', async () => { + const first = manager(); + const owner = { + sessionId: 'a', + host: 'host', + pid: '123', + rVer: '4.6.0', + processExited: false, + } as session.Session; + sandbox.stub(session, 'sessionRequest').resolves({ data: 'plot', format: 'svg' }); + const resolve = sandbox.stub(session, 'resolvePlotSession').returns(undefined); + const information = sandbox.stub(vscode.window, 'showInformationMessage').resolves(); + await first.showStandardPlot(owner); + await panels[0].receive({ type: 'ready' }); + await first.dispose(); + panels = []; + manager(); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 0); + sinon.assert.calledOnce(information); + resolve.returns(owner); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 1); + }); + + test('httpgd reconnects with its token and retains live rendering and removal after reload', async () => { + const first = manager(); + await first.showHttpgdPlot('http://localhost:1234/live?token=secret'); + first.httpgdManager.viewers[0].plots = [ + { id: '1', data: 'not persisted', width: 400, height: 300, zoom: 1 }, + ]; + await first.dispose(); + assert.ok(!JSON.stringify([...state.values()]).includes('secret')); + assert.ok(!JSON.stringify([...secrets.values()]).includes('')); + panels = []; + connect.resetHistory(); + const restored = manager(); + sinon.assert.notCalled(connect); + await callbacks.get('r.plot.showViewers')!(); + sinon.assert.calledOnce(connect); + const viewer = restored.httpgdManager.viewers[0]; + assert.strictEqual(viewer.token, 'secret'); + assert.deepStrictEqual(viewer.plots, []); + viewer.plots = [{ id: 'live', data: '', width: 1, height: 1, zoom: 1 }]; + const render = sandbox.stub(viewer.api, 'getPlotText').resolves('fresh'); + sandbox.stub(viewer.api, 'getPlots').returns([{ id: 'live' }]); + await viewer.focusPlot('live'); + sinon.assert.called(render); + const remove = sandbox.stub(viewer.api, 'removePlot').resolves(); + await viewer.closePlot('live'); + sinon.assert.calledWithExactly(remove, { id: 'live' }); + await callbacks.get('r.plot.showViewers')!(); + sinon.assert.calledOnce(connect); + }); + + test('httpgd reload fetches fresh plots from an authenticated server', async () => { + connect.callThrough(); + let revision = 1; + let removed = false; + const requests: string[] = []; + const server = http.createServer((request, response) => { + if (request.headers['x-httpgd-token'] !== 'device-token') { + response.writeHead(401).end(); + return; + } + const route = new URL(request.url!, 'http://localhost').pathname; + requests.push(route); + const state = { upid: revision, hsize: removed ? 0 : 1, active: true }; + switch (route) { + case '/renderers': + response.end( + JSON.stringify({ + renderers: [{ id: 'svgp', name: 'SVG', ext: 'svg', descr: 'SVG' }], + }), + ); + break; + case '/plots': + response.end(JSON.stringify({ state, plots: removed ? [] : [{ id: '1' }] })); + break; + case '/plot': + response.end(`revision ${revision}`); + break; + case '/remove': + removed = true; + response.end(JSON.stringify({ ...state, hsize: 0 })); + break; + default: + response.end(JSON.stringify(state)); + } + }); + server.on('upgrade', (_request, socket) => socket.destroy()); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + let restored: CommonPlotManager | undefined; + try { + const first = manager(); + const address = server.address() as AddressInfo; + await first.showHttpgdPlot(`http://127.0.0.1:${address.port}/live?token=device-token`); + await waitForValue(() => + first.httpgdManager.viewers[0].plots.length ? true : undefined, + ); + await first.dispose(); + revision = 2; + requests.length = 0; + restored = manager(); + await callbacks.get('r.plot.showViewers')!(); + const viewer = restored.httpgdManager.viewers[0]; + await waitForValue(() => + viewer.plots[0]?.data.includes('revision 2') ? true : undefined, + ); + assert.ok(requests.includes('/renderers')); + assert.ok(requests.includes('/plots')); + assert.ok(requests.includes('/plot')); + assert.ok( + (await viewer.api.getPlotBytes({ id: '1', renderer: 'svgp' })) + .toString() + .includes('revision 2'), + ); + await viewer.closePlot('1'); + assert.strictEqual(removed, true); + } finally { + await restored?.dispose(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + test('httpgd restoration does not replace a device that already attached with a new token', async () => { + const first = manager(); + await first.showHttpgdPlot('http://localhost:1234/live?token=old'); + await first.dispose(); + const restored = manager(); + await restored.showHttpgdPlot('http://localhost:1234/live?token=new'); + const live = restored.httpgdManager.viewers[0]; + await callbacks.get('r.plot.showViewers')!(); + assert.deepStrictEqual(restored.httpgdManager.viewers, [live]); + assert.strictEqual(live.token, 'new'); + }); + + test('a real JGD webview receives and exports restored frames after its ready handshake', async () => { + const first = manager(); + first.jgdManager.history.addPlot('old-device', { + version: 1, + sessionId: 'old-device', + device: { width: 400, height: 300, dpi: 96, bg: 'magenta' }, + ops: [], + }); + await first.dispose(); + let panel: vscode.WebviewPanel | undefined; + let resolveReady!: () => void; + let resolveExport!: (data: string) => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const exported = new Promise((resolve) => { + resolveExport = resolve; + }); + (vscode.window.createWebviewPanel as sinon.SinonStub).callsFake( + (...args: Parameters) => { + panel = createRealWebviewPanel(...args); + panel.webview.onDidReceiveMessage((message: { type: string; data?: string }) => { + if (message.type === 'ready') { + resolveReady(); + } + if (message.type === 'export_data' && message.data) { + resolveExport(message.data); + } + }); + return panel; + }, + ); + sandbox.stub(vscode.window, 'showSaveDialog').resolves(undefined); + manager(); + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('Restored webview did not render/export')), + 15000, + ); + }); + try { + await callbacks.get('r.plot.showViewers')!(); + await Promise.race([ready, timeout]); + await panel!.webview.postMessage({ + type: 'export', + format: 'svg', + width: 400, + height: 300, + }); + const svg = Buffer.from(await Promise.race([exported, timeout]), 'base64').toString( + 'utf8', + ); + assert.ok(svg.includes(' { + const first = manager(); + await first.showHttpgdPlot('http://localhost:1234/live?token=secret'); + await first.dispose(); + const value = [...secrets.values()][0]; + let finishRead!: (value: string) => void; + (extensionContext.secrets.get as sinon.SinonStub).returns( + new Promise((resolve) => { + finishRead = resolve; + }), + ); + connect.resetHistory(); + panels = []; + const restored = manager(); + const showing = callbacks.get('r.plot.showViewers')!(); + const stopping = restored.dispose(); + finishRead(value); + await Promise.all([showing, stopping]); + sinon.assert.notCalled(connect); + assert.strictEqual(panels.length, 0); + }); +}); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 960e599f5..9df06b35f 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -73,7 +73,7 @@ suite('Session Communication', () => { plotPanelCleanup?.(); plotPanelCleanup = undefined; const plotManager = extension.globalPlotManager as plotViewer.CommonPlotManager | undefined; - plotManager?.standardPlotViewer.dispose(); + await plotManager?.dispose(); const attachedSessionId = session.activeSession?.sessionId; if (rTerminal.rTerm) { rTerminal.rTerm.dispose(); diff --git a/src/test/suite/sessionConnectionLifecycle.test.ts b/src/test/suite/sessionConnectionLifecycle.test.ts index c87bb1b49..f95c2a19e 100644 --- a/src/test/suite/sessionConnectionLifecycle.test.ts +++ b/src/test/suite/sessionConnectionLifecycle.test.ts @@ -208,6 +208,36 @@ suite('Session Connection Lifecycle API', () => { assert.notStrictEqual(newReference.connectionId, oldReference.connectionId); }); + test('standard plot notifications target their originating session, not the active terminal', async () => { + const showStandardPlot = sandbox.stub().resolves(); + sandbox.stub(extension, 'globalPlotManager').value({ showStandardPlot }); + const first = await connections.attach('standard-a', 55040, 'external-host'); + await connections.attach('standard-b', 55041, 'external-host'); + await session.activateSessionById('standard-b'); + first.emit( + 'data', + Buffer.from(JSON.stringify({ method: 'plot_updated', params: {} }) + '\n'), + ); + await waitForValue(() => (showStandardPlot.calledOnce ? true : undefined)); + assert.strictEqual( + (showStandardPlot.firstCall.args[0] as session.Session).sessionId, + 'standard-a', + ); + assert.strictEqual(session.activeSession?.sessionId, 'standard-b'); + const owner = showStandardPlot.firstCall.args[0] as session.Session; + assert.strictEqual(session.resolvePlotSession(owner), owner); + assert.strictEqual( + session.resolvePlotSession({ + sessionId: owner.sessionId, + host: owner.host, + pid: 'different-process', + rVer: owner.rVer, + processExited: false, + }), + undefined, + ); + }); + test('attach UI effects preserve native behavior but ignore background and superseded choices', async () => { const showHttpgdPlot = sandbox.stub().resolves(); sandbox @@ -239,7 +269,12 @@ suite('Session Connection Lifecycle API', () => { plotUrl: 'http://127.0.0.1:55031', }); await waitForValue(() => (showHttpgdPlot.calledOnce ? true : undefined)); - assert.deepStrictEqual(showHttpgdPlot.firstCall.args, ['http://127.0.0.1:55031']); + assert.strictEqual(showHttpgdPlot.firstCall.args[0], 'http://127.0.0.1:55031'); + assert.strictEqual( + (showHttpgdPlot.firstCall.args[1] as session.Session).sessionId, + 'plot-native-background', + 'plots belong to their notifying session even when a different session is active', + ); assert.strictEqual( session.activeSession, selected, diff --git a/src/test/suite/standardPlotViewer.test.ts b/src/test/suite/standardPlotViewer.test.ts index 33324831d..0a8978491 100644 --- a/src/test/suite/standardPlotViewer.test.ts +++ b/src/test/suite/standardPlotViewer.test.ts @@ -219,6 +219,30 @@ suite('Standard plot viewer request coordination', () => { sinon.assert.notCalled(panels[0].postMessage); }); + test('a bound viewer keeps requesting its owner after the active session changes', async () => { + const owner = { sessionId: 'A', pid: '123', rVer: '4.5.0' } as session.Session; + viewer = new StandardPlotViewer(owner); + activeSession.value(owner); + const response = deferred(); + const request = sandbox.stub(session, 'sessionRequest').returns(response.promise); + await viewer.update(); + const ready = panels[0].ready(); + activeSession.value({ sessionId: 'B' }); + response.resolve({ data: 'A plot', format: 'svg' }); + await ready; + sinon.assert.calledWithExactly(panels[0].postMessage, { + type: 'update', + data: 'A plot', + format: 'svg', + }); + await panels[0].resize(300, 400); + assert.strictEqual(request.lastCall.args[1], owner); + viewer.dispose(); + viewer.show(); + assert.strictEqual(panels.length, 2); + assert.ok(panels[1].panel.webview.html.includes("type: 'ready'")); + }); + test('keeps a different active session queued when the old session returns no data', async () => { const sessionA = { sessionId: 'A' } as session.Session; const sessionB = { sessionId: 'B' } as session.Session; diff --git a/src/viewerSession.ts b/src/viewerSession.ts index 2b1a1e6e8..0ae83f125 100644 --- a/src/viewerSession.ts +++ b/src/viewerSession.ts @@ -9,6 +9,21 @@ export interface ViewerSessionSource { processExited: boolean; } +export function isViewerSessionSource(value: unknown): value is ViewerSessionSource { + if (!value || typeof value !== 'object') { + return false; + } + const source = value as Partial; + return ( + typeof source.sessionId === 'string' && + source.sessionId.length > 0 && + typeof source.host === 'string' && + typeof source.pid === 'string' && + typeof source.rVer === 'string' && + typeof source.processExited === 'boolean' + ); +} + export interface ViewerSessionContext { readonly sessionId: string; readonly source: Readonly; From 786957c27c7112ca392a36b2b265bfd8162af3c8 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:55:31 +1100 Subject: [PATCH 02/32] feat(plot): use separate JGD viewers per session Give each JGD session its own viewer and remove the session dropdown. Keep history, navigation, resize, and text measurement scoped to each session, and restore separate viewers after VS Code reload. Avoid redundant plot requests and toolbar updates, and prevent httpgd restoration from overwriting newer connection credentials. --- src/plotViewer/httpgdViewer.ts | 17 +- src/plotViewer/index.ts | 20 ++- src/plotViewer/jgdPlotHistory.ts | 117 +++++++------- src/plotViewer/jgdSocketServer.ts | 68 ++++---- src/plotViewer/jgdViewer.ts | 207 ++++++++++++------------- src/plotViewer/standardViewer.ts | 13 +- src/test/suite/jgdPlotHistory.test.ts | 54 ++++--- src/test/suite/jgdSocketServer.test.ts | 27 ++-- src/test/suite/plotCommands.test.ts | 2 +- src/test/suite/plotRestoration.test.ts | 199 +++++++++++++++++++++++- 10 files changed, 459 insertions(+), 265 deletions(-) diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 6a6ca555e..e48658d11 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -76,11 +76,7 @@ export class HttpgdManager { restoreConnections(): Promise { this.restoring ??= this.savedConnections.then(async (connections) => { for (const connection of connections) { - const origin = new URL(connection.url).origin; - // A session may already have attached with newer credentials. - if (!this.viewers.some((viewer) => viewer.origin === origin)) { - await this.showViewer(connection.url, connection.source); - } + await this.showViewer(connection.url, connection.source, true); } }); return this.restoring; @@ -116,7 +112,11 @@ export class HttpgdManager { }; } - public async showViewer(urlString: string, source?: ViewerSessionSource): Promise { + public async showViewer( + urlString: string, + source?: ViewerSessionSource, + onlyIfMissing = false, + ): Promise { await Promise.resolve(); if (this.disposed) { return; @@ -124,6 +124,11 @@ export class HttpgdManager { const url = new URL(urlString); const token = url.searchParams.get('token') || undefined; const ind = this.viewers.findIndex((viewer) => viewer.origin === url.origin); + // Check after the async boundary: a new device may have attached while + // restoration was pending. Never replace its credentials with saved ones. + if (onlyIfMissing && ind >= 0) { + return; + } if (ind >= 0) { const viewer = this.viewers.splice(ind, 1)[0]; if (viewer.token === token) { diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index eeef86da4..b6dfcbc59 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -66,10 +66,7 @@ export class CommonPlotManager implements PlotManager { get viewers(): PlotViewer[] { const viewers: PlotViewer[] = [...this.httpgdManager.viewers]; - const jgdViewer = this.jgdManager.getViewer(); - if (jgdViewer) { - viewers.push(jgdViewer); - } + viewers.push(...this.jgdManager.getViewers()); viewers.push(this.standardPlotViewer); viewers.push(...this.standardViewers.values()); return viewers; @@ -127,6 +124,9 @@ export class CommonPlotManager implements PlotManager { // Start the JGD server when the backend allows it, so switching to jgd // takes effect on the next R (re)start without reloading VS Code. private applyBackend(): void { + if (this.disposed) { + return; + } const backend = resolveBackend(); void vscode.commands.executeCommand('setContext', 'r.plot.backend', backend); const envCollection = extensionContext.environmentVariableCollection; @@ -143,9 +143,13 @@ export class CommonPlotManager implements PlotManager { } public async showStandardPlot(target = activeSession): Promise { - if (!target) { + if (!target || this.disposed) { return; } + await this.getStandardViewer(target).update(); + } + + private getStandardViewer(target: Session): StandardPlotViewer { const key = sessionProcessIdentity(target); let viewer = this.standardViewers.get(key); if (!viewer) { @@ -154,7 +158,7 @@ export class CommonPlotManager implements PlotManager { } else { viewer.bindSession(target); } - await viewer.update(); + return viewer; } public async showHttpgdPlot(url: string, target?: Session): Promise { @@ -225,7 +229,9 @@ export class CommonPlotManager implements PlotManager { for (const source of this.restoredStandardSessions) { const target = resolvePlotSession(source); if (target) { - await this.showStandardPlot(target); + // Opening the panel below requests its plot on readiness. Do + // not wait for R or refresh viewers that are already restored. + this.getStandardViewer(target); } else if (!this.standardViewers.has(sessionProcessIdentity(source))) { unavailable = true; } diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts index 1b6e242f5..81b3f957e 100644 --- a/src/plotViewer/jgdPlotHistory.ts +++ b/src/plotViewer/jgdPlotHistory.ts @@ -23,14 +23,12 @@ interface SessionHistory { export interface PlotHistoryState { activeSessionId: string; - selectedSessionId?: string; sessions: Array; } export class PlotHistory { private sessions: Map = new Map(); private activeSessionId: string = ''; - private selectedSessionId: string | undefined; private maxPlots: number; private emitter = new EventEmitter(); @@ -47,17 +45,17 @@ export class PlotHistory { } setSessionLabel(sessionId: string, label: string): void { + label = label.slice(0, 256); const history = this.sessions.get(sessionId); if (history && history.label !== label) { - history.label = label.slice(0, 256); - this.emitter.emit('change'); + history.label = label; + this.emitter.emit('change', sessionId); } } snapshot(): PlotHistoryState { return { activeSessionId: this.activeSessionId, - selectedSessionId: this.selectedSessionId, sessions: [...this.sessions].map(([id, history]) => ({ id, ...history })), }; } @@ -97,30 +95,10 @@ export class PlotHistory { state.activeSessionId && this.sessions.has(state.activeSessionId) ? state.activeSessionId : (this.sessionIds().at(-1) ?? ''); - this.selectedSessionId = - state.selectedSessionId && this.sessions.has(state.selectedSessionId) - ? state.selectedSessionId - : undefined; this.emitter.emit('change'); } - selectSession(sessionId: string): boolean { - if (!this.sessions.has(sessionId)) { - return false; - } - this.selectedSessionId = sessionId; - this.activeSessionId = sessionId; - this.emitter.emit('change'); - return true; - } - - private activate(sessionId: string): void { - if (!this.selectedSessionId) { - this.activeSessionId = sessionId; - } - } - - onDidChange(listener: () => void): { dispose(): void } { + onDidChange(listener: (sessionId?: string) => void): { dispose(): void } { this.emitter.on('change', listener); return { dispose: () => { @@ -142,8 +120,8 @@ export class PlotHistory { session.plots.shift(); } session.currentIndex = session.plots.length - 1; - this.activate(sessionId); - this.emitter.emit('change'); + this.activeSessionId = sessionId; + this.emitter.emit('change', sessionId); } replaceCurrent(sessionId: string, plot: PlotFrame) { @@ -156,8 +134,8 @@ export class PlotHistory { plot.rIndex = old.rIndex; } session.plots[session.currentIndex] = plot; - this.activate(sessionId); - this.emitter.emit('change'); + this.activeSessionId = sessionId; + this.emitter.emit('change', sessionId); } appendOps(sessionId: string, plot: PlotFrame): boolean { @@ -178,8 +156,8 @@ export class PlotHistory { if (plot.frameExt) { latest.frameExt = plot.frameExt; } - this.activate(sessionId); - this.emitter.emit('change'); + this.activeSessionId = sessionId; + this.emitter.emit('change', sessionId); return true; } @@ -194,7 +172,7 @@ export class PlotHistory { } plot.rIndex = rIndex; session.plots[idx] = plot; - this.emitter.emit('change'); + this.emitter.emit('change', sessionId); return true; } @@ -221,12 +199,12 @@ export class PlotHistory { plot.rIndex = expectedRIndex; } session.plots[session.plots.length - 1] = plot; - this.emitter.emit('change'); + this.emitter.emit('change', sessionId); return true; } - currentPlot(): PlotFrame | null { - const session = this.sessions.get(this.activeSessionId); + currentPlot(sessionId = this.activeSessionId): PlotFrame | null { + const session = this.sessions.get(sessionId); if (!session || session.currentIndex < 0) { return null; } @@ -237,23 +215,34 @@ export class PlotHistory { return this.sessions.get(sessionId)?.plots.at(-1); } - navigatePrevious(): PlotFrame | null { - const session = this.sessions.get(this.activeSessionId); - if (!session || session.currentIndex <= 0) { - return null; - } - session.currentIndex--; - this.emitter.emit('change'); - return session.plots[session.currentIndex]; + navigatePrevious(sessionId = this.activeSessionId): PlotFrame | null { + return this.navigateTo(this.currentIndex(sessionId) - 2, sessionId); + } + + navigateNext(sessionId = this.activeSessionId): PlotFrame | null { + return this.navigateTo(this.currentIndex(sessionId), sessionId); } - navigateNext(): PlotFrame | null { - const session = this.sessions.get(this.activeSessionId); - if (!session || session.currentIndex >= session.plots.length - 1) { + navigateFirst(sessionId = this.activeSessionId): PlotFrame | null { + return this.navigateTo(0, sessionId); + } + + navigateLast(sessionId = this.activeSessionId): PlotFrame | null { + return this.navigateTo(this.count(sessionId) - 1, sessionId); + } + + private navigateTo(index: number, sessionId: string): PlotFrame | null { + const session = this.sessions.get(sessionId); + if ( + !session || + index < 0 || + index >= session.plots.length || + index === session.currentIndex + ) { return null; } - session.currentIndex++; - this.emitter.emit('change'); + session.currentIndex = index; + this.emitter.emit('change', sessionId); return session.plots[session.currentIndex]; } @@ -261,38 +250,38 @@ export class PlotHistory { return this.activeSessionId; } - currentIndex(): number { - const session = this.sessions.get(this.activeSessionId); + currentIndex(sessionId = this.activeSessionId): number { + const session = this.sessions.get(sessionId); return session ? session.currentIndex + 1 : 0; } - currentRIndex(): number | undefined { - const plot = this.currentPlot(); + currentRIndex(sessionId = this.activeSessionId): number | undefined { + const plot = this.currentPlot(sessionId); return plot?.rIndex; } - count(): number { - const session = this.sessions.get(this.activeSessionId); + count(sessionId = this.activeSessionId): number { + const session = this.sessions.get(sessionId); return session ? session.plots.length : 0; } - clear() { - const session = this.sessions.get(this.activeSessionId); + clear(sessionId = this.activeSessionId) { + const session = this.sessions.get(sessionId); if (session) { session.plots = []; session.currentIndex = -1; session.latestDeleted = false; } - this.emitter.emit('change'); + this.emitter.emit('change', sessionId); } - isLatestDeleted(): boolean { - const session = this.sessions.get(this.activeSessionId); + isLatestDeleted(sessionId = this.activeSessionId): boolean { + const session = this.sessions.get(sessionId); return session ? session.latestDeleted : false; } - removeCurrent(): PlotFrame | null { - const session = this.sessions.get(this.activeSessionId); + removeCurrent(sessionId = this.activeSessionId): PlotFrame | null { + const session = this.sessions.get(sessionId); if (!session || session.plots.length === 0) { return null; } @@ -303,13 +292,13 @@ export class PlotHistory { } if (session.plots.length === 0) { session.currentIndex = -1; - this.emitter.emit('change'); + this.emitter.emit('change', sessionId); return null; } if (session.currentIndex >= session.plots.length) { session.currentIndex = session.plots.length - 1; } - this.emitter.emit('change'); + this.emitter.emit('change', sessionId); return session.plots[session.currentIndex]; } } diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts index 4db1c7f23..8797e72f0 100644 --- a/src/plotViewer/jgdSocketServer.ts +++ b/src/plotViewer/jgdSocketServer.ts @@ -7,7 +7,7 @@ import { PlotHistory, PlotFrame } from './jgdPlotHistory'; const SERVER_NAME = 'jgd-vscode'; -export type ConnectionChangeListener = (count: number) => void; +export type ConnectionChangeListener = (count: number, sessionId: string) => void; export interface JgdMessage { type: string; @@ -40,11 +40,11 @@ interface RSession { const isWindows = process.platform === 'win32'; export interface JgdMeasureText { - (request: JgdMessage, dpi: number): Promise; + (request: JgdMessage, dpi: number, sessionId: string): Promise; } export interface JgdGetDimensions { - (): { width: number; height: number } | null; + (sessionId: string): { width: number; height: number } | null; } export class JgdSocketServer { @@ -99,12 +99,11 @@ export class JgdSocketServer { this.onDeviceClosedFn = fn; } - handleResize(w: number, h: number) { - const sessionId = this.history.getActiveSessionId(); - const idx = this.history.currentIndex(); - const total = this.history.count(); - if ((total > 0 && idx < total) || (total > 0 && this.history.isLatestDeleted())) { - const rIndex = this.history.currentRIndex(); + handleResize(w: number, h: number, sessionId: string) { + const idx = this.history.currentIndex(sessionId); + const total = this.history.count(sessionId); + if ((total > 0 && idx < total) || (total > 0 && this.history.isLatestDeleted(sessionId))) { + const rIndex = this.history.currentRIndex(sessionId); if (rIndex !== undefined) { this.broadcastResize(w, h, rIndex, sessionId); } @@ -118,10 +117,10 @@ export class JgdSocketServer { return !!session && !session.deviceClosed; } - private notifyConnectionChange() { + private notifyConnectionChange(sessionId: string) { const count = this.sessions.size; for (const l of this.connectionListeners) { - l(count); + l(count, sessionId); } } @@ -209,7 +208,7 @@ export class JgdSocketServer { dpi: 96, }; this.sessions.set(sessionId, session); - this.notifyConnectionChange(); + this.notifyConnectionChange(sessionId); socket.on('data', (data) => { session.buffer += data.toString(); @@ -231,7 +230,7 @@ export class JgdSocketServer { }; socket.write(JSON.stringify(welcome) + '\n'); - const dims = this.getDimensionsFn?.(); + const dims = this.getDimensionsFn?.(sessionId); if (dims) { session.lastResizeW = dims.width; session.lastResizeH = dims.height; @@ -251,13 +250,13 @@ export class JgdSocketServer { socket.on('close', () => { this.sessions.delete(sessionId); - this.notifyConnectionChange(); + this.notifyConnectionChange(sessionId); }); socket.on('error', (err) => { console.error(`jgd session ${sessionId} error:`, err.message); this.sessions.delete(sessionId); - this.notifyConnectionChange(); + this.notifyConnectionChange(sessionId); }); } @@ -310,7 +309,7 @@ export class JgdSocketServer { } this.history.addPlot(session.id, plot as PlotFrame); } else { - this.history.replaceLatest(session.id, plot as PlotFrame); + accepted = this.history.replaceLatest(session.id, plot as PlotFrame); } if (accepted) { if (typeof deviceLabel === 'string' && deviceLabel) { @@ -324,7 +323,7 @@ export class JgdSocketServer { case 'metrics_request': if (this.measureTextFn) { - void this.measureTextFn(msg, session.dpi) + void this.measureTextFn(msg, session.dpi, session.id) .then((response: unknown) => { const resp = JSON.stringify(response) + '\n'; session.socket.write(resp); @@ -357,14 +356,11 @@ export class JgdSocketServer { } private broadcastResize(w: number, h: number, plotIndex?: number, sessionId?: string) { + const session = sessionId ? this.sessions.get(sessionId) : undefined; + if (!session || session.deviceClosed) { + return; + } if (plotIndex !== undefined) { - if (!sessionId) { - return; - } - const session = this.sessions.get(sessionId); - if (!session || session.deviceClosed) { - return; - } session.lastResizeW = w; session.lastResizeH = h; session.lastResizeHadPlotIndex = true; @@ -373,20 +369,16 @@ export class JgdSocketServer { return; } - const data = JSON.stringify({ type: 'resize', width: w, height: h }) + '\n'; - for (const session of this.sessions.values()) { - if (session.id !== sessionId || session.deviceClosed) { - continue; - } - if (session.lastResizeW === w && session.lastResizeH === h) { - if (!session.lastResizeHadPlotIndex) { - continue; - } - } - session.lastResizeHadPlotIndex = false; - session.lastResizeW = w; - session.lastResizeH = h; - session.socket.write(data); + if ( + session.lastResizeW === w && + session.lastResizeH === h && + !session.lastResizeHadPlotIndex + ) { + return; } + session.lastResizeHadPlotIndex = false; + session.lastResizeW = w; + session.lastResizeH = h; + session.socket.write(JSON.stringify({ type: 'resize', width: w, height: h }) + '\n'); } } diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 135c9b733..677ca89d9 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -81,7 +81,6 @@ interface WebviewDeleteCurrent { type WebviewMessage = | { type: 'ready' } - | { type: 'selectSession'; sessionId: string } | WebviewMetricsResponse | WebviewMetricsWarmup | WebviewExportData @@ -93,7 +92,7 @@ type WebviewMessage = export class JgdManager { public server: JgdSocketServer; public history: PlotHistory; - private viewer: JgdViewer | null = null; + private viewers = new Map(); private extensionUri: vscode.Uri | null = null; private historyChangeDisposable: { dispose(): void } | null = null; private started = false; @@ -113,40 +112,40 @@ export class JgdManager { return; } this.started = true; - this.server.setOnFrame(() => { - const current = this.history.currentPlot(); + this.server.setOnFrame((sessionId) => { + const current = this.history.currentPlot(sessionId); if (current) { - this.getOrCreateViewer().showPlot(current); + this.getOrCreateViewer(sessionId).showPlot(current); } }); - this.server.setOnDeviceClosed((_sessionId) => { - this.viewer?.updateToolbar(); + this.server.setOnDeviceClosed((sessionId) => { + this.viewers.get(sessionId)?.updateToolbar(); }); - this.server.onConnectionChange(() => this.viewer?.updateToolbar()); + this.server.onConnectionChange((_count, sessionId) => + this.viewers.get(sessionId)?.updateToolbar(), + ); - this.server.setMeasureText((request: JgdMessage) => { - return this.getOrCreateViewer().measureText(request as unknown as MetricsRequest); + this.server.setMeasureText((request: JgdMessage, _dpi, sessionId) => { + return this.getOrCreateViewer(sessionId).measureText( + request as unknown as MetricsRequest, + ); }); - this.server.setGetDimensions(() => { - return this.viewer?.getPanelDimensions() ?? null; + this.server.setGetDimensions((sessionId) => { + return this.viewers.get(sessionId)?.getPanelDimensions() ?? null; }); this.server.start(); - this.historyChangeDisposable = this.history.onDidChange(() => { - this.viewer?.updateToolbar(); - void vscode.commands.executeCommand( - 'setContext', - 'r.plot.canGoBack', - this.history.currentIndex() > 1, - ); - void vscode.commands.executeCommand( - 'setContext', - 'r.plot.canGoForward', - this.history.currentIndex() < this.history.count(), - ); + this.historyChangeDisposable = this.history.onDidChange((sessionId) => { + if (sessionId) { + this.viewers.get(sessionId)?.updateToolbar(); + } else { + for (const viewer of this.viewers.values()) { + viewer.updateToolbar(); + } + } }); } @@ -154,43 +153,55 @@ export class JgdManager { this.started = false; this.server.stop(); this.historyChangeDisposable?.dispose(); - this.viewer?.dispose(); - this.viewer = null; + for (const viewer of this.viewers.values()) { + viewer.dispose(); + } + this.viewers.clear(); } - getViewer(): JgdViewer | null { - if (!this.viewer && this.history.sessionIds().length && this.extensionUri) { - return this.getOrCreateViewer(); + getViewers(): JgdViewer[] { + for (const sessionId of this.history.sessionIds()) { + this.getViewer(sessionId); } - return this.viewer; + return [...this.viewers.values()]; + } + + getViewer(sessionId = this.history.getActiveSessionId()): JgdViewer | null { + return sessionId && this.extensionUri ? this.getOrCreateViewer(sessionId) : null; } getEnvVars(): Record { return this.server.getEnvVars(); } - private getOrCreateViewer(): JgdViewer { - if (!this.viewer) { - this.viewer = new JgdViewer(this.extensionUri!, this.history, this.server); + private getOrCreateViewer(sessionId: string): JgdViewer { + let viewer = this.viewers.get(sessionId); + if (!viewer) { + viewer = new JgdViewer(this.extensionUri!, this.history, this.server, sessionId); + this.viewers.set(sessionId, viewer); } - return this.viewer; + return viewer; } } export class JgdViewer implements PlotViewer { - readonly id = 'jgd'; + readonly id: string; private panel: vscode.WebviewPanel | null = null; private pendingMetrics: Map void> = new Map(); private metricsIdCounter = 0; private metricsCache: Map = new Map(); private panelWidth = 800; private panelHeight = 600; + private lastToolbarState: string | undefined; constructor( private extensionUri: vscode.Uri, private history: PlotHistory, private server: JgdSocketServer, - ) {} + readonly sessionId: string, + ) { + this.id = `jgd:${sessionId}`; + } show(preserveFocus?: boolean): void { if (this.panel) { @@ -198,7 +209,7 @@ export class JgdViewer implements PlotViewer { } else { this.createPanel(preserveFocus); } - const plot = this.history.currentPlot(); + const plot = this.history.currentPlot(this.sessionId); if (plot) { this.sendPlotToWebview(plot); } @@ -221,7 +232,7 @@ export class JgdViewer implements PlotViewer { async handleCommand(command: string, ...args: unknown[]): Promise { switch (command) { case 'nextPlot': { - const plot = this.history.navigateNext(); + const plot = this.history.navigateNext(this.sessionId); if (plot) { this.sendPlotToWebview(plot); this.updateToolbar(); @@ -229,13 +240,13 @@ export class JgdViewer implements PlotViewer { plot.device.width !== this.panelWidth || plot.device.height !== this.panelHeight ) { - this.server.handleResize(this.panelWidth, this.panelHeight); + this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId); } } break; } case 'prevPlot': { - const plot = this.history.navigatePrevious(); + const plot = this.history.navigatePrevious(this.sessionId); if (plot) { this.sendPlotToWebview(plot); this.updateToolbar(); @@ -243,40 +254,26 @@ export class JgdViewer implements PlotViewer { plot.device.width !== this.panelWidth || plot.device.height !== this.panelHeight ) { - this.server.handleResize(this.panelWidth, this.panelHeight); + this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId); } } break; } case 'firstPlot': { - let plot = this.history.navigatePrevious(); - while (plot) { - const prev = this.history.navigatePrevious(); - if (!prev) { - break; - } - plot = prev; - } + const plot = this.history.navigateFirst(this.sessionId); if (plot) { this.sendPlotToWebview(plot); this.updateToolbar(); - this.server.handleResize(this.panelWidth, this.panelHeight); + this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId); } break; } case 'lastPlot': { - let plot = this.history.navigateNext(); - while (plot) { - const next = this.history.navigateNext(); - if (!next) { - break; - } - plot = next; - } + const plot = this.history.navigateLast(this.sessionId); if (plot) { this.sendPlotToWebview(plot); this.updateToolbar(); - this.server.handleResize(this.panelWidth, this.panelHeight); + this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId); } break; } @@ -290,7 +287,7 @@ export class JgdViewer implements PlotViewer { } case 'closePlot': case 'hidePlot': { - const plot = this.history.removeCurrent(); + const plot = this.history.removeCurrent(this.sessionId); if (plot) { this.sendPlotToWebview(plot); } else { @@ -300,7 +297,7 @@ export class JgdViewer implements PlotViewer { break; } case 'resetPlots': - this.history.clear(); + this.history.clear(this.sessionId); void this.panel?.webview.postMessage({ type: 'clear' }); this.updateToolbar(); break; @@ -325,26 +322,42 @@ export class JgdViewer implements PlotViewer { this.updateToolbar(); } - updateToolbar() { - const selected = this.history.getActiveSessionId(); - const sessions = this.history.sessionIds().map((id, index) => { - const deviceLabel = this.history.sessionLabel(id); - const process = deviceLabel && /^r-(\d+)-(\d+)$/.exec(deviceLabel); - const label = process - ? `R PID ${process[1]} ยท device ${process[2]}` - : deviceLabel || `JGD session ${index + 1}`; - return { id, label: `${label}${this.server.isConnected(id) ? '' : ' (disconnected)'}` }; - }); - if (this.panel) { - this.panel.title = `R Plot (${sessions.find((item) => item.id === selected)?.label ?? 'JGD'})`; + updateToolbar(force = false) { + if (!this.panel) { + return; + } + const deviceLabel = this.history.sessionLabel(this.sessionId); + const process = deviceLabel && /^r-(\d+)-(\d+)$/.exec(deviceLabel); + const label = process + ? `R PID ${process[1]} ยท device ${process[2]}` + : deviceLabel || `JGD ${this.sessionId}`; + const title = `R Plot (${label}${this.server.isConnected(this.sessionId) ? '' : ' (disconnected)'})`; + if (this.panel.title !== title) { + this.panel.title = title; } - void this.panel?.webview.postMessage({ + const message = { type: 'toolbar', - current: this.history.currentIndex(), - total: this.history.count(), - sessions, - selected, - }); + current: this.history.currentIndex(this.sessionId), + total: this.history.count(this.sessionId), + }; + const state = JSON.stringify(message); + if (!force && state === this.lastToolbarState) { + return; + } + this.lastToolbarState = state; + void this.panel.webview.postMessage(message); + if (this.panel.active) { + void vscode.commands.executeCommand( + 'setContext', + 'r.plot.canGoBack', + message.current > 1, + ); + void vscode.commands.executeCommand( + 'setContext', + 'r.plot.canGoForward', + message.current < message.total, + ); + } } getPanelDimensions(): { width: number; height: number } { @@ -490,24 +503,11 @@ export class JgdViewer implements PlotViewer { this.panel.webview.onDidReceiveMessage((raw: WebviewMessage) => { switch (raw.type) { case 'ready': { - const plot = this.history.currentPlot(); + const plot = this.history.currentPlot(this.sessionId); if (plot) { this.sendPlotToWebview(plot); } - this.updateToolbar(); - break; - } - case 'selectSession': { - if (this.history.selectSession(raw.sessionId)) { - const plot = this.history.currentPlot(); - if (plot) { - this.sendPlotToWebview(plot); - } else { - void this.panel?.webview.postMessage({ type: 'clear' }); - } - this.updateToolbar(); - this.server.handleResize(this.panelWidth, this.panelHeight); - } + this.updateToolbar(true); break; } case 'metrics_response': { @@ -543,7 +543,7 @@ export class JgdViewer implements PlotViewer { case 'resize': { this.panelWidth = raw.width; this.panelHeight = raw.height; - this.server.handleResize(raw.width, raw.height); + this.server.handleResize(raw.width, raw.height, this.sessionId); break; } case 'navigate': { @@ -568,7 +568,9 @@ export class JgdViewer implements PlotViewer { this.panel.onDidDispose(() => { this.panel = null; this.metricsCache.clear(); + this.lastToolbarState = undefined; }); + this.panel.onDidChangeViewState(() => this.updateToolbar(true)); this.panel.webview.html = this.getWebviewHtml(); } @@ -659,7 +661,6 @@ body { background: var(--vscode-editor-background); overflow: hidden; display: f color: var(--vscode-dropdown-foreground); background: var(--vscode-dropdown-background); border: 1px solid var(--vscode-dropdown-border); padding: 2px; min-width: 0; } -#session-select { max-width: 45%; } #plot-info { flex: 1; text-align: center; } #canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; } canvas { display: block; } @@ -667,7 +668,6 @@ canvas { display: block; }
- @@ -699,11 +699,6 @@ const metricsCanvas = document.getElementById('metrics-canvas'); const metricsCtx = metricsCanvas.getContext('2d'); let currentPlot = null; - -document.getElementById('session-select').addEventListener('change', (event) => { - vscode.postMessage({ type: 'selectSession', sessionId: event.target.value }); -}); - document.getElementById('btn-prev').addEventListener('click', () => { vscode.postMessage({ type: 'navigate', direction: 'previous' }); }); @@ -782,14 +777,6 @@ window.addEventListener('message', (event) => { ctx.clearRect(0, 0, canvas.width, canvas.height); break; case 'toolbar': - const select = document.getElementById('session-select'); - select.replaceChildren(...msg.sessions.map(session => { - const option = document.createElement('option'); - option.value = session.id; - option.textContent = session.label; - return option; - })); - select.value = msg.selected; document.getElementById('plot-info').textContent = msg.total > 0 ? msg.current + ' / ' + msg.total : 'No plots'; document.getElementById('btn-prev').disabled = msg.current <= 1; diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index e40737c48..7afbe133f 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -24,8 +24,6 @@ export class StandardPlotViewer implements PlotViewer { private panel: vscode.WebviewPanel | undefined; private viewWidth: number = 800; private viewHeight: number = 600; - private plotData: string | undefined; - private plotFormat: string | undefined; private panelGeneration = 0; private pendingPlotRequest: PendingPlotRequest | undefined; private plotRequest: Promise | undefined; @@ -156,14 +154,15 @@ export class StandardPlotViewer implements PlotViewer { return Promise.resolve(); } + const conf = config(); this.pendingPlotRequest = { panel, panelGeneration: this.panelGeneration, targetSession: this.owner ?? session.activeSession, width: this.viewWidth, height: this.viewHeight, - format: config().get('plot.format', 'svglite'), - devArgs: config().get>('plot.devArgs'), + format: conf.get('plot.format', 'svglite'), + devArgs: conf.get>('plot.devArgs'), }; return this.startPlotDrain(); } @@ -246,12 +245,10 @@ export class StandardPlotViewer implements PlotViewer { } if (this.isCurrentRequest(request)) { - this.plotData = response.data; - this.plotFormat = response.format || request.format; void request.panel.webview.postMessage({ type: 'update', - data: this.plotData, - format: this.plotFormat, + data: response.data, + format: response.format || request.format, }); } } diff --git a/src/test/suite/jgdPlotHistory.test.ts b/src/test/suite/jgdPlotHistory.test.ts index ddaf93984..f46dd3b72 100644 --- a/src/test/suite/jgdPlotHistory.test.ts +++ b/src/test/suite/jgdPlotHistory.test.ts @@ -65,6 +65,19 @@ suite('JGD PlotHistory', () => { assert.strictEqual(history.navigateNext(), null); assert.strictEqual(history.currentIndex(), 3); }); + + test('first and last navigation emit one change without visiting intermediate plots', () => { + let changes = 0; + history.onDidChange(() => changes++); + assert.strictEqual(history.navigateFirst()?.device.bg, 'A'); + assert.strictEqual(changes, 1); + assert.strictEqual(history.navigateFirst(), null); + assert.strictEqual(changes, 1); + assert.strictEqual(history.navigateLast()?.device.bg, 'C'); + assert.strictEqual(changes, 2); + assert.strictEqual(history.navigateLast(), null); + assert.strictEqual(changes, 2); + }); }); suite('removeCurrent', () => { @@ -327,29 +340,29 @@ suite('JGD PlotHistory', () => { }); suite('multi-session', () => { - test('selects an existing session without drawing and retains its navigation', () => { + test('navigates and removes plots in one session without changing another', () => { history.addPlot('s1', makePlot('A')); history.addPlot('s1', makePlot('B')); - history.navigatePrevious(); history.addPlot('s2', makePlot('C')); - assert.deepStrictEqual(history.sessionIds(), ['s1', 's2']); - assert.ok(history.selectSession('s1')); - assert.strictEqual(history.currentPlot()?.device.bg, 'A'); history.addPlot('s2', makePlot('D')); - assert.strictEqual(history.currentPlot()?.device.bg, 'A'); - assert.ok(history.selectSession('s2')); - assert.strictEqual(history.currentPlot()?.device.bg, 'D'); - assert.strictEqual(history.selectSession('missing'), false); - assert.strictEqual(history.getActiveSessionId(), 's2'); + assert.deepStrictEqual(history.sessionIds(), ['s1', 's2']); + assert.strictEqual(history.navigatePrevious('s1')?.device.bg, 'A'); + assert.strictEqual(history.currentPlot('s2')?.device.bg, 'D'); + history.removeCurrent('s1'); + assert.strictEqual(history.currentPlot('s1')?.device.bg, 'B'); + assert.strictEqual(history.currentIndex('s2'), 2); + history.clear('s1'); + assert.strictEqual(history.count('s1'), 0); + assert.strictEqual(history.count('s2'), 2); }); - test('a late resize reply cannot switch the selected session', () => { + test('a late resize reply changes only its own session', () => { history.addPlot('s1', { ...makePlot('A'), rIndex: 0 }); history.addPlot('s2', makePlot('B')); history.replaceAtIndex('s1', 0, makePlot('A resized')); assert.strictEqual(history.getActiveSessionId(), 's2'); - history.selectSession('s1'); - assert.strictEqual(history.currentPlot()?.device.bg, 'A resized'); + assert.strictEqual(history.currentPlot('s1')?.device.bg, 'A resized'); + assert.strictEqual(history.currentPlot('s2')?.device.bg, 'B'); }); test('tracks plots independently per session', () => { @@ -365,7 +378,7 @@ suite('JGD PlotHistory', () => { }); suite('events', () => { - test('restores selection and per-session navigation from reload state', () => { + test('restores each session navigation independently from reload state', () => { history.addPlot('a', makePlot('A1')); history.addPlot('a', makePlot('A2')); history.removeCurrent(); @@ -373,17 +386,16 @@ suite('JGD PlotHistory', () => { history.addPlot('b', makePlot('B1')); history.addPlot('b', makePlot('B2')); history.navigatePrevious(); - history.selectSession('a'); const restored = new PlotHistory(); restored.restore(JSON.parse(JSON.stringify(history.snapshot())) as unknown); - assert.strictEqual(restored.getActiveSessionId(), 'a'); + assert.strictEqual(restored.getActiveSessionId(), 'b'); assert.strictEqual(restored.sessionLabel('a'), 'r-123-1'); - assert.strictEqual(restored.isLatestDeleted(), true); + assert.strictEqual(restored.isLatestDeleted('a'), true); restored.addPlot('new-device', makePlot('new')); - assert.strictEqual(restored.getActiveSessionId(), 'a'); - restored.selectSession('b'); - assert.strictEqual(restored.currentPlot()?.device.bg, 'B1'); - assert.strictEqual(restored.navigateNext()?.device.bg, 'B2'); + assert.strictEqual(restored.getActiveSessionId(), 'new-device'); + assert.strictEqual(restored.currentPlot('a')?.device.bg, 'A1'); + assert.strictEqual(restored.currentPlot('b')?.device.bg, 'B1'); + assert.strictEqual(restored.navigateNext('b')?.device.bg, 'B2'); }); test('restoration ignores invalid frames and applies the current history limit', () => { diff --git a/src/test/suite/jgdSocketServer.test.ts b/src/test/suite/jgdSocketServer.test.ts index 7594fe724..a9bd243c4 100644 --- a/src/test/suite/jgdSocketServer.test.ts +++ b/src/test/suite/jgdSocketServer.test.ts @@ -164,7 +164,7 @@ suite('JGD SocketServer', () => { } suite('frame routing', () => { - test('resizes only the selected device, including its latest plot', async () => { + test('resizes only the requested device, regardless of the latest active session', async () => { const first = await connect(); const second = await connect(); first.send(makePlotMsg('A')); @@ -173,8 +173,7 @@ suite('JGD SocketServer', () => { const firstId = history .sessionIds() .find((id) => history.latestPlot(id)?.device.bg === 'A')!; - history.selectSession(firstId); - server.handleResize(1000, 700); + server.handleResize(1000, 700, firstId); assert.strictEqual((JSON.parse(await first.readLine()) as JgdMessage).type, 'resize'); second.send({ type: 'metrics_request', id: 42, kind: 'strWidth', str: 'barrier' }); assert.strictEqual( @@ -246,7 +245,7 @@ suite('JGD SocketServer', () => { assert.strictEqual(history.count(), 1); assert.strictEqual(history.currentPlot()?.device.bg, 'RED'); - server.handleResize(1000, 700); + server.handleResize(1000, 700, history.getActiveSessionId()); const resizeMsg = JSON.parse(await client.readLine()) as { type: string; plotIndex: number; @@ -318,11 +317,11 @@ suite('JGD SocketServer', () => { assert.strictEqual(measuredRequests.length, 1); }); - test('font measurement uses each device DPI without leaking across connections', async () => { + test('font measurement receives the owning session and DPI without leaking across connections', async () => { const first = await connect(); const second = await connect(); - server.setMeasureText((request, dpi) => - Promise.resolve({ type: 'metrics_response', id: request.id, dpi }), + server.setMeasureText((request, dpi, sessionId) => + Promise.resolve({ type: 'metrics_response', id: request.id, dpi, sessionId }), ); first.send({ type: 'frame', @@ -335,8 +334,18 @@ suite('JGD SocketServer', () => { }); first.send({ type: 'metrics_request', id: 1, kind: 'strWidth', str: 'hello' }); second.send({ type: 'metrics_request', id: 2, kind: 'strWidth', str: 'hello' }); - assert.strictEqual((JSON.parse(await first.readLine()) as { dpi: number }).dpi, 144); - assert.strictEqual((JSON.parse(await second.readLine()) as { dpi: number }).dpi, 96); + const firstResponse = JSON.parse(await first.readLine()) as { + dpi: number; + sessionId: string; + }; + const secondResponse = JSON.parse(await second.readLine()) as { + dpi: number; + sessionId: string; + }; + assert.strictEqual(firstResponse.dpi, 144); + assert.strictEqual(secondResponse.dpi, 96); + assert.strictEqual(firstResponse.sessionId, history.getActiveSessionId()); + assert.notStrictEqual(firstResponse.sessionId, secondResponse.sessionId); }); }); }); diff --git a/src/test/suite/plotCommands.test.ts b/src/test/suite/plotCommands.test.ts index ecfc42c6c..2c4d71958 100644 --- a/src/test/suite/plotCommands.test.ts +++ b/src/test/suite/plotCommands.test.ts @@ -82,7 +82,7 @@ suite('Contextual plot commands', () => { show: showJgdViewer, handleCommand: sandbox.stub(), } as unknown as JgdViewer; - sandbox.stub(manager.jgdManager, 'getViewer').returns(jgdViewer); + sandbox.stub(manager.jgdManager, 'getViewers').returns([jgdViewer]); const viewer = sandbox.createStubInstance(HttpgdViewer); Object.defineProperty(viewer, 'host', { value: 'localhost:1234' }); viewer.getPanelPath.returns('/plot-viewer'); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index e7b825020..59c2f0d1d 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -6,6 +6,8 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { CommonPlotManager } from '../../plotViewer'; import { JgdManager } from '../../plotViewer/jgdViewer'; +import type { JgdMessage } from '../../plotViewer/jgdSocketServer'; +import type { PlotFrame } from '../../plotViewer/jgdPlotHistory'; import { HttpgdClient } from '../../plotViewer/httpgdClient'; import { extensionContext } from '../../extension'; import * as session from '../../session'; @@ -65,6 +67,7 @@ suite('Standalone plot restoration', () => { get: (_key: string, fallback: unknown) => fallback, } as vscode.WorkspaceConfiguration); sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((viewType, title) => { + const panelId = panels.length; const disposed = new vscode.EventEmitter(); let receive = (_message: unknown): unknown => undefined; const post = sandbox.stub().resolves(true); @@ -79,7 +82,7 @@ suite('Standalone plot restoration', () => { asWebviewUri: (uri: vscode.Uri) => uri.with({ scheme: 'https', - authority: `panel${panels.length}.vscode-resource.vscode-cdn.net`, + authority: `panel${panelId}.vscode-resource.vscode-cdn.net`, }), onDidReceiveMessage: (callback: typeof receive) => { receive = callback; @@ -149,6 +152,181 @@ suite('Standalone plot restoration', () => { data: 'fresh-from-R', format: 'svg', }); + request.resetHistory(); + resolve.resetHistory(); + await callbacks.get('r.plot.showViewers')!(); + sinon.assert.notCalled(request); + sinon.assert.calledOnce(resolve); + assert.strictEqual(panels.length, 1); + const reattached = { + sessionId: owner.sessionId, + host: owner.host, + pid: owner.pid, + rVer: owner.rVer, + processExited: false, + } as session.Session; + resolve.returns(reattached); + panels[0].panel.dispose(); + await callbacks.get('r.plot.showViewers')!(); + await panels[1].receive({ type: 'ready' }); + sinon.assert.calledOnce(request); + assert.strictEqual(request.lastCall.args[1], reattached); + }); + + function startJgd(value: CommonPlotManager) { + (JgdManager.prototype.start as sinon.SinonStub).callThrough(); + sandbox.stub(value.jgdManager.server, 'start'); + const onFrame = sandbox.stub(value.jgdManager.server, 'setOnFrame'); + value.jgdManager.start(); + return onFrame.firstCall.args[0] as (id: string, message: JgdMessage) => void; + } + + function frame(sessionId: string, bg = 'white'): PlotFrame { + return { + version: 1, + sessionId, + device: { width: 400, height: 300, dpi: 96, bg }, + ops: [], + }; + } + + test('JGD frames update only their own viewer without redundant toolbar messages', async () => { + const value = manager(); + const deliver = startJgd(value); + const history = value.jgdManager.history; + history.addPlot('a', frame('a')); + history.setSessionLabel('a', 'r-123-1'); + deliver('a', { type: 'frame' }); + await panels[0].receive({ type: 'ready' }); + const post = panels[0].post; + post.resetHistory(); + for (let index = 0; index < 100; index++) { + history.addPlot('b', frame('b')); + deliver('b', { type: 'frame' }); + } + sinon.assert.notCalled(post); + assert.strictEqual(panels.length, 2); + history.setSessionLabel('b', 'r-456-1'); + await panels[1].receive({ type: 'ready' }); + assert.ok(panels[0].panel.title.includes('123')); + assert.ok(panels[1].panel.title.includes('456')); + assert.ok(!panels[0].panel.webview.html.includes('session-select')); + panels[1].post.resetHistory(); + + for (let index = 0; index < 100; index++) { + history.appendOps('a', { ...frame('a'), ops: [{ op: 'line', index }] }); + deliver('a', { type: 'frame' }); + } + assert.strictEqual(post.withArgs(sinon.match({ type: 'render' })).callCount, 100); + sinon.assert.notCalled(post.withArgs(sinon.match({ type: 'toolbar' }))); + sinon.assert.notCalled(panels[1].post); + assert.strictEqual(history.currentPlot('a')?.ops.length, 100); + await panels[0].receive({ type: 'ready' }); + sinon.assert.calledOnce(post.withArgs(sinon.match({ type: 'toolbar' }))); + }); + + test('JGD tabs independently navigate, resize, measure text, remove plots and reopen', async () => { + const value = manager(); + const metrics = sandbox.spy(value.jgdManager.server, 'setMeasureText'); + const dimensions = sandbox.spy(value.jgdManager.server, 'setGetDimensions'); + const deliver = startJgd(value); + const resize = sandbox.stub(value.jgdManager.server, 'handleResize'); + const history = value.jgdManager.history; + for (const id of ['a', 'b']) { + history.addPlot(id, frame(id, `${id}1`)); + history.addPlot(id, frame(id, `${id}2`)); + deliver(id, { type: 'frame' }); + await panels.at(-1)!.receive({ type: 'ready' }); + } + const a = value.jgdManager.getViewer('a')!; + const b = value.jgdManager.getViewer('b')!; + assert.notStrictEqual(a.id, b.id); + Object.assign(panels[0].panel, { active: false }); + assert.strictEqual(value.activeViewer, b); + await callbacks.get('r.plot.prevPlot')!( + vscode.Uri.from({ scheme: 'webview', path: a.getPanelPath()! }), + ); + assert.strictEqual(history.currentIndex('a'), 1); + assert.strictEqual(history.currentIndex('b'), 2); + sinon.assert.calledWithExactly(resize, 800, 600, 'a'); + await panels[0].receive({ type: 'resize', width: 700, height: 500 }); + await panels[1].receive({ type: 'resize', width: 900, height: 650 }); + sinon.assert.calledWithExactly(resize, 700, 500, 'a'); + sinon.assert.calledWithExactly(resize, 900, 650, 'b'); + const getDimensions = dimensions.firstCall.args[0]; + assert.deepStrictEqual(getDimensions('a'), { width: 700, height: 500 }); + assert.deepStrictEqual(getDimensions('b'), { width: 900, height: 650 }); + const measure = metrics.firstCall.args[0]; + panels[0].post.resetHistory(); + panels[1].post.resetHistory(); + const measured = measure( + { type: 'metrics_request', id: 7, kind: 'strWidth', str: 'x' }, + 96, + 'a', + ); + const request = panels[0].post.lastCall.args[0] as { id: number }; + sinon.assert.notCalled(panels[1].post); + await panels[0].receive({ + type: 'metrics_response', + id: request.id, + originalId: 7, + width: 42, + ascent: 10, + descent: 3, + }); + assert.deepStrictEqual(await measured, { + type: 'metrics_response', + id: 7, + width: 42, + ascent: 10, + descent: 3, + }); + await panels[0].receive({ type: 'deleteCurrent' }); + assert.strictEqual(history.count('a'), 1); + assert.strictEqual(history.count('b'), 2); + await b.handleCommand('resetPlots'); + assert.strictEqual(history.count('a'), 1); + assert.strictEqual(history.count('b'), 0); + panels[0].panel.dispose(); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 3, 'only the closed tab is recreated'); + await panels[2].receive({ type: 'ready' }); + sinon.assert.calledWith( + panels[2].post, + sinon.match({ type: 'render', plot: sinon.match({ sessionId: 'a' }) }), + ); + }); + + test('JGD restores separate tabs with each session history position after reload', async () => { + const first = manager(); + const history = first.jgdManager.history; + history.addPlot('a', frame('a', 'a1')); + history.addPlot('a', frame('a', 'a2')); + history.addPlot('b', frame('b', 'b1')); + history.addPlot('b', frame('b', 'b2')); + history.navigatePrevious('a'); + await first.dispose(); + const restored = manager(); + assert.strictEqual(panels.length, 0); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 2); + for (const [index, id, bg] of [ + [0, 'a', 'a1'], + [1, 'b', 'b2'], + ] as const) { + await panels[index].receive({ type: 'ready' }); + sinon.assert.calledWith( + panels[index].post, + sinon.match({ + type: 'render', + plot: sinon.match({ sessionId: id, device: sinon.match({ bg }) }), + }), + ); + assert.ok(panels[index].panel.title.includes('disconnected')); + } + await restored.jgdManager.getViewer('b')!.handleCommand('firstPlot'); + assert.strictEqual(restored.jgdManager.history.currentIndex('a'), 1); + assert.strictEqual(restored.jgdManager.history.currentIndex('b'), 1); }); test('standard restoration waits for the owning session instead of showing an archived image', async () => { @@ -289,6 +467,25 @@ suite('Standalone plot restoration', () => { assert.strictEqual(live.token, 'new'); }); + test('httpgd restoration keeps credentials from an attachment arriving at its async boundary', async () => { + const first = manager(); + await first.showHttpgdPlot('http://localhost:1234/live?token=old'); + await first.dispose(); + const restored = manager(); + const show = restored.httpgdManager.showViewer.bind(restored.httpgdManager); + sandbox + .stub(restored.httpgdManager, 'showViewer') + .callsFake((url, source, onlyIfMissing) => { + if (new URL(url).searchParams.get('token') === 'old') { + void show('http://localhost:1234/live?token=new'); + } + return show(url, source, onlyIfMissing); + }); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(restored.httpgdManager.viewers.length, 1); + assert.strictEqual(restored.httpgdManager.viewers[0].token, 'new'); + }); + test('a real JGD webview receives and exports restored frames after its ready handshake', async () => { const first = manager(); first.jgdManager.history.addPlot('old-device', { From 73fb9d3e77d63959edceafd3bebd0a70918f4bcd Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:06:22 +1100 Subject: [PATCH 03/32] feat(plot): add shared session indicators to plot viewers Reuse the list/data viewer indicator in standard, httpgd, and JGD viewers, showing R : on hover or keyboard focus. Preserve session identity across reloads, handle late attachments, and update indicators when the owning R process exits. --- src/plotViewer/httpgdViewer.ts | 44 +++- src/plotViewer/jgdPlotHistory.ts | 14 ++ src/plotViewer/jgdViewer.ts | 54 ++++- src/plotViewer/standardViewer.ts | 29 ++- src/plotViewer/webview/index.ejs | 3 + src/plotViewer/webview/index.ts | 4 +- src/plotViewer/webview/style.css | 11 + src/session.ts | 18 ++ src/test/suite/plotRestoration.test.ts | 209 ++++++++++++++++-- .../suite/sessionConnectionLifecycle.test.ts | 25 +++ src/test/suite/standardPlotViewer.test.ts | 23 +- 11 files changed, 406 insertions(+), 28 deletions(-) diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index e48658d11..0c868425b 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -5,7 +5,14 @@ import * as path from 'path'; import * as fs from 'fs'; import ejs from 'ejs'; import { randomUUID } from 'crypto'; -import { formatSessionLabel, isViewerSessionSource, ViewerSessionSource } from '../viewerSession'; +import { + formatSessionLabel, + isViewerSessionSource, + viewerSessionStyle, + ViewerSessionContext, + ViewerSessionSource, +} from '../viewerSession'; +import { getViewerSessionContext } from '../session'; import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util'; import { extensionContext } from '../extension'; @@ -132,6 +139,7 @@ export class HttpgdManager { if (ind >= 0) { const viewer = this.viewers.splice(ind, 1)[0]; if (viewer.token === token) { + viewer.bindSession(source); this.viewers.unshift(viewer); viewer.show(); return; @@ -191,6 +199,9 @@ export class HttpgdManager { } interface EjsData { + sessionHtml: string; + sessionStyle: string; + codicons: string; overwriteStyles: boolean; previewPlotLayout: PreviewPlotLayout; activePlot?: HttpgdPlotId; @@ -218,7 +229,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { webviewPanel?: vscode.WebviewPanel; readonly api: HttpgdClient; private disposed = false; - private readonly source?: ViewerSessionSource; + private source?: ViewerSessionSource; + private sessionContext?: ViewerSessionContext; get connection(): HttpgdConnection { const url = new URL(this.origin); @@ -276,7 +288,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.origin = url.origin; this.id = this.host; this.token = options.token; - this.source = options.source; + this.bindSession(options.source); this.parent = options.parent; this.api = new HttpgdClient(this.origin, this.token); @@ -364,6 +376,18 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } } + public bindSession(source?: ViewerSessionSource): void { + if (!source || this.source) { + return; + } + this.source = { ...source }; + this.sessionContext = getViewerSessionContext(source.sessionId, source); + if (this.webviewPanel) { + this.sessionContext?.attach(this.webviewPanel); + this.refreshHtml(); + } + } + public show(preserveFocus?: boolean): void { preserveFocus ??= this.showOptions.preserveFocus; if (!this.webviewPanel) { @@ -724,6 +748,19 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { overwriteCssPath = asWebViewPath('styleOverwrites.css'); } return { + sessionHtml: this.sessionContext?.getHtml() ?? '', + sessionStyle: viewerSessionStyle, + codicons: + this.webviewPanel?.webview + .asWebviewUri( + vscode.Uri.joinPath( + extensionContext.extensionUri, + 'dist', + 'resources', + 'codicon.css', + ), + ) + .toString() ?? '', overwriteStyles: this.stripStyles, previewPlotLayout: this.previewPlotLayout, plots: this.plots, @@ -754,6 +791,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { webviewPanel.webview.onDidReceiveMessage((e: OutMessage) => { this.handleWebviewMessage(e); }); + this.sessionContext?.attach(webviewPanel); return webviewPanel; } diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts index 81b3f957e..3d6ed9c5e 100644 --- a/src/plotViewer/jgdPlotHistory.ts +++ b/src/plotViewer/jgdPlotHistory.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events'; +import { isViewerSessionSource, ViewerSessionSource } from '../viewerSession'; export interface PlotFrame { version: number; @@ -19,6 +20,7 @@ interface SessionHistory { currentIndex: number; latestDeleted: boolean; label?: string; + source?: ViewerSessionSource; } export interface PlotHistoryState { @@ -44,6 +46,17 @@ export class PlotHistory { return this.sessions.get(sessionId)?.label; } + sessionSource(sessionId: string): ViewerSessionSource | undefined { + return this.sessions.get(sessionId)?.source; + } + + setSessionSource(sessionId: string, source: ViewerSessionSource): void { + const history = this.sessions.get(sessionId); + if (history && !history.source) { + history.source = { ...source }; + } + } + setSessionLabel(sessionId: string, label: string): void { label = label.slice(0, 256); const history = this.sessions.get(sessionId); @@ -89,6 +102,7 @@ export class PlotHistory { currentIndex: entry.plots.length ? Math.max(0, entry.currentIndex - removed) : -1, latestDeleted: entry.latestDeleted, label: typeof entry.label === 'string' ? entry.label.slice(0, 256) : undefined, + source: isViewerSessionSource(entry.source) ? { ...entry.source } : undefined, }); } this.activeSessionId = diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 677ca89d9..0015daab7 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -3,6 +3,12 @@ import { PlotViewer, plotPanelPath } from './types'; import { PlotHistory, PlotFrame } from './jgdPlotHistory'; import { JgdSocketServer, JgdMessage } from './jgdSocketServer'; import { config, UriIcon } from '../util'; +import { + getViewerSessionContext, + onDidChangeSessionConnection, + resolveJgdSessionSource, +} from '../session'; +import { getViewerSessionScript, viewerSessionStyle, ViewerSessionContext } from '../viewerSession'; interface MetricsRequest { id: number; @@ -95,6 +101,7 @@ export class JgdManager { private viewers = new Map(); private extensionUri: vscode.Uri | null = null; private historyChangeDisposable: { dispose(): void } | null = null; + private sessionConnectionDisposable: vscode.Disposable | undefined; private started = false; constructor() { @@ -105,6 +112,15 @@ export class JgdManager { initialize(extensionUri: vscode.Uri) { this.extensionUri = extensionUri; + this.sessionConnectionDisposable = onDidChangeSessionConnection((event) => { + if (event.kind === 'connected') { + for (const [id, viewer] of this.viewers) { + if (this.server.isConnected(id) && !this.history.sessionSource(id)) { + viewer.updateToolbar(); + } + } + } + }); } start() { @@ -153,6 +169,7 @@ export class JgdManager { this.started = false; this.server.stop(); this.historyChangeDisposable?.dispose(); + this.sessionConnectionDisposable?.dispose(); for (const viewer of this.viewers.values()) { viewer.dispose(); } @@ -193,6 +210,7 @@ export class JgdViewer implements PlotViewer { private panelWidth = 800; private panelHeight = 600; private lastToolbarState: string | undefined; + private sessionContext: ViewerSessionContext | undefined; constructor( private extensionUri: vscode.Uri, @@ -323,6 +341,11 @@ export class JgdViewer implements PlotViewer { } updateToolbar(force = false) { + if (this.resolveSessionContext() && this.panel) { + this.sessionContext?.attach(this.panel); + this.panel.webview.html = this.getWebviewHtml(); + this.lastToolbarState = undefined; + } if (!this.panel) { return; } @@ -364,6 +387,25 @@ export class JgdViewer implements PlotViewer { return { width: this.panelWidth, height: this.panelHeight }; } + private resolveSessionContext(): boolean { + if (this.sessionContext) { + return false; + } + const saved = this.history.sessionSource(this.sessionId); + // Restored, disconnected devices must never bind to a reused PID. + const source = + saved ?? + (this.server.isConnected(this.sessionId) + ? resolveJgdSessionSource(this.history.sessionLabel(this.sessionId) ?? '') + : undefined); + if (!source) { + return false; + } + this.sessionContext = getViewerSessionContext(source.sessionId, source); + this.history.setSessionSource(this.sessionId, source); + return Boolean(this.sessionContext); + } + private canonicalizeFamily(family: string | undefined): string { if (!family || family === '' || family === 'sans') { return 'sans-serif'; @@ -479,6 +521,7 @@ export class JgdViewer implements PlotViewer { } private createPanel(preserveFocus = false) { + this.resolveSessionContext(); const viewColumnConfig = config().get>('session.viewers.viewColumn') ?? {}; const plotColumn = viewColumnConfig['plot'] ?? 'Two'; @@ -571,6 +614,7 @@ export class JgdViewer implements PlotViewer { this.lastToolbarState = undefined; }); this.panel.onDidChangeViewState(() => this.updateToolbar(true)); + this.sessionContext?.attach(this.panel); this.panel.webview.html = this.getWebviewHtml(); } @@ -635,12 +679,17 @@ export class JgdViewer implements PlotViewer { } private getWebviewHtml(): string { + const webview = this.panel!.webview; + const codicons = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'dist', 'resources', 'codicon.css'), + ); return ` - + + @@ -677,6 +727,7 @@ canvas { display: block; } + ${this.sessionContext?.getHtml() ?? ''}
@@ -693,6 +744,7 @@ ${getRendererScript()} function getRendererScript(): string { return ` const vscode = acquireVsCodeApi(); +${getViewerSessionScript()} const canvas = document.getElementById('plot-canvas'); const ctx = canvas.getContext('2d'); const metricsCanvas = document.getElementById('metrics-canvas'); diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index 7afbe133f..8d1dc901a 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -2,7 +2,14 @@ import * as vscode from 'vscode'; import { asViewColumn, config, UriIcon } from '../util'; import * as session from '../session'; import { PlotViewer, plotPanelPath } from './types'; -import { formatSessionLabel, ViewerSessionSource } from '../viewerSession'; +import { + formatSessionLabel, + getViewerSessionScript, + viewerSessionStyle, + ViewerSessionContext, + ViewerSessionSource, +} from '../viewerSession'; +import { extensionContext } from '../extension'; interface PlotResponse { data: string; @@ -29,6 +36,7 @@ export class StandardPlotViewer implements PlotViewer { private plotRequest: Promise | undefined; private source: ViewerSessionSource | undefined; + private sessionContext: ViewerSessionContext | undefined; get sessionSource(): ViewerSessionSource | undefined { return this.source; @@ -145,6 +153,10 @@ export class StandardPlotViewer implements PlotViewer { }); // Install the receiver before starting the document so its first ready // message cannot be missed, even without a visible layout/resize event. + this.sessionContext = this.source + ? session.getViewerSessionContext(this.source.sessionId, this.source) + : undefined; + this.sessionContext?.attach(panel); panel.webview.html = this.getHtml(); } @@ -270,12 +282,16 @@ export class StandardPlotViewer implements PlotViewer { } private getHtml() { + const codicons = this.panel!.webview.asWebviewUri( + vscode.Uri.joinPath(extensionContext.extensionUri, 'dist', 'resources', 'codicon.css'), + ); return ` + +
${this.sessionContext?.getHtml() ?? ''}
`, + ); await Promise.race([ready, timeout]); + const details = await Promise.race([indicator, timeout]); + assert.strictEqual(details.label, 'R 4.6.0: 12140'); + assert.strictEqual( + details.fontLoaded, + true, + 'the shared information icon font loads under the JGD CSP', + ); await panel!.webview.postMessage({ type: 'export', format: 'svg', diff --git a/src/test/suite/sessionConnectionLifecycle.test.ts b/src/test/suite/sessionConnectionLifecycle.test.ts index f95c2a19e..91ac289cf 100644 --- a/src/test/suite/sessionConnectionLifecycle.test.ts +++ b/src/test/suite/sessionConnectionLifecycle.test.ts @@ -57,6 +57,31 @@ suite('Session Connection Lifecycle API', () => { sandbox.restore(); }); + test('JGD device labels resolve only to a unique connected local R process', async () => { + const first = await connections.attach('jgd-local', 55101, os.hostname(), { + attachmentId: 'jgd-test', + autoActivate: false, + }); + await connections.attach('jgd-remote', 55101, 'external-host', { + attachmentId: 'jgd-test', + autoActivate: false, + }); + const source = session.resolveJgdSessionSource('r-55101-1'); + assert.strictEqual(source?.sessionId, 'jgd-local'); + assert.strictEqual(source?.pid, '55101'); + assert.strictEqual(source?.rVer, '4.4.0'); + assert.strictEqual(session.resolveJgdSessionSource('unknown-device'), undefined); + const duplicate = await connections.attach('jgd-ambiguous', 55101, os.hostname(), { + attachmentId: 'jgd-test', + autoActivate: false, + }); + assert.strictEqual(session.resolveJgdSessionSource('r-55101-1'), undefined); + await session.cleanupSession('jgd-ambiguous', duplicate); + assert.strictEqual(session.resolveJgdSessionSource('r-55101-1')?.sessionId, 'jgd-local'); + await session.cleanupSession('jgd-local', first); + assert.strictEqual(session.resolveJgdSessionSource('r-55101-1'), undefined); + }); + test('background external attach remains unselected and exposes its current reference', async () => { const events: RSessionConnectionChange[] = []; const subscription = session.onDidChangeSessionConnection((event) => events.push(event)); diff --git a/src/test/suite/standardPlotViewer.test.ts b/src/test/suite/standardPlotViewer.test.ts index 0a8978491..c82f28674 100644 --- a/src/test/suite/standardPlotViewer.test.ts +++ b/src/test/suite/standardPlotViewer.test.ts @@ -51,9 +51,10 @@ suite('Standard plot viewer request coordination', () => { } as unknown as vscode.WorkspaceConfiguration); sandbox.stub(vscode.window, 'createWebviewPanel').callsFake(() => { const disposed = new vscode.EventEmitter(); - let receiveMessage: - | ((message: { type: string; width?: number; height?: number }) => Promise) - | undefined; + const listeners = new Set<(message: unknown) => unknown>(); + const receiveMessage = async (message: unknown) => { + await Promise.all([...listeners].map((listener) => listener(message))); + }; const postMessage = sandbox.stub().resolves(true); const panel = { title: 'R Plot', @@ -62,8 +63,12 @@ suite('Standard plot viewer request coordination', () => { webview: { html: '', postMessage, - onDidReceiveMessage: (listener: typeof receiveMessage) => { - receiveMessage = listener; + asWebviewUri: (uri: vscode.Uri) => uri, + onDidReceiveMessage: (listener: (message: unknown) => unknown) => { + listeners.add(listener); + return new vscode.Disposable(() => { + listeners.delete(listener); + }); }, }, onDidDispose: disposed.event, @@ -220,7 +225,13 @@ suite('Standard plot viewer request coordination', () => { }); test('a bound viewer keeps requesting its owner after the active session changes', async () => { - const owner = { sessionId: 'A', pid: '123', rVer: '4.5.0' } as session.Session; + const owner = { + sessionId: 'A', + host: 'external-host', + pid: '123', + rVer: '4.5.0', + processExited: false, + } as session.Session; viewer = new StandardPlotViewer(owner); activeSession.value(owner); const response = deferred(); From 773159f2ff678f1d1640bb94fdb4c816e262a202 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:11:06 +1100 Subject: [PATCH 04/32] fix: removed session label from viewer Tab --- src/plotViewer/httpgdViewer.ts | 5 +---- src/plotViewer/jgdViewer.ts | 11 +---------- src/plotViewer/standardViewer.ts | 5 +---- src/test/suite/plotRestoration.test.ts | 7 ++++--- 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 0c868425b..750012b85 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -6,7 +6,6 @@ import * as fs from 'fs'; import ejs from 'ejs'; import { randomUUID } from 'crypto'; import { - formatSessionLabel, isViewerSessionSource, viewerSessionStyle, ViewerSessionContext, @@ -777,9 +776,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { protected makeNewWebview(showOptions?: ShowOptions): vscode.WebviewPanel { const webviewPanel = vscode.window.createWebviewPanel( 'RPlot', - this.source - ? `R Plot (${formatSessionLabel(this.source.rVer, this.source.pid)})` - : 'R Plot', + 'R Plot', showOptions || this.showOptions, this.webviewOptions, ); diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 0015daab7..53f285358 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -349,15 +349,6 @@ export class JgdViewer implements PlotViewer { if (!this.panel) { return; } - const deviceLabel = this.history.sessionLabel(this.sessionId); - const process = deviceLabel && /^r-(\d+)-(\d+)$/.exec(deviceLabel); - const label = process - ? `R PID ${process[1]} ยท device ${process[2]}` - : deviceLabel || `JGD ${this.sessionId}`; - const title = `R Plot (${label}${this.server.isConnected(this.sessionId) ? '' : ' (disconnected)'})`; - if (this.panel.title !== title) { - this.panel.title = title; - } const message = { type: 'toolbar', current: this.history.currentIndex(this.sessionId), @@ -534,7 +525,7 @@ export class JgdViewer implements PlotViewer { this.panel = vscode.window.createWebviewPanel( 'jgd.plotPane', - 'R Plot (JGD)', + 'R Plot', { viewColumn, preserveFocus }, { enableScripts: true, diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index 8d1dc901a..b6c3177d1 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -3,7 +3,6 @@ import { asViewColumn, config, UriIcon } from '../util'; import * as session from '../session'; import { PlotViewer, plotPanelPath } from './types'; import { - formatSessionLabel, getViewerSessionScript, viewerSessionStyle, ViewerSessionContext, @@ -110,9 +109,7 @@ export class StandardPlotViewer implements PlotViewer { private createPanel(viewColumn: vscode.ViewColumn) { const panel = vscode.window.createWebviewPanel( 'r.standardPlot', - this.source - ? `R Plot (${formatSessionLabel(this.source.rVer, this.source.pid)})` - : 'R Plot', + 'R Plot', { viewColumn, preserveFocus: true, diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 0ae508ff5..3501910c8 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -162,6 +162,7 @@ suite('Standalone plot restoration', () => { ); assert.strictEqual(owned.length, 3, 'each backend has its own session indicator'); for (const item of owned) { + assert.strictEqual(item.panel.title, 'R Plot'); assert.ok(item.panel.webview.html.includes('class="viewer-session-tooltip"')); assert.ok(item.panel.webview.html.includes('codicon.css')); item.post.resetHistory(); @@ -357,8 +358,8 @@ suite('Standalone plot restoration', () => { assert.strictEqual(panels.length, 2); history.setSessionLabel('b', 'r-456-1'); await panels[1].receive({ type: 'ready' }); - assert.ok(panels[0].panel.title.includes('123')); - assert.ok(panels[1].panel.title.includes('456')); + assert.strictEqual(panels[0].panel.title, 'R Plot'); + assert.strictEqual(panels[1].panel.title, 'R Plot'); assert.ok(!panels[0].panel.webview.html.includes('session-select')); panels[1].post.resetHistory(); @@ -471,7 +472,7 @@ suite('Standalone plot restoration', () => { plot: sinon.match({ sessionId: id, device: sinon.match({ bg }) }), }), ); - assert.ok(panels[index].panel.title.includes('disconnected')); + assert.strictEqual(panels[index].panel.title, 'R Plot'); } await restored.jgdManager.getViewer('b')!.handleCommand('firstPlot'); assert.strictEqual(restored.jgdManager.history.currentIndex('a'), 1); From de357318f11c484b9959be2a707dd8b6c953b4a0 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:27:41 +1100 Subject: [PATCH 05/32] fix(plot): restore viewers for the active R session after reload - Persist restoration state during normal operation - Preserve live R plot state and reconnect JGD devices with stable identities - Limit Show Viewer to the active session and prevent duplicate or empty tabs - Keep closed viewers closed during reattachment across all plot engines - Bump sess to 3.4.0.9001 - Add session isolation and live reconnection regression tests --- sess/DESCRIPTION | 2 +- sess/R/hooks.R | 83 +++-- sess/R/runtime.R | 23 +- sess/R/server.R | 8 +- sess/inst/tinytest/test-attachment-metadata.R | 2 +- sess/inst/tinytest/test-plot-notification.R | 3 + sess/inst/tinytest/test-reconnect.R | 2 +- sess/man/runtime_stop.Rd | 6 +- src/extension.ts | 6 +- src/plotViewer/httpgdTypes.d.ts | 1 + src/plotViewer/httpgdViewer.ts | 80 ++++- src/plotViewer/index.ts | 109 +++--- src/plotViewer/jgdPlotHistory.ts | 7 + src/plotViewer/jgdSocketServer.ts | 59 +++- src/plotViewer/jgdViewer.ts | 125 +++++-- src/plotViewer/types.ts | 2 +- src/session.ts | 6 +- src/test/node/plotReconnect.test.ts | 266 +++++++++++++++ src/test/suite/jgdSocketServer.test.ts | 75 +++++ src/test/suite/plotCommands.test.ts | 18 +- src/test/suite/plotRestoration.test.ts | 310 ++++++++++++++++-- 21 files changed, 1047 insertions(+), 146 deletions(-) create mode 100644 src/test/node/plotReconnect.test.ts diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index 20ab22572..1e5ddc1b8 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -1,7 +1,7 @@ Package: sess Type: Package Title: High-Performance IPC Bridge for R Sessions -Version: 3.4.0 +Version: 3.4.0.9001 Authors@R: c( person(given = "Randy", family = "Lai", diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 7d3e65381..79239a5a9 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -50,7 +50,11 @@ runtime_start <- function(use_rstudioapi = TRUE, .sess_env$runtime_start_phase <- "previous-runtime-stop" runtime_stop() } + if (isTRUE(state$preserved_plots) && !identical(state$plot_backend, plot_backend)) { + runtime_stop() + } state <- .runtime_state() + state$plot_backend <- plot_backend state$active <- TRUE completed <- FALSE on.exit({ @@ -227,43 +231,69 @@ runtime_start <- function(use_rstudioapi = TRUE, package_version(getNamespaceVersion("httpgd")) >= "2.0.1" selected_backend <- .select_plot_backend(plot_backend, has_httpgd, has_jgd) if (selected_backend == "jgd") { - .runtime_set_option("device", function(...) { + open_jgd_device <- function(key = NULL, recorded = NULL) { jgd::jgd() .runtime_track_device() - }) - - # On reattach (e.g. after a VS Code window reload) the renderer starts a new - # socket, but any jgd device opened before the reload is still bound to the - # old, now-dead socket. jgd::jgd() only reads JGD_SOCKET at device-creation - # time, so the stale device never reconnects and plots silently go nowhere. - # Reopen it against the new socket, replaying the current plot if possible. - reconnect_jgd_device <- function() { - devs <- grDevices::dev.list() - if (is.null(devs) || !"jgd" %in% names(devs)) { - return(invisible(FALSE)) + if (is.null(key)) { + .sess_env$plot_device_sequence <- (.sess_env$plot_device_sequence %||% 0L) + 1L + key <- as.character(.sess_env$plot_device_sequence) } - grDevices::dev.set(devs[names(devs) == "jgd"][[1]]) - recorded <- tryCatch(grDevices::recordPlot(), error = function(e) NULL) - tryCatch(grDevices::dev.off(), error = function(e) NULL) - before_reopen <- grDevices::dev.list() - tryCatch(jgd::jgd(), error = function(e) NULL) - after_reopen <- grDevices::dev.list() - if (!is.null(after_reopen)) { - opened <- if (is.null(before_reopen)) after_reopen else setdiff(after_reopen, before_reopen) - if (length(opened)) .runtime_track_device(opened[[1L]]) + devices <- .runtime_state()$devices + devices[[length(devices)]]$key <- key + state$devices <- devices + # Carry the logical device identity across a new renderer socket. Device + # numbers and JGD's native counter can change when the device is reopened. + set_identity <- function(restoring = FALSE) { + if ("jgd_frame_ext" %in% getNamespaceExports("jgd")) { + jgd::jgd_frame_ext(jsonlite::toJSON(list(vscodeR = list( + sessionId = .session_id(), host = unname(Sys.info()[["nodename"]]), + pid = as.character(Sys.getpid()), + rVer = sprintf("%s.%s", R.version$major, R.version$minor), + processExited = FALSE, deviceId = key, restoring = restoring + )), auto_unbox = TRUE)) + } } + set_identity(!is.null(recorded)) if (!is.null(recorded)) { tryCatch(grDevices::replayPlot(recorded), error = function(e) NULL) + set_identity() } - invisible(TRUE) } - reconnect_jgd_device() + .runtime_set_option("device", function(...) open_jgd_device()) + + # The R process survives reload, but JGD's renderer socket does not. Reopen + # each retained device and replay its current plot with the same logical ID. + previous <- grDevices::dev.cur() + retained <- .runtime_state()$devices + state$devices <- list() + for (entry in retained) { + devs <- grDevices::dev.list() + index <- match(entry$id, devs) + if (is.na(index) || !identical(names(devs)[[index]], entry$name)) next + if (!identical(entry$name, "jgd")) { + .runtime_track_device(entry$id) + next + } + grDevices::dev.set(entry$id) + recorded <- tryCatch(grDevices::recordPlot(), error = function(e) NULL) + tryCatch(grDevices::dev.off(entry$id), error = function(e) NULL) + open_jgd_device(entry$key, recorded) + if (identical(unname(previous), entry$id)) previous <- grDevices::dev.cur() + } + if (previous %in% grDevices::dev.list()) grDevices::dev.set(previous) } else if (selected_backend == "httpgd") { .runtime_set_option("device", function(...) { httpgd::hgd(silent = TRUE) .runtime_track_device() notify_client("httpgd", list(url = httpgd::hgd_url())) }) + for (entry in .runtime_state()$devices) { + devs <- grDevices::dev.list() + index <- match(entry$id, devs) + if (!is.na(index) && identical(names(devs)[[index]], entry$name) && entry$name %in% c("httpgd", "unigd")) { + notify_client("httpgd", list(url = httpgd::hgd_url(which = entry$id), restoring = TRUE)) + } + } } else if (selected_backend == "standard") { # If a specific interactive backend was explicitly requested but is # unavailable, warn before silently degrading to the standard viewer. @@ -315,7 +345,7 @@ runtime_start <- function(use_rstudioapi = TRUE, grDevices::dev.control(displaylist = "enable") }) - update_plot <- function(...) { + update_plot <- function(..., notify = TRUE) { tryCatch( { if (check_null_dev()) { @@ -328,7 +358,7 @@ runtime_start <- function(use_rstudioapi = TRUE, plot_updated <<- FALSE last_plot_record_length <<- curr_length .runtime_set_field("latest_plot_record", record) - .defer_runtime_notification("plot_updated") + if (notify) .defer_runtime_notification("plot_updated") } } } @@ -343,7 +373,8 @@ runtime_start <- function(use_rstudioapi = TRUE, .runtime_set_hook("plot.new", new_plot, "replace") .runtime_set_hook("grid.newpage", new_plot, "replace") - update_plot() + # Reattach refreshes the record without reopening a viewer the user closed. + update_plot(notify = FALSE) .runtime_add_task_callback(function(...) { update_plot(...) }, name = "sess.plot") diff --git a/sess/R/runtime.R b/sess/R/runtime.R index c62eb22fd..b5c93dd9a 100644 --- a/sess/R/runtime.R +++ b/sess/R/runtime.R @@ -187,7 +187,7 @@ invisible(id) } -.runtime_restore <- function() { +.runtime_restore <- function(preserve_plots = FALSE) { state <- .runtime_state() for (name in rev(state$task_callbacks)) { @@ -255,16 +255,17 @@ # Close devices opened through sess's device factory so plotting resumes on # the device R selects after the runtime-owned device is removed. - for (entry in rev(state$devices)) { + for (entry in if (preserve_plots) list() else rev(state$devices)) { devices <- grDevices::dev.list() index <- if (is.null(devices)) NA_integer_ else match(entry$id, devices) if (!is.na(index) && identical(names(devices)[[index]], entry$name)) { try(grDevices::dev.off(which = entry$id), silent = TRUE) } } - state$devices <- list() + if (!preserve_plots) state$devices <- list() for (entry in rev(state$fields)) { + if (preserve_plots && identical(entry$name, "latest_plot_record")) next exists_current <- exists(entry$name, envir = .sess_env, inherits = FALSE) current <- if (exists_current) get(entry$name, envir = .sess_env, inherits = FALSE) else NULL if (identical(exists_current, entry$installed_exists) && @@ -276,15 +277,21 @@ } } } - state$fields <- list() + state$fields <- if (preserve_plots) { + Filter(function(entry) identical(entry$name, "latest_plot_record"), state$fields) + } else list() for (name in names(state$options)) { + if (preserve_plots && identical(name, "sess.null_dev")) next entry <- state$options[[name]] if (identical(getOption(name), entry$installed)) { try(do.call(options, .runtime_named_value(name, entry$original)), silent = TRUE) } } - state$options <- list() + state$options <- if (preserve_plots && "sess.null_dev" %in% names(state$options)) { + state$options["sess.null_dev"] + } else list() + state$preserved_plots <- preserve_plots .runtime_clear_viewer_state() state$active <- FALSE invisible(NULL) @@ -293,8 +300,10 @@ #' Stop the VS Code runtime integration (internal) #' #' Removes runtime callbacks and restores R state installed by runtime_start(). +#' @param preserve_plots Keep live graphics devices and the latest plot while +#' reconnecting the same R process after an editor reload. #' @keywords internal -runtime_stop <- function() { +runtime_stop <- function(preserve_plots = FALSE) { state <- .runtime_state() if (!isTRUE(state$active) && !length(state$options) && !length(state$bindings) && !length(state$hooks) && @@ -305,5 +314,5 @@ runtime_stop <- function() { } # Prevent callbacks from sending new notifications during cleanup. state$active <- FALSE - .runtime_restore() + .runtime_restore(preserve_plots) } diff --git a/sess/R/server.R b/sess/R/server.R index 846bb4afc..4d2a5d3cc 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -44,7 +44,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, plot_backend <- .resolve_plot_backend(plot_backend) # Invalidate poll callbacks and restore a previous runtime before reconnecting. - .transport_disconnect(silent = TRUE) + .transport_disconnect(silent = TRUE, preserve_plots = TRUE) # Metadata belongs to this connect call, not to the R process. In particular, # a later native attach must not inherit an external terminal's identity. .sess_env$attachment_id <- attachment_id @@ -339,7 +339,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, } # An attach write/poll failure may itself have queued a retry. Cancel it # before scheduling our retry so there is only one pending chain. - .transport_disconnect(silent = TRUE) + .transport_disconnect(silent = TRUE, preserve_plots = TRUE) } .schedule_reconnect(settings, .sess_env$transport_generation, schedule) }, 1) @@ -358,7 +358,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, registration$run(endpoint, setup) } -.transport_disconnect <- function(silent = FALSE, reconnect = FALSE) { +.transport_disconnect <- function(silent = FALSE, reconnect = FALSE, preserve_plots = FALSE) { con <- .sess_env$con settings <- .sess_env$reconnect .sess_env$reconnect <- NULL @@ -372,7 +372,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, .sess_env$pending_responses <- list() if (!is.null(con)) try(close(con), silent = TRUE) - runtime_stop() + runtime_stop(preserve_plots = reconnect || preserve_plots) if (reconnect && !is.null(con)) .schedule_reconnect(settings) if (!silent && !is.null(con)) message("[sess] Disconnected from VS Code") invisible(NULL) diff --git a/sess/inst/tinytest/test-attachment-metadata.R b/sess/inst/tinytest/test-attachment-metadata.R index ab353a3e8..f9924dab6 100644 --- a/sess/inst/tinytest/test-attachment-metadata.R +++ b/sess/inst/tinytest/test-attachment-metadata.R @@ -35,7 +35,7 @@ local({ env$connect <- get("connect", namespace) environment(env$connect) <- env disconnects <- 0L - env$.transport_disconnect <- function(silent = FALSE) { + env$.transport_disconnect <- function(silent = FALSE, preserve_plots = FALSE) { disconnects <<- disconnects + 1L state$con <- NULL state$reconnect <- NULL diff --git a/sess/inst/tinytest/test-plot-notification.R b/sess/inst/tinytest/test-plot-notification.R index c2fb3cb0f..6a00705cc 100644 --- a/sess/inst/tinytest/test-plot-notification.R +++ b/sess/inst/tinytest/test-plot-notification.R @@ -69,5 +69,8 @@ local({ expect_warning(scheduled[[3L]](), "Failed to send IPC message") expect_null(sess_env$con) expect_false(isTRUE(sess:::.runtime_state()$active)) + expect_equal(grDevices::dev.cur(), getOption("sess.null_dev")) + expect_inherits(sess_env$latest_plot_record, "recordedplot") + sess:::runtime_stop() expect_equal(grDevices::dev.cur(), original_device) }) diff --git a/sess/inst/tinytest/test-reconnect.R b/sess/inst/tinytest/test-reconnect.R index e00f747fc..6e5d1fbc1 100644 --- a/sess/inst/tinytest/test-reconnect.R +++ b/sess/inst/tinytest/test-reconnect.R @@ -7,7 +7,7 @@ local({ environment(env$.schedule_reconnect) <- env env$.reconnect_with_startup <- sess:::.reconnect_with_startup environment(env$.reconnect_with_startup) <- env - env$.transport_disconnect <- function(silent) { + env$.transport_disconnect <- function(silent, ...) { env$.sess_env$con <- NULL env$.sess_env$transport_generation <- env$.sess_env$transport_generation + 1L } diff --git a/sess/man/runtime_stop.Rd b/sess/man/runtime_stop.Rd index f16d8eb12..75df24155 100644 --- a/sess/man/runtime_stop.Rd +++ b/sess/man/runtime_stop.Rd @@ -4,7 +4,11 @@ \alias{runtime_stop} \title{Stop the VS Code runtime integration (internal)} \usage{ -runtime_stop() +runtime_stop(preserve_plots = FALSE) +} +\arguments{ +\item{preserve_plots}{Keep live graphics devices and the latest plot while +reconnecting the same R process after an editor reload.} } \description{ Removes runtime callbacks and restores R state installed by runtime_start(). diff --git a/src/extension.ts b/src/extension.ts index 38412c0dd..b4a582adb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -380,7 +380,9 @@ export function ensureWorkspaceViewer(): void { } export async function deactivate(): Promise { - await shutdownHtmlWidgetViewers(true); - await (globalPlotManager as plotViewer.CommonPlotManager)?.dispose(); + await Promise.all([ + shutdownHtmlWidgetViewers(true), + (globalPlotManager as plotViewer.CommonPlotManager)?.dispose(), + ]); await session.shutdownSessionWatcher(); } diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts index 9d17e028f..1670712e6 100644 --- a/src/plotViewer/httpgdTypes.d.ts +++ b/src/plotViewer/httpgdTypes.d.ts @@ -21,6 +21,7 @@ export interface HttpgdPlot { // Example for possible viewer creation options: export interface HttpgdViewerOptions { + restoring?: boolean; parent: HttpgdManager; token?: string; preserveFocus?: boolean; diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 750012b85..91838aec0 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -12,6 +12,7 @@ import { ViewerSessionSource, } from '../viewerSession'; import { getViewerSessionContext } from '../session'; +import { sessionProcessIdentity } from '../sessionIdentity'; import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util'; import { extensionContext } from '../extension'; @@ -55,9 +56,9 @@ export class HttpgdManager { viewerOptions: HttpgdViewerOptions; recentlyActiveViewers: HttpgdViewer[] = []; private savedConnections: Promise = Promise.resolve([]); - private restoring: Promise | undefined; private secretKey: string | undefined; private disposed = false; + private connectionWrites: Promise = Promise.resolve(); dispose(): void { this.disposed = true; @@ -79,16 +80,35 @@ export class HttpgdManager { } } - restoreConnections(): Promise { - this.restoring ??= this.savedConnections.then(async (connections) => { - for (const connection of connections) { - await this.showViewer(connection.url, connection.source, true); - } - }); - return this.restoring; + async restoreConnection(source: ViewerSessionSource): Promise { + const key = sessionProcessIdentity(source); + const matches = (owner?: ViewerSessionSource) => + owner && sessionProcessIdentity(owner) === key; + const existing = () => + [...this.recentlyActiveViewers, ...this.viewers].find((viewer) => + matches(viewer.connection.source), + ); + if (existing()) { + return existing(); + } + const connections = await this.savedConnections; + const connection = connections.filter((saved) => matches(saved.source)).at(-1); + if (connection) { + await this.showViewer(connection.url, source, true); + } + return existing(); } - async saveConnections(): Promise { + saveConnections(): Promise { + this.connectionWrites = this.connectionWrites + .then(() => this.writeConnections()) + .catch((error) => + console.warn('[Plot Viewer] Could not save httpgd connections', error), + ); + return this.connectionWrites; + } + + private async writeConnections(): Promise { const connections = new Map( (await this.savedConnections).map((connection) => [ new URL(connection.url).origin, @@ -122,6 +142,7 @@ export class HttpgdManager { urlString: string, source?: ViewerSessionSource, onlyIfMissing = false, + restoring = false, ): Promise { await Promise.resolve(); if (this.disposed) { @@ -140,7 +161,12 @@ export class HttpgdManager { if (viewer.token === token) { viewer.bindSession(source); this.viewers.unshift(viewer); - viewer.show(); + if (!restoring) { + viewer.show(); + } + if (!onlyIfMissing) { + await this.saveConnections(); + } return; } // A restarted device can reuse its port with a new authentication @@ -163,8 +189,12 @@ export class HttpgdManager { this.viewerOptions.fullWindow = conf.get('plot.defaults.fullWindowMode', false); this.viewerOptions.token = token; this.viewerOptions.source = source; + this.viewerOptions.restoring = restoring; const viewer = new HttpgdViewer(url.origin, this.viewerOptions); this.viewers.unshift(viewer); + if (!onlyIfMissing) { + await this.saveConnections(); + } } public registerActiveViewer(viewer: HttpgdViewer): void { @@ -175,6 +205,20 @@ export class HttpgdManager { this.recentlyActiveViewers.unshift(viewer); } + public closeOtherSessionPanels(viewer: HttpgdViewer): void { + const source = viewer.connection.source; + if (!source) { + return; + } + const key = sessionProcessIdentity(source); + for (const other of this.viewers) { + const owner = other.connection.source; + if (other !== viewer && owner && sessionProcessIdentity(owner) === key) { + other.webviewPanel?.dispose(); + } + } + } + public getRecentViewer(): HttpgdViewer | undefined { return this.recentlyActiveViewers.find((viewer) => !!viewer.webviewPanel); } @@ -230,6 +274,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { private disposed = false; private source?: ViewerSessionSource; private sessionContext?: ViewerSessionContext; + private restoring: boolean; get connection(): HttpgdConnection { const url = new URL(this.origin); @@ -287,6 +332,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.origin = url.origin; this.id = this.host; this.token = options.token; + this.restoring = options.restoring ?? false; this.bindSession(options.source); this.parent = options.parent; @@ -388,6 +434,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } public show(preserveFocus?: boolean): void { + this.restoring = false; preserveFocus ??= this.showOptions.preserveFocus; if (!this.webviewPanel) { const showOptions = { @@ -627,6 +674,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { if (this.disposed) { return; } + const restoring = this.restoring; const nPlots = this.plots.length; const hidden = new Set(this.hiddenPlots); const existing = new Map(this.plots.map((plot) => [plot.id, plot])); @@ -645,9 +693,19 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } const oldPlotIds = new Set(this.plots.map((plt) => plt.id)); this.plots = newPlots; + this.restoring = false; if (this.plots.length !== nPlots) { this.activePlot = this.plots[this.plots.length - 1]?.id; } + // Initial connection replays existing plots. Only a new plot or an + // explicit Show Viewer should reopen a closed panel. + if ( + !this.webviewPanel && + !redraw && + (restoring || !this.plots.some((plot) => !oldPlotIds.has(plot.id))) + ) { + return; + } if (redraw || !this.webviewPanel) { this.refreshHtml(); } else { @@ -774,6 +832,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } protected makeNewWebview(showOptions?: ShowOptions): vscode.WebviewPanel { + this.parent.closeOtherSessionPanels(this); + this.parent.registerActiveViewer(this); const webviewPanel = vscode.window.createWebviewPanel( 'RPlot', 'R Plot', diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index b6dfcbc59..f116d7861 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -57,6 +57,9 @@ export class CommonPlotManager implements PlotManager { private standardViewers = new Map(); private restoredStandardSessions: ViewerSessionSource[] = []; private disposed = false; + private historySaveTimer: NodeJS.Timeout | undefined; + private historySubscription: { dispose(): void } | undefined; + private historyWrites: Promise = Promise.resolve(); constructor() { this.httpgdManager = new HttpgdManager(); @@ -95,6 +98,16 @@ export class CommonPlotManager implements PlotManager { ? sources.filter(isViewerSessionSource) : []; this.httpgdManager.initialize(); + this.historySubscription = this.jgdManager.history.onDidChange(() => { + // Checkpoint during normal operation: shutdown-only writes can be + // lost when VS Code tears down the extension host's storage bridge. + if (!this.historySaveTimer && !this.disposed) { + this.historySaveTimer = setTimeout(() => { + this.historySaveTimer = undefined; + void this.saveJgdHistory(); + }, 250); + } + }); for (const cmd of commands) { const fullCommand = `r.plot.${cmd}`; @@ -155,13 +168,14 @@ export class CommonPlotManager implements PlotManager { if (!viewer) { viewer = new StandardPlotViewer(target); this.standardViewers.set(key, viewer); + void this.saveStandardSources(); } else { viewer.bindSession(target); } return viewer; } - public async showHttpgdPlot(url: string, target?: Session): Promise { + public async showHttpgdPlot(url: string, target?: Session, restoring = false): Promise { const source = target ? { sessionId: target.sessionId, @@ -171,7 +185,7 @@ export class CommonPlotManager implements PlotManager { processExited: target.processExited, } : undefined; - await this.httpgdManager.showViewer(url, source); + await this.httpgdManager.showViewer(url, source, false, restoring); } public getJgdEnvVars(): Record { @@ -183,6 +197,31 @@ export class CommonPlotManager implements PlotManager { return; } this.disposed = true; + clearTimeout(this.historySaveTimer); + this.historySubscription?.dispose(); + const saved = Promise.all([ + this.saveJgdHistory(), + this.saveStandardSources(), + this.httpgdManager.saveConnections(), + ]); + this.jgdManager.stop(); + for (const viewer of this.standardViewers.values()) { + viewer.dispose(); + } + this.standardPlotViewer.dispose(); + this.httpgdManager.dispose(); + await saved; + } + + private saveJgdHistory(): Promise { + const snapshot = structuredClone(this.jgdManager.history.snapshot()); + this.historyWrites = this.historyWrites + .then(() => extensionContext.workspaceState.update('r.plot.jgdState', snapshot)) + .catch((error) => console.warn('[Plot Viewer] Could not save JGD state', error)); + return this.historyWrites; + } + + private async saveStandardSources(): Promise { const standardSources = new Map( this.restoredStandardSessions.map((source) => [sessionProcessIdentity(source), source]), ); @@ -194,25 +233,11 @@ export class CommonPlotManager implements PlotManager { ); } } - const saved = Promise.all([ - extensionContext.workspaceState.update( - 'r.plot.jgdState', - this.jgdManager.history.snapshot(), - ), - extensionContext.workspaceState.update('r.plot.standardSessions', [ - ...standardSources.values(), - ]), - this.httpgdManager.saveConnections(), - ]); - this.jgdManager.stop(); - for (const viewer of this.standardViewers.values()) { - viewer.dispose(); - } - this.standardPlotViewer.dispose(); - this.httpgdManager.dispose(); - await saved.catch((error) => - console.warn('[Plot Viewer] Could not save reload state', error), - ); + await extensionContext.workspaceState + .update('r.plot.standardSessions', [...standardSources.values()]) + .then(undefined, (error) => + console.warn('[Plot Viewer] Could not save standard plot sessions', error), + ); } private async handleCommand( @@ -221,28 +246,38 @@ export class CommonPlotManager implements PlotManager { ...args: unknown[] ): Promise { if (command === 'showViewers') { - await this.httpgdManager.restoreConnections(); + const source = activeSession; + const target = source && resolvePlotSession(source); if (this.disposed) { return; } - let unavailable = false; - for (const source of this.restoredStandardSessions) { - const target = resolvePlotSession(source); - if (target) { - // Opening the panel below requests its plot on readiness. Do - // not wait for R or refresh viewers that are already restored. - this.getStandardViewer(target); - } else if (!this.standardViewers.has(sessionProcessIdentity(source))) { - unavailable = true; - } - } - if (unavailable) { + if (!target) { void vscode.window.showInformationMessage( - 'Attach the original R session to restore its standard plot viewer.', + 'Focus an attached R session to show its plot viewer.', ); + return; } - for (const viewer of this.viewers) { - viewer.show(true); + if (this.jgdManager.showForSession(target)) { + return; + } + const httpgd = await this.httpgdManager.restoreConnection(target); + if (this.disposed) { + return; + } + if (httpgd) { + httpgd.show(true); + return; + } + const key = sessionProcessIdentity(target); + if ( + this.standardViewers.has(key) || + this.restoredStandardSessions.some((saved) => sessionProcessIdentity(saved) === key) + ) { + this.getStandardViewer(target).show(true); + } else { + void vscode.window.showInformationMessage( + 'No plot is available for the active R session.', + ); } return; } diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts index 3d6ed9c5e..4f091ef94 100644 --- a/src/plotViewer/jgdPlotHistory.ts +++ b/src/plotViewer/jgdPlotHistory.ts @@ -54,6 +54,7 @@ export class PlotHistory { const history = this.sessions.get(sessionId); if (history && !history.source) { history.source = { ...source }; + this.emitter.emit('change', sessionId); } } @@ -229,6 +230,12 @@ export class PlotHistory { return this.sessions.get(sessionId)?.plots.at(-1); } + invalidateReplayIndices(sessionId: string): void { + for (const plot of this.sessions.get(sessionId)?.plots ?? []) { + delete plot.rIndex; + } + } + navigatePrevious(sessionId = this.activeSessionId): PlotFrame | null { return this.navigateTo(this.currentIndex(sessionId) - 2, sessionId); } diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts index 8797e72f0..407780231 100644 --- a/src/plotViewer/jgdSocketServer.ts +++ b/src/plotViewer/jgdSocketServer.ts @@ -4,6 +4,8 @@ import * as path from 'path'; import * as fs from 'fs'; import * as crypto from 'crypto'; import { PlotHistory, PlotFrame } from './jgdPlotHistory'; +import { isViewerSessionSource, ViewerSessionSource } from '../viewerSession'; +import { sessionProcessIdentity } from '../sessionIdentity'; const SERVER_NAME = 'jgd-vscode'; @@ -35,6 +37,7 @@ interface RSession { lastResizeHadPlotIndex: boolean; dpi: number; deviceClosed?: boolean; + source?: ViewerSessionSource; } const isWindows = process.platform === 'win32'; @@ -249,24 +252,62 @@ export class JgdSocketServer { }); socket.on('close', () => { - this.sessions.delete(sessionId); - this.notifyConnectionChange(sessionId); + if (this.sessions.get(session.id) === session) { + this.sessions.delete(session.id); + this.notifyConnectionChange(session.id); + } }); socket.on('error', (err) => { console.error(`jgd session ${sessionId} error:`, err.message); - this.sessions.delete(sessionId); - this.notifyConnectionChange(sessionId); + if (this.sessions.get(session.id) === session) { + this.sessions.delete(session.id); + this.notifyConnectionChange(session.id); + } }); } private handleMessage(session: RSession, line: string) { + if (this.sessions.get(session.id) !== session) { + return; + } try { const msg = JSON.parse(line) as JgdMessage; switch (msg.type) { case 'frame': { const plot = msg.plot; if (plot) { + const identity = msg.ext?.vscodeR; + let restoring = false; + if ( + isViewerSessionSource(identity) && + 'deviceId' in identity && + typeof identity.deviceId === 'string' && + identity.deviceId.length > 0 && + identity.deviceId.length <= 256 + ) { + const id = `session-${sessionProcessIdentity(identity)}:${identity.deviceId}`; + if (!session.source) { + this.sessions.delete(session.id); + this.sessions.get(id)?.socket.destroy(); + session.id = id; + session.source = { + sessionId: identity.sessionId, + host: identity.host, + pid: identity.pid, + rVer: identity.rVer, + processExited: false, + }; + this.sessions.set(id, session); + // Historical R replay indices belong to the old + // physical device, not this replacement socket. + this.history.invalidateReplayIndices(id); + } + restoring = + session.id === id && + 'restoring' in identity && + identity.restoring === true; + } if (Number.isFinite(plot.device?.dpi) && plot.device.dpi > 0) { session.dpi = plot.device.dpi; } @@ -281,7 +322,12 @@ export class JgdSocketServer { : undefined; let accepted = true; - if (isResizeReplay && plotIndex !== undefined) { + if (restoring) { + plot.rIndex = msg.plotNumber; + accepted = msg.incremental + ? this.history.appendOps(session.id, plot as PlotFrame) + : this.history.replaceLatest(session.id, plot as PlotFrame); + } else if (isResizeReplay && plotIndex !== undefined) { accepted = this.history.replaceAtIndex( session.id, plotIndex, @@ -312,6 +358,9 @@ export class JgdSocketServer { accepted = this.history.replaceLatest(session.id, plot as PlotFrame); } if (accepted) { + if (session.source) { + this.history.setSessionSource(session.id, session.source); + } if (typeof deviceLabel === 'string' && deviceLabel) { this.history.setSessionLabel(session.id, deviceLabel); } diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 53f285358..81b7e2237 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -8,7 +8,13 @@ import { onDidChangeSessionConnection, resolveJgdSessionSource, } from '../session'; -import { getViewerSessionScript, viewerSessionStyle, ViewerSessionContext } from '../viewerSession'; +import { + getViewerSessionScript, + viewerSessionStyle, + ViewerSessionContext, + ViewerSessionSource, +} from '../viewerSession'; +import { sessionProcessIdentity } from '../sessionIdentity'; interface MetricsRequest { id: number; @@ -114,9 +120,13 @@ export class JgdManager { this.extensionUri = extensionUri; this.sessionConnectionDisposable = onDidChangeSessionConnection((event) => { if (event.kind === 'connected') { - for (const [id, viewer] of this.viewers) { + // Binding an owner can rekey or merge viewers during this pass. + const viewers = Array.from(this.viewers.values()); + for (const viewer of viewers) { + const id = viewer.sessionId; if (this.server.isConnected(id) && !this.history.sessionSource(id)) { viewer.updateToolbar(); + this.getOrCreateViewer(id); } } } @@ -128,35 +138,54 @@ export class JgdManager { return; } this.started = true; - this.server.setOnFrame((sessionId) => { + this.server.setOnFrame((sessionId, message) => { const current = this.history.currentPlot(sessionId); if (current) { - this.getOrCreateViewer(sessionId).showPlot(current); + const metadata = message.ext?.vscodeR; + const restoring = Boolean( + metadata && + typeof metadata === 'object' && + 'restoring' in metadata && + metadata.restoring === true, + ); + this.getOrCreateViewer(sessionId).showPlot( + current, + !restoring && !message.resizeReplay, + ); } }); this.server.setOnDeviceClosed((sessionId) => { - this.viewers.get(sessionId)?.updateToolbar(); + this.findViewer(sessionId)?.updateToolbar(); }); this.server.onConnectionChange((_count, sessionId) => - this.viewers.get(sessionId)?.updateToolbar(), + this.findViewer(sessionId)?.updateToolbar(), ); this.server.setMeasureText((request: JgdMessage, _dpi, sessionId) => { - return this.getOrCreateViewer(sessionId).measureText( - request as unknown as MetricsRequest, + // Metrics can arrive before the first frame identifies the device. + // They must not create an ownerless viewer for the temporary socket ID. + return ( + this.findViewer(sessionId)?.measureText(request as unknown as MetricsRequest) ?? + Promise.resolve({ + type: 'metrics_response', + id: request.id, + width: 0, + ascent: 0, + descent: 0, + }) ); }); this.server.setGetDimensions((sessionId) => { - return this.viewers.get(sessionId)?.getPanelDimensions() ?? null; + return this.findViewer(sessionId)?.getPanelDimensions() ?? null; }); this.server.start(); this.historyChangeDisposable = this.history.onDidChange((sessionId) => { if (sessionId) { - this.viewers.get(sessionId)?.updateToolbar(); + this.findViewer(sessionId)?.updateToolbar(); } else { for (const viewer of this.viewers.values()) { viewer.updateToolbar(); @@ -177,12 +206,45 @@ export class JgdManager { } getViewers(): JgdViewer[] { - for (const sessionId of this.history.sessionIds()) { - this.getViewer(sessionId); - } return [...this.viewers.values()]; } + showForSession(source: ViewerSessionSource): boolean { + const key = sessionProcessIdentity(source); + const ids = this.history.sessionIds().filter((id) => { + const owner = this.sourceForDevice(id); + return owner && sessionProcessIdentity(owner) === key && this.history.count(id) > 0; + }); + const current = this.viewers.get(key)?.sessionId ?? this.history.getActiveSessionId(); + const id = ids.includes(current) ? current : ids.at(-1); + if (!id) { + return false; + } + this.getOrCreateViewer(id).show(true); + return true; + } + + private sourceForDevice(id: string): ViewerSessionSource | undefined { + const source = + this.history.sessionSource(id) ?? + (this.server.isConnected(id) + ? resolveJgdSessionSource(this.history.sessionLabel(id) ?? '') + : undefined); + if (source) { + this.history.setSessionSource(id, source); + } + return source; + } + + private viewerKey(id: string): string { + const source = this.sourceForDevice(id); + return source ? sessionProcessIdentity(source) : id; + } + + private findViewer(id: string): JgdViewer | undefined { + return [...this.viewers.values()].find((viewer) => viewer.sessionId === id); + } + getViewer(sessionId = this.history.getActiveSessionId()): JgdViewer | null { return sessionId && this.extensionUri ? this.getOrCreateViewer(sessionId) : null; } @@ -192,17 +254,35 @@ export class JgdManager { } private getOrCreateViewer(sessionId: string): JgdViewer { - let viewer = this.viewers.get(sessionId); + const key = this.viewerKey(sessionId); + let viewer = this.viewers.get(key); + // A legacy device may acquire its owner after its first frame. + const previous = this.findViewer(sessionId); + if (previous && previous !== viewer) { + for (const [oldKey, value] of this.viewers) { + if (value === previous) { + this.viewers.delete(oldKey); + } + } + if (viewer) { + previous.dispose(); + } else { + viewer = previous; + } + } if (!viewer) { viewer = new JgdViewer(this.extensionUri!, this.history, this.server, sessionId); - this.viewers.set(sessionId, viewer); } + viewer.sessionId = sessionId; + this.viewers.set(key, viewer); return viewer; } } export class JgdViewer implements PlotViewer { - readonly id: string; + get id(): string { + return `jgd:${this.sessionId}`; + } private panel: vscode.WebviewPanel | null = null; private pendingMetrics: Map void> = new Map(); private metricsIdCounter = 0; @@ -216,10 +296,8 @@ export class JgdViewer implements PlotViewer { private extensionUri: vscode.Uri, private history: PlotHistory, private server: JgdSocketServer, - readonly sessionId: string, - ) { - this.id = `jgd:${sessionId}`; - } + public sessionId: string, + ) {} show(preserveFocus?: boolean): void { if (this.panel) { @@ -332,8 +410,11 @@ export class JgdViewer implements PlotViewer { } } - showPlot(plot: PlotFrame) { + showPlot(plot: PlotFrame, reveal = true) { if (!this.panel) { + if (!reveal) { + return; + } this.createPanel(true); } this.sendPlotToWebview(plot); @@ -525,7 +606,7 @@ export class JgdViewer implements PlotViewer { this.panel = vscode.window.createWebviewPanel( 'jgd.plotPane', - 'R Plot', + 'R Plot (JGD)', { viewColumn, preserveFocus }, { enableScripts: true, diff --git a/src/plotViewer/types.ts b/src/plotViewer/types.ts index 8ed6254ce..29df92a9f 100644 --- a/src/plotViewer/types.ts +++ b/src/plotViewer/types.ts @@ -23,5 +23,5 @@ export interface PlotManager { activeViewer: PlotViewer | undefined; initialize(): void; showStandardPlot(target?: Session): Promise; - showHttpgdPlot(url: string, target?: Session): Promise; + showHttpgdPlot(url: string, target?: Session, restoring?: boolean): Promise; } diff --git a/src/session.ts b/src/session.ts index 4ffaac6dc..7b45e04b3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4000,7 +4000,11 @@ async function handleNotification(message: Record, socket: IpcS case 'httpgd': { if (typeof params.url === 'string' && params.url) { const target = socket._sessionId ? sessions.get(socket._sessionId) : undefined; - await globalPlotManager?.showHttpgdPlot(params.url, target); + await globalPlotManager?.showHttpgdPlot( + params.url, + target, + params.restoring === true, + ); } break; } diff --git a/src/test/node/plotReconnect.test.ts b/src/test/node/plotReconnect.test.ts new file mode 100644 index 000000000..eaf5abcaf --- /dev/null +++ b/src/test/node/plotReconnect.test.ts @@ -0,0 +1,266 @@ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as net from 'node:net'; +import { spawn, execFile, ChildProcessWithoutNullStreams } from 'node:child_process'; +import { promisify } from 'node:util'; +import { PlotHistory } from '../../plotViewer/jgdPlotHistory'; +import { JgdSocketServer } from '../../plotViewer/jgdSocketServer'; +import { HttpgdClient } from '../../plotViewer/httpgdClient'; + +const run = promisify(execFile); +async function until(read: () => T | undefined): Promise { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const result = read(); + if (result !== undefined) { + return result; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error('Timed out waiting for plot reconnect'); +} + +suite('Live R plot reconnect', function () { + this.timeout(60000); + let directory: string; + let library: string; + suiteSetup(async function () { + try { + const probe = await run('Rscript', [ + '--vanilla', + '-e', + 'cat(all(vapply(c("processx", "later", "jsonlite", "jgd", "httpgd", "svglite"), requireNamespace, logical(1), quietly=TRUE)))', + ]); + if (probe.stdout.trim() !== 'TRUE') { + this.skip(); + } + } catch { + this.skip(); + } + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'plot-reconnect-')); + library = path.join(directory, 'library'); + await fs.mkdir(library); + await run('R', [ + 'CMD', + 'INSTALL', + '--no-test-load', + `--library=${library}`, + path.resolve('dist/resources/sess'), + ]); + }); + suiteTeardown(async () => { + if (directory) { + await fs.rm(directory, { recursive: true, force: true }); + } + }); + + for (const backend of ['jgd', 'standard', 'httpgd']) { + test(`${backend} retains its live R plot across renderer and session reconnect`, async () => { + let history = new PlotHistory(); + let jgd: JgdSocketServer; + let server: net.Server; + let r: ChildProcessWithoutNullStreams | undefined; + let httpgd: HttpgdClient | undefined; + const sockets = new Set(); + const notifications: Array<{ + method: string; + params: { url?: string; restoring?: boolean }; + }> = []; + let endpoint = ''; + let generation = 0; + async function startServers() { + generation++; + endpoint = + process.platform === 'win32' + ? `\\\\.\\pipe\\plot-reconnect-${process.pid}-${generation}` + : path.join(directory, `sess-${generation}.sock`); + server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + let buffer = ''; + socket.on('data', (data) => { + buffer += data.toString(); + let index: number; + while ((index = buffer.indexOf('\n')) >= 0) { + notifications.push( + JSON.parse( + buffer.slice(0, index), + ) as (typeof notifications)[number], + ); + buffer = buffer.slice(index + 1); + } + }); + }); + await new Promise((resolve) => server.listen(endpoint, resolve)); + jgd = new JgdSocketServer(history); + jgd.setMeasureText((request) => + Promise.resolve({ + type: 'metrics_response', + id: request.id, + width: 8, + ascent: 8, + descent: 2, + }), + ); + await new Promise((resolve) => { + jgd.onReady(resolve); + jgd.start(); + }); + } + async function stopServers() { + jgd?.stop(); + for (const socket of sockets) { + socket.destroy(); + } + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + } + try { + await startServers(); + r = spawn('R', ['--vanilla', '--slave'], { + env: { ...process.env, R_LIBS_USER: library, JGD_SOCKET: jgd!.getSocketPath() }, + }); + let output = ''; + let errors = ''; + r.stdout.on('data', (data: Buffer) => { + output += data.toString(); + }); + r.stderr.on('data', (data: Buffer) => { + errors += data.toString(); + }); + let sequence = 0; + async function evaluate(code: string): Promise<{ data: string; error?: string }> { + const marker = `PLOT_RESULT_${++sequence}:`; + r!.stdin.write( + `tryCatch({value <- {${code}}; cat(${JSON.stringify(marker)}, jsonlite::toJSON(value, auto_unbox=TRUE), "\\n", sep="")}, error=function(e) cat(${JSON.stringify(marker)}, jsonlite::toJSON(list(error=conditionMessage(e)), auto_unbox=TRUE), "\\n", sep=""))\n`, + ); + const line = await until(() => { + if (r!.exitCode !== null) { + throw new Error(`R exited: ${errors}`); + } + const start = output.indexOf(marker); + const end = output.indexOf('\n', start); + return start >= 0 && end >= 0 + ? output.slice(start + marker.length, end) + : undefined; + }); + const result = JSON.parse(line) as { data: string; error?: string }; + assert.ok(!result?.error, `${result?.error}\n${errors}`); + return result; + } + const connect = () => + evaluate( + `Sys.setenv(JGD_SOCKET=${JSON.stringify(jgd!.getSocketPath())}); sess::connect(${JSON.stringify(endpoint)}, use_rstudioapi=FALSE, plot_backend="${backend}"); TRUE`, + ); + await connect(); + await evaluate('plot(1:3, main="first plot"); TRUE'); + await evaluate('plot(3:1, main="before reload"); TRUE'); + let originalId: string | undefined; + let httpgdUrl: string | undefined; + if (backend === 'jgd') { + originalId = await until(() => + history.count() === 2 ? history.getActiveSessionId() : undefined, + ); + assert.ok(history.sessionSource(originalId)?.sessionId); + } else if (backend === 'httpgd') { + httpgdUrl = await until( + () => notifications.find((item) => item.method === 'httpgd')?.params.url, + ); + const url = new URL(httpgdUrl); + httpgd = new HttpgdClient( + url.origin, + url.searchParams.get('token') ?? undefined, + ); + await httpgd.connect(); + assert.equal(httpgd.getPlots().length, 2); + httpgd.disconnect(); + } else { + const plot = await evaluate( + 'sess:::handle_plot_latest(list(format="svglite"))', + ); + assert.ok( + Buffer.from(plot.data, 'base64').toString().includes('before reload'), + ); + } + const saved: unknown = JSON.parse(JSON.stringify(history.snapshot())); + await stopServers(); + await evaluate('sess:::.transport_disconnect(silent=TRUE, reconnect=TRUE); TRUE'); + history = new PlotHistory(); + history.restore(saved); + notifications.length = 0; + await startServers(); + await connect(); + if (backend === 'jgd') { + await until(() => + jgd!.isConnected(originalId!) && + history.latestPlot(originalId!)?.rIndex === 0 + ? true + : undefined, + ); + assert.deepEqual(history.sessionIds(), [originalId]); + assert.equal( + history.count(originalId), + 2, + 'replay replaces the latest plot instead of duplicating history', + ); + history.navigateFirst(originalId); + assert.equal( + history.currentRIndex(originalId), + undefined, + 'old replay indices must not target the replacement device', + ); + history.navigateLast(originalId); + await evaluate('plot(2:4, main="after reload"); TRUE'); + await until(() => (history.count(originalId) === 3 ? true : undefined)); + assert.deepEqual(history.sessionIds(), [originalId]); + } else if (backend === 'httpgd') { + assert.equal( + await until( + () => + notifications.find((item) => item.method === 'httpgd')?.params.url, + ), + httpgdUrl, + ); + assert.equal( + notifications.find((item) => item.method === 'httpgd')?.params.restoring, + true, + ); + const url = new URL(httpgdUrl!); + httpgd = new HttpgdClient( + url.origin, + url.searchParams.get('token') ?? undefined, + ); + await httpgd.connect(); + assert.equal(httpgd.getPlots().length, 2); + const svg = await httpgd.getPlotText({ + id: httpgd.getPlots()[1].id, + renderer: 'svgp', + }); + assert.ok(svg.includes('before reload')); + } else { + assert.ok( + !notifications.some((item) => item.method === 'plot_updated'), + 'reattachment must not announce an existing standard plot as new', + ); + const plot = await evaluate( + 'sess:::handle_plot_latest(list(format="svglite"))', + ); + assert.ok( + Buffer.from(plot.data, 'base64').toString().includes('before reload'), + ); + } + } finally { + httpgd?.disconnect(); + if (r && r.exitCode === null) { + const exited = new Promise((resolve) => r!.once('exit', () => resolve())); + r.kill(); + await exited; + } + await stopServers(); + } + }); + } +}); diff --git a/src/test/suite/jgdSocketServer.test.ts b/src/test/suite/jgdSocketServer.test.ts index a9bd243c4..afe1b18cd 100644 --- a/src/test/suite/jgdSocketServer.test.ts +++ b/src/test/suite/jgdSocketServer.test.ts @@ -164,6 +164,81 @@ suite('JGD SocketServer', () => { } suite('frame routing', () => { + test('reconnects the original logical device without merging other sessions or devices', async () => { + const identity = { + sessionId: 'session-a', + host: 'localhost', + pid: '123', + rVer: '4.5.0', + processExited: false, + deviceId: '1', + }; + async function sendFrame( + client: ClientHelper, + label: string, + source: object, + restoring = false, + ) { + client.send( + makePlotMsg(label, 400, 300, { + plotNumber: 0, + newPage: true, + ext: { vscodeR: { ...source, restoring } }, + }), + ); + client.send({ type: 'metrics_request', id: 42, kind: 'strWidth', str: 'barrier' }); + await client.readLine(); + } + const original = await connect(); + await sendFrame(original, 'original', identity); + const id = history.getActiveSessionId(); + await sendFrame(await connect(), 'other device', { ...identity, deviceId: '2' }); + await sendFrame(await connect(), 'other session', { + ...identity, + sessionId: 'session-b', + pid: '456', + }); + const replacement = await connect(); + await sendFrame(replacement, 'restored', identity, true); + assert.strictEqual(history.sessionIds().length, 3); + assert.strictEqual(history.count(id), 1); + assert.strictEqual(history.latestPlot(id)?.device.bg, 'restored'); + await waitMs(50); // Let the old socket close after its replacement connects. + assert.ok(server.isConnected(id)); + await sendFrame(replacement, 'new plot', identity); + assert.strictEqual(history.count(id), 2); + assert.strictEqual(history.sessionIds().length, 3); + }); + + test('reconnect replay does not restore a plot the user deleted', async () => { + const identity = { + sessionId: 'session-a', + host: 'localhost', + pid: '123', + rVer: '4.5.0', + processExited: false, + deviceId: '1', + }; + const original = await connect(); + original.send(makePlotMsg('original', 400, 300, { ext: { vscodeR: identity } })); + await waitMs(50); + const id = history.getActiveSessionId(); + history.removeCurrent(id); + const replacement = await connect(); + replacement.send( + makePlotMsg('replayed', 400, 300, { + ext: { vscodeR: { ...identity, restoring: true } }, + }), + ); + replacement.send({ type: 'metrics_request', id: 42, kind: 'strWidth', str: 'barrier' }); + await replacement.readLine(); + assert.strictEqual(history.count(id), 0); + replacement.send(makePlotMsg('new plot', 400, 300, { ext: { vscodeR: identity } })); + replacement.send({ type: 'metrics_request', id: 43, kind: 'strWidth', str: 'barrier' }); + await replacement.readLine(); + assert.strictEqual(history.count(id), 1); + }); + test('resizes only the requested device, regardless of the latest active session', async () => { const first = await connect(); const second = await connect(); diff --git a/src/test/suite/plotCommands.test.ts b/src/test/suite/plotCommands.test.ts index 2c4d71958..b142ef5ea 100644 --- a/src/test/suite/plotCommands.test.ts +++ b/src/test/suite/plotCommands.test.ts @@ -1,4 +1,5 @@ import * as assert from 'assert'; +import * as session from '../../session'; import * as fs from 'fs'; import * as path from 'path'; import * as sinon from 'sinon'; @@ -78,6 +79,15 @@ suite('Contextual plot commands', () => { sandbox.stub(manager.jgdManager, 'start'); sandbox.stub(manager.jgdManager, 'getEnvVars').returns({}); const showJgdViewer = sandbox.stub(); + const source = { + sessionId: 'focused', + host: 'host', + pid: '123', + processExited: false, + } as session.Session; + sandbox.stub(session, 'activeSession').value(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); + const showForSession = sandbox.stub(manager.jgdManager, 'showForSession').returns(true); const jgdViewer = { show: showJgdViewer, handleCommand: sandbox.stub(), @@ -117,10 +127,10 @@ suite('Contextual plot commands', () => { assert.deepStrictEqual(viewer.handleCommand.firstCall.args, [command, 'plot-id']); } await invoke('r.plot.showViewers'); - assert.strictEqual(viewer.show.callCount, 1); - assert.deepStrictEqual(viewer.show.firstCall.args, [true]); - sinon.assert.calledOnceWithExactly(showJgdViewer, true); - sinon.assert.calledOnceWithExactly(showStandardViewer, true); + sinon.assert.calledOnceWithExactly(showForSession, source); + assert.strictEqual(viewer.show.callCount, 0); + sinon.assert.notCalled(showJgdViewer); + sinon.assert.notCalled(showStandardViewer); sinon.assert.notCalled(fallback); } finally { sandbox.restore(); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 3501910c8..9f5f0c87c 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -10,6 +10,7 @@ import { JgdManager } from '../../plotViewer/jgdViewer'; import type { JgdMessage } from '../../plotViewer/jgdSocketServer'; import type { PlotFrame } from '../../plotViewer/jgdPlotHistory'; import { HttpgdClient } from '../../plotViewer/httpgdClient'; +import { HttpgdViewer } from '../../plotViewer/httpgdViewer'; import { extensionContext } from '../../extension'; import * as session from '../../session'; import * as util from '../../util'; @@ -18,6 +19,12 @@ import { waitForValue } from '../common/sessionConnections'; const createRealWebviewPanel = vscode.window.createWebviewPanel; +class RefreshViewer extends HttpgdViewer { + receivePlots(ids: string[]): Promise { + return this.refreshPlots(ids.map((id) => ({ id }))); + } +} + suite('Standalone plot restoration', () => { let sandbox: sinon.SinonSandbox; let state: Map; @@ -134,10 +141,14 @@ suite('Standalone plot restoration', () => { } as session.Session; } + function focus(source: session.Session): void { + sandbox.stub(session, 'activeSession').value(source); + } + test('all plot backends show the exact shared session indicator and retain it after reload', async () => { const a = owner('indicator-a', '12101'); const b = owner('indicator-b', '12102'); - sandbox.stub(session, 'activeSession').value(b); + sandbox.stub(session, 'sessionRequest').resolves({ data: 'plot', format: 'svg' }); sandbox .stub(session, 'resolvePlotSession') @@ -152,8 +163,12 @@ suite('Standalone plot restoration', () => { first.jgdManager.history.addPlot(source.sessionId, frame(source.sessionId)); first.jgdManager.history.setSessionSource(source.sessionId, source); } - async function checkIndicators() { - await callbacks.get('r.plot.showViewers')!(); + async function checkIndicators(value: CommonPlotManager) { + for (const source of [a, b]) { + await value.showStandardPlot(source); + (await value.httpgdManager.restoreConnection(source))!.show(); + value.jgdManager.showForSession(source); + } assert.strictEqual(panels.length, 6); for (const source of [a, b]) { const label = `R 4.6.0: ${source.pid}`; @@ -162,7 +177,10 @@ suite('Standalone plot restoration', () => { ); assert.strictEqual(owned.length, 3, 'each backend has its own session indicator'); for (const item of owned) { - assert.strictEqual(item.panel.title, 'R Plot'); + assert.strictEqual( + item.panel.title, + item.panel.viewType === 'jgd.plotPane' ? 'R Plot (JGD)' : 'R Plot', + ); assert.ok(item.panel.webview.html.includes('class="viewer-session-tooltip"')); assert.ok(item.panel.webview.html.includes('codicon.css')); item.post.resetHistory(); @@ -174,14 +192,13 @@ suite('Standalone plot restoration', () => { } } } - await checkIndicators(); + await checkIndicators(first); for (const { panel } of panels) { panel.dispose(); } await first.dispose(); panels = []; - manager(); - await checkIndicators(); + await checkIndicators(manager()); }); test('JGD binds live devices including late R attachments, but never guesses a restored owner', async () => { @@ -235,7 +252,8 @@ suite('Standalone plot restoration', () => { await value.showHttpgdPlot('http://localhost:1234', source); value.jgdManager.history.addPlot('device', frame('device')); value.jgdManager.history.setSessionSource('device', source); - await callbacks.get('r.plot.showViewers')!(); + value.httpgdManager.viewers[0].show(); + value.jgdManager.showForSession(source); assert.strictEqual(panels.length, 3); kill.callsFake(() => { throw Object.assign(new Error('gone'), { code: 'ESRCH' }); @@ -256,7 +274,7 @@ suite('Standalone plot restoration', () => { test('httpgd gains the session indicator when an existing URL is attached to R', async () => { const value = manager(); await value.showHttpgdPlot('http://localhost:1234'); - await callbacks.get('r.plot.showViewers')!(); + value.httpgdManager.viewers[0].show(); assert.ok(!panels[0].panel.webview.html.includes('class="viewer-session"')); await value.showHttpgdPlot('http://localhost:1234', owner('late-httpgd', '12130')); assert.strictEqual(panels.length, 1); @@ -268,6 +286,33 @@ suite('Standalone plot restoration', () => { }); }); + test('all backends checkpoint restoration state before shutdown', async () => { + const source = owner('checkpoint', '12150'); + sandbox.stub(session, 'sessionRequest').resolves({ data: 'live', format: 'svg' }); + sandbox.stub(session, 'resolvePlotSession').returns(source); + const first = manager(); + await first.showStandardPlot(source); + await first.showHttpgdPlot('http://localhost:1234?token=checkpoint', source); + first.jgdManager.history.addPlot('device', frame('device')); + first.jgdManager.history.setSessionSource('device', source); + await waitForValue(() => (state.has('r.plot.jgdState') ? true : undefined)); + assert.ok(state.has('r.plot.standardSessions')); + assert.ok(state.has('r.plot.httpgdConnectionsKey')); + for (const { panel } of panels) { + panel.dispose(); + } + panels = []; + const restored = manager(); + // Deliberately do not call first.dispose(): the old extension host may + // stop before its shutdown hook can write through VS Code's storage bridge. + restored.jgdManager.showForSession(source); + (await restored.httpgdManager.restoreConnection(source))!.show(); + await restored.showStandardPlot(source); + assert.strictEqual(panels.length, 3); + assert.strictEqual(restored.jgdManager.history.count('device'), 1); + assert.strictEqual(restored.httpgdManager.viewers[0].token, 'checkpoint'); + }); + test('Show Viewers requests the standard plot from its original attached session after reload', async () => { const first = manager(); const owner = { @@ -280,6 +325,7 @@ suite('Standalone plot restoration', () => { const request = sandbox .stub(session, 'sessionRequest') .resolves({ data: 'original', format: 'svg' }); + focus(owner); const resolve = sandbox.stub(session, 'resolvePlotSession').returns(owner); await first.showStandardPlot(owner); await panels[0].receive({ type: 'ready' }); @@ -340,6 +386,190 @@ suite('Standalone plot restoration', () => { }; } + test('Show Viewer opens only the focused session and never ownerless or empty JGD viewers', async () => { + const a = owner('a', '123'); + const b = owner('b', '456'); + const active = sandbox.stub(session, 'activeSession').value(a); + const resolve = sandbox + .stub(session, 'resolvePlotSession') + .callsFake((source) => source as session.Session); + const value = manager(); + const metrics = sandbox.spy(value.jgdManager.server, 'setMeasureText'); + const deliver = startJgd(value); + await metrics.firstCall.args[0]({ type: 'metrics_request', id: 1 }, 96, 'temporary-socket'); + assert.strictEqual(value.jgdManager.getViewers().length, 0); + for (const source of [a, b]) { + value.jgdManager.history.addPlot(source.sessionId, frame(source.sessionId)); + value.jgdManager.history.setSessionSource(source.sessionId, source); + deliver(source.sessionId, { type: 'frame' }); + } + value.jgdManager.history.addPlot('unknown', frame('unknown')); + value.jgdManager.history.addPlot('empty', frame('empty')); + value.jgdManager.history.setSessionSource('empty', a); + value.jgdManager.history.clear('empty'); + for (const { panel } of panels) { + panel.dispose(); + } + panels = []; + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 1); + assert.ok(panels[0].panel.webview.html.includes('R 4.6.0: 123')); + sinon.assert.calledOnceWithExactly(resolve, a); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 1); + active.value(b); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 2); + assert.ok(panels[1].panel.webview.html.includes('R 4.6.0: 456')); + active.value(undefined); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 2); + }); + + test('multiple JGD devices in one R session reuse one viewer', async () => { + const source = owner('a', '123'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); + const value = manager(); + const deliver = startJgd(value); + for (const id of ['device-1', 'device-2', 'device-1']) { + value.jgdManager.history.addPlot(id, frame(id)); + value.jgdManager.history.setSessionSource(id, source); + deliver(id, { type: 'frame' }); + } + assert.strictEqual(panels.length, 1); + assert.strictEqual(value.jgdManager.getViewers().length, 1); + assert.strictEqual(value.jgdManager.getViewers()[0].sessionId, 'device-1'); + panels[0].panel.dispose(); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 2, 'one replacement tab after closing'); + }); + + test('JGD reattachment after reload keeps closed viewers closed until Show Viewer or a new plot', async () => { + const source = owner('a', '123'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); + const first = manager(); + const deliver = startJgd(first); + first.jgdManager.history.addPlot('device', frame('device')); + first.jgdManager.history.setSessionSource('device', source); + deliver('device', { type: 'frame' }); + panels[0].panel.dispose(); + await first.dispose(); + (JgdManager.prototype.start as sinon.SinonStub).resetBehavior(); + panels = []; + const restored = manager(); + const replay = startJgd(restored); + replay('device', { type: 'frame', ext: { vscodeR: { restoring: true } } }); + replay('device', { type: 'frame', resizeReplay: true }); + assert.strictEqual(panels.length, 0); + assert.strictEqual(restored.jgdManager.history.count('device'), 1); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 1); + panels[0].panel.dispose(); + replay('device', { type: 'frame', ext: { vscodeR: { restoring: true } } }); + assert.strictEqual(panels.length, 1); + restored.jgdManager.history.addPlot('device', frame('device', 'new')); + replay('device', { type: 'frame' }); + assert.strictEqual(panels.length, 2); + }); + + test('httpgd restoration connects only the focused session', async () => { + const a = owner('a', '123'); + const b = owner('b', '456'); + focus(a); + sandbox.stub(session, 'resolvePlotSession').returns(a); + const first = manager(); + await first.showHttpgdPlot('http://localhost:1234?token=a', a); + await first.showHttpgdPlot('http://localhost:1235?token=b', b); + await first.dispose(); + connect.resetHistory(); + const restored = manager(); + await callbacks.get('r.plot.showViewers')!(); + sinon.assert.calledOnce(connect); + assert.strictEqual(restored.httpgdManager.viewers.length, 1); + assert.strictEqual(restored.httpgdManager.viewers[0].connection.source?.sessionId, 'a'); + assert.strictEqual(panels.length, 1); + }); + + test('httpgd reattachment and existing plot updates keep closed panels closed', async () => { + const source = owner('a', '123'); + const value = manager(); + const viewer = new RefreshViewer('http://localhost:1234', { + ...value.httpgdManager.viewerOptions, + source, + restoring: true, + }); + value.httpgdManager.viewers.push(viewer); + sandbox.stub(viewer.api, 'getPlotText').resolves(''); + await viewer.receivePlots(['1']); + assert.strictEqual(panels.length, 0); + await viewer.receivePlots(['1']); + assert.strictEqual(panels.length, 0); + viewer.show(); + assert.strictEqual(panels.length, 1); + panels[0].panel.dispose(); + await value.showHttpgdPlot('http://localhost:1234', source, true); + await viewer.receivePlots(['1']); + assert.strictEqual(panels.length, 1); + await viewer.receivePlots(['1', '2']); + assert.strictEqual(panels.length, 2, 'a new plot opens one replacement panel'); + }); + + test('httpgd devices share one visible viewer per session while other sessions remain independent', async () => { + const a = owner('a', '123'); + const b = owner('b', '456'); + focus(a); + sandbox.stub(session, 'resolvePlotSession').returns(a); + const value = manager(); + await value.showHttpgdPlot('http://localhost:1234', a); + const first = value.httpgdManager.viewers[0]; + first.show(); + await value.showHttpgdPlot('http://localhost:1235', b); + const other = value.httpgdManager.viewers[0]; + other.show(); + await value.showHttpgdPlot('http://localhost:1236', a); + const second = value.httpgdManager.viewers[0]; + second.show(); + assert.strictEqual(first.webviewPanel, undefined); + assert.ok(second.webviewPanel); + assert.ok(other.webviewPanel); + first.show(); + assert.strictEqual(second.webviewPanel, undefined); + assert.ok(first.webviewPanel); + assert.ok(other.webviewPanel); + panels.at(-1)!.panel.dispose(); + const count = panels.length; + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, count + 1); + assert.ok(first.webviewPanel, 'reopens the last used device for the focused session'); + assert.strictEqual(second.webviewPanel, undefined); + }); + + test('standard restoration requests only the focused session', async () => { + const a = owner('a', '123'); + const b = owner('b', '456'); + focus(a); + sandbox.stub(session, 'resolvePlotSession').returns(a); + const request = sandbox + .stub(session, 'sessionRequest') + .resolves({ data: 'svg', format: 'svg' }); + const first = manager(); + await first.showStandardPlot(a); + await first.showStandardPlot(b); + for (const { panel } of panels) { + panel.dispose(); + } + await first.dispose(); + panels = []; + manager(); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 1); + await panels[0].receive({ type: 'ready' }); + sinon.assert.calledOnce(request); + assert.strictEqual(request.firstCall.args[1], a); + }); + test('JGD frames update only their own viewer without redundant toolbar messages', async () => { const value = manager(); const deliver = startJgd(value); @@ -358,8 +588,8 @@ suite('Standalone plot restoration', () => { assert.strictEqual(panels.length, 2); history.setSessionLabel('b', 'r-456-1'); await panels[1].receive({ type: 'ready' }); - assert.strictEqual(panels[0].panel.title, 'R Plot'); - assert.strictEqual(panels[1].panel.title, 'R Plot'); + assert.strictEqual(panels[0].panel.title, 'R Plot (JGD)'); + assert.strictEqual(panels[1].panel.title, 'R Plot (JGD)'); assert.ok(!panels[0].panel.webview.html.includes('session-select')); panels[1].post.resetHistory(); @@ -382,9 +612,13 @@ suite('Standalone plot restoration', () => { const deliver = startJgd(value); const resize = sandbox.stub(value.jgdManager.server, 'handleResize'); const history = value.jgdManager.history; + const aOwner = owner('a', '123'); + focus(aOwner); + sandbox.stub(session, 'resolvePlotSession').returns(aOwner); for (const id of ['a', 'b']) { history.addPlot(id, frame(id, `${id}1`)); history.addPlot(id, frame(id, `${id}2`)); + history.setSessionSource(id, id === 'a' ? aOwner : owner('b', '456')); deliver(id, { type: 'frame' }); await panels.at(-1)!.receive({ type: 'ready' }); } @@ -455,10 +689,21 @@ suite('Standalone plot restoration', () => { history.addPlot('b', frame('b', 'b1')); history.addPlot('b', frame('b', 'b2')); history.navigatePrevious('a'); + const a = owner('a', '123'); + const b = owner('b', '456'); + history.setSessionSource('a', a); + history.setSessionSource('b', b); + const active = sandbox.stub(session, 'activeSession').value(a); + sandbox + .stub(session, 'resolvePlotSession') + .callsFake((source) => source as session.Session); await first.dispose(); const restored = manager(); assert.strictEqual(panels.length, 0); await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 1); + active.value(b); + await callbacks.get('r.plot.showViewers')!(); assert.strictEqual(panels.length, 2); for (const [index, id, bg] of [ [0, 'a', 'a1'], @@ -472,7 +717,7 @@ suite('Standalone plot restoration', () => { plot: sinon.match({ sessionId: id, device: sinon.match({ bg }) }), }), ); - assert.strictEqual(panels[index].panel.title, 'R Plot'); + assert.strictEqual(panels[index].panel.title, 'R Plot (JGD)'); } await restored.jgdManager.getViewer('b')!.handleCommand('firstPlot'); assert.strictEqual(restored.jgdManager.history.currentIndex('a'), 1); @@ -489,6 +734,7 @@ suite('Standalone plot restoration', () => { processExited: false, } as session.Session; sandbox.stub(session, 'sessionRequest').resolves({ data: 'plot', format: 'svg' }); + focus(owner); const resolve = sandbox.stub(session, 'resolvePlotSession').returns(undefined); const information = sandbox.stub(vscode.window, 'showInformationMessage').resolves(); await first.showStandardPlot(owner); @@ -505,8 +751,11 @@ suite('Standalone plot restoration', () => { }); test('httpgd reconnects with its token and retains live rendering and removal after reload', async () => { + const source = owner('httpgd-owner', '14001'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); const first = manager(); - await first.showHttpgdPlot('http://localhost:1234/live?token=secret'); + await first.showHttpgdPlot('http://localhost:1234/live?token=secret', source); first.httpgdManager.viewers[0].plots = [ { id: '1', data: 'not persisted', width: 400, height: 300, zoom: 1 }, ]; @@ -535,6 +784,9 @@ suite('Standalone plot restoration', () => { }); test('httpgd reload fetches fresh plots from an authenticated server', async () => { + const source = owner('httpgd-owner', '14001'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); connect.callThrough(); let revision = 1; let removed = false; @@ -575,7 +827,10 @@ suite('Standalone plot restoration', () => { try { const first = manager(); const address = server.address() as AddressInfo; - await first.showHttpgdPlot(`http://127.0.0.1:${address.port}/live?token=device-token`); + await first.showHttpgdPlot( + `http://127.0.0.1:${address.port}/live?token=device-token`, + source, + ); await waitForValue(() => first.httpgdManager.viewers[0].plots.length ? true : undefined, ); @@ -606,11 +861,14 @@ suite('Standalone plot restoration', () => { }); test('httpgd restoration does not replace a device that already attached with a new token', async () => { + const source = owner('httpgd-owner', '14001'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); const first = manager(); - await first.showHttpgdPlot('http://localhost:1234/live?token=old'); + await first.showHttpgdPlot('http://localhost:1234/live?token=old', source); await first.dispose(); const restored = manager(); - await restored.showHttpgdPlot('http://localhost:1234/live?token=new'); + await restored.showHttpgdPlot('http://localhost:1234/live?token=new', source); const live = restored.httpgdManager.viewers[0]; await callbacks.get('r.plot.showViewers')!(); assert.deepStrictEqual(restored.httpgdManager.viewers, [live]); @@ -618,8 +876,11 @@ suite('Standalone plot restoration', () => { }); test('httpgd restoration keeps credentials from an attachment arriving at its async boundary', async () => { + const source = owner('httpgd-owner', '14001'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); const first = manager(); - await first.showHttpgdPlot('http://localhost:1234/live?token=old'); + await first.showHttpgdPlot('http://localhost:1234/live?token=old', source); await first.dispose(); const restored = manager(); const show = restored.httpgdManager.showViewer.bind(restored.httpgdManager); @@ -627,7 +888,7 @@ suite('Standalone plot restoration', () => { .stub(restored.httpgdManager, 'showViewer') .callsFake((url, source, onlyIfMissing) => { if (new URL(url).searchParams.get('token') === 'old') { - void show('http://localhost:1234/live?token=new'); + void show('http://localhost:1234/live?token=new', source); } return show(url, source, onlyIfMissing); }); @@ -644,10 +905,10 @@ suite('Standalone plot restoration', () => { device: { width: 400, height: 300, dpi: 96, bg: 'magenta' }, ops: [], }); - first.jgdManager.history.setSessionSource( - 'old-device', - owner('jgd-real-indicator', '12140'), - ); + const source = owner('jgd-real-indicator', '12140'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); + first.jgdManager.history.setSessionSource('old-device', source); await first.dispose(); let panel: vscode.WebviewPanel | undefined; let resolveIndicator!: (message: { label: string; fontLoaded: boolean }) => void; @@ -732,8 +993,11 @@ suite('Standalone plot restoration', () => { }); test('shutdown during credential loading does not reopen a connection or panel', async () => { + const source = owner('httpgd-owner', '14001'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); const first = manager(); - await first.showHttpgdPlot('http://localhost:1234/live?token=secret'); + await first.showHttpgdPlot('http://localhost:1234/live?token=secret', source); await first.dispose(); const value = [...secrets.values()][0]; let finishRead!: (value: string) => void; From f0a80ae40dc50b8857dc0c14aa5dbf6f8bed736d Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:34:39 +1100 Subject: [PATCH 06/32] fix: httpgd now stops refreshes and cancels pending requests when its session disconnects, without showing shutdown-related error popups. --- src/plotViewer/httpgdViewer.ts | 71 +++++++++++++-- src/test/suite/httpgdViewer.test.ts | 132 +++++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 8 deletions(-) diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 91838aec0..ea849e4de 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -11,7 +11,11 @@ import { ViewerSessionContext, ViewerSessionSource, } from '../viewerSession'; -import { getViewerSessionContext } from '../session'; +import { + getViewerSessionContext, + onDidChangeSessionConnection, + resolvePlotSession, +} from '../session'; import { sessionProcessIdentity } from '../sessionIdentity'; import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util'; @@ -274,6 +278,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { private disposed = false; private source?: ViewerSessionSource; private sessionContext?: ViewerSessionContext; + private sessionExitSubscription?: { dispose(): void }; + private sessionConnectionSubscription?: vscode.Disposable; + private sessionDisconnected = false; private restoring: boolean; get connection(): HttpgdConnection { @@ -333,7 +340,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.id = this.host; this.token = options.token; this.restoring = options.restoring ?? false; - this.bindSession(options.source); this.parent = options.parent; this.api = new HttpgdClient(this.origin, this.token); @@ -376,8 +382,20 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.fullWindow = options.fullWindow ?? this.defaultFullWindow; this.resizeTimeoutLength = options.resizeTimeoutLength ?? this.resizeTimeoutLength; this.refreshTimeoutLength = options.refreshTimeoutLength ?? this.refreshTimeoutLength; + this.bindSession(options.source); + this.connect(); + } + + private get canRefresh(): boolean { + return !this.disposed && !this.sessionDisconnected && !this.sessionContext?.hasExited; + } + + private connect(): void { + if (!this.canRefresh) { + return; + } void this.api.connect().catch((err) => { - if (!this.disposed) { + if (this.canRefresh && !(err instanceof Error && err.name === 'AbortError')) { void vscode.window.showErrorMessage(`httpgd connection failed: ${String(err)}`); } }); @@ -427,6 +445,25 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } this.source = { ...source }; this.sessionContext = getViewerSessionContext(source.sessionId, source); + this.sessionExitSubscription = this.sessionContext?.observeExit(() => this.dispose()); + this.sessionConnectionSubscription = onDidChangeSessionConnection((event) => { + if (event.session.sessionId !== source.sessionId || this.disposed) { + return; + } + const connected = Boolean(resolvePlotSession(source)); + if (!connected && !this.sessionDisconnected) { + this.sessionDisconnected = true; + this.stopRequests(); + } else if (connected && this.sessionDisconnected) { + this.sessionDisconnected = false; + // The first snapshot after reconnect is not a new plot. + this.restoring = true; + this.connect(); + } + }); + if (this.sessionContext?.hasExited) { + this.dispose(); + } if (this.webviewPanel) { this.sessionContext?.attach(this.webviewPanel); this.refreshHtml(); @@ -610,6 +647,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } protected handleResize(height: number, width: number, userTriggered: boolean = false): void { + if (!this.canRefresh) { + return; + } this.viewHeight = height; this.viewWidth = width; if (userTriggered || this.resizeTimeoutLength === 0) { @@ -628,18 +668,21 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } private reportRefreshError(err: unknown): void { - if (!this.disposed) { + if (this.canRefresh && !(err instanceof Error && err.name === 'AbortError')) { void vscode.window.showErrorMessage(`Plot refresh failed: ${String(err)}`); } } protected async resizePlot(id?: HttpgdPlotId): Promise { + if (!this.canRefresh) { + return; + } id ??= this.activePlot; if (!id) { return; } const plt = await this.getPlotContent(id, this.viewWidth, this.viewHeight, this.zoom); - if (this.disposed) { + if (!this.canRefresh) { return; } this.plotWidth = plt.width; @@ -652,6 +695,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { redraw: boolean = false, force: boolean = false, ): Promise { + if (!this.canRefresh) { + return; + } if (this.refreshTimeoutLength === 0) { await this.refreshPlots(plotsIdResponse, redraw, force); } else { @@ -671,7 +717,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { redraw: boolean = false, force: boolean = false, ): Promise { - if (this.disposed) { + if (!this.canRefresh) { return; } const restoring = this.restoring; @@ -688,7 +734,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } }); const newPlots = await Promise.all(newPlotPromises); - if (this.disposed) { + if (!this.canRefresh) { return; } const oldPlotIds = new Set(this.plots.map((plt) => plt.id)); @@ -928,9 +974,20 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } public dispose(): void { + if (this.disposed) { + return; + } this.disposed = true; + this.sessionExitSubscription?.dispose(); + this.sessionConnectionSubscription?.dispose(); + this.stopRequests(); + } + + private stopRequests(): void { clearTimeout(this.resizeTimeout); clearTimeout(this.refreshTimeout); + this.resizeTimeout = undefined; + this.refreshTimeout = undefined; this.api.disconnect(); } } diff --git a/src/test/suite/httpgdViewer.test.ts b/src/test/suite/httpgdViewer.test.ts index c522d4eaf..f614f9f57 100644 --- a/src/test/suite/httpgdViewer.test.ts +++ b/src/test/suite/httpgdViewer.test.ts @@ -6,6 +6,21 @@ import { HttpgdClient } from '../../plotViewer/httpgdClient'; import { HttpgdManager, HttpgdViewer } from '../../plotViewer/httpgdViewer'; import { mockExtensionContext } from '../common/mockvscode'; import * as util from '../../util'; +import * as session from '../../session'; +import type { ViewerSessionContext } from '../../viewerSession'; +import type { RSessionConnectionChange } from '../../api'; + +class LifecycleViewer extends HttpgdViewer { + resizeNow(): void { + this.handleResize(600, 900, true); + } + queueResize(): void { + this.handleResize(600, 900); + } + queueRefresh(): Promise { + return this.refreshPlotsDelayed([{ id: '1' }]); + } +} class TestViewer extends HttpgdViewer { renderCount = 0; @@ -25,11 +40,12 @@ class TestViewer extends HttpgdViewer { suite('httpgd viewer requests', () => { let sandbox: sinon.SinonSandbox; let manager: HttpgdManager; + let connect: sinon.SinonStub; const extensionRoot = path.resolve(__dirname, '../../..'); setup(() => { sandbox = sinon.createSandbox(); mockExtensionContext(extensionRoot, sandbox); - sandbox.stub(HttpgdClient.prototype, 'connect').resolves(); + connect = sandbox.stub(HttpgdClient.prototype, 'connect').resolves(); sandbox.stub(vscode.commands, 'executeCommand').resolves(); sandbox.stub(util, 'config').returns({ get: (_key: string, fallback: unknown) => fallback, @@ -94,4 +110,118 @@ suite('httpgd viewer requests', () => { assert.notStrictEqual(manager.viewers[0], old); assert.strictEqual(manager.viewers[0].token, 'second'); }); + + function lifetime() { + const source = { + sessionId: 'owner', + host: 'remote', + pid: '123', + rVer: '4.6.0', + processExited: false, + }; + let exited = false; + let exit!: () => void; + const released = sandbox.stub(); + const context: ViewerSessionContext = { + sessionId: source.sessionId, + source, + get hasExited() { + return exited; + }, + observeExit: (callback) => { + exit = callback; + return { dispose: released }; + }, + getHtml: () => '', + attach: () => {}, + }; + sandbox.stub(session, 'getViewerSessionContext').returns(context); + const events = new vscode.EventEmitter(); + sandbox.stub(session, 'onDidChangeSessionConnection').callsFake(events.event); + const resolve = sandbox + .stub(session, 'resolvePlotSession') + .returns(source as session.Session); + const viewer = new LifecycleViewer('localhost:1234', { + ...manager.viewerOptions, + source, + resizeTimeoutLength: 100, + refreshTimeoutLength: 10, + }); + viewer.activePlot = '1'; + manager.viewers.push(viewer); + const disconnect = sandbox.spy(viewer.api, 'disconnect'); + const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + const fire = (kind: RSessionConnectionChange['kind'], sessionId = source.sessionId) => + events.fire({ kind, session: { sessionId, connectionId: 'connection' } }); + return { + source, + viewer, + resolve, + disconnect, + errors, + released, + fire, + exit: () => { + exited = true; + exit(); + }, + }; + } + + test('owning process exit cancels queued work and leaves the open panel intact', async () => { + const clock = sandbox.useFakeTimers(); + const state = lifetime(); + const request = sandbox.stub(state.viewer.api, 'getPlotText').resolves(''); + const closePanel = sandbox.stub(); + state.viewer.webviewPanel = { dispose: closePanel } as unknown as vscode.WebviewPanel; + state.viewer.queueResize(); + await state.viewer.queueRefresh(); + state.exit(); + await clock.tickAsync(200); + state.viewer.resizeNow(); + await state.viewer.queueRefresh(); + await clock.tickAsync(200); + sinon.assert.notCalled(request); + sinon.assert.calledOnce(state.disconnect); + sinon.assert.calledOnce(state.released); + sinon.assert.notCalled(closePanel); + sinon.assert.notCalled(state.errors); + connect.resetHistory(); + state.fire('connected'); + sinon.assert.notCalled(connect); + state.viewer.webviewPanel = undefined; + }); + + test('session disconnect suppresses in-flight refresh errors and reconnect resumes only its owner', async () => { + const clock = sandbox.useFakeTimers(); + const state = lifetime(); + let fail!: (error: Error) => void; + const request = sandbox.stub(state.viewer.api, 'getPlotText').returns( + new Promise((_resolve, reject) => { + fail = reject; + }), + ); + state.viewer.resizeNow(); + state.resolve.returns(undefined); + state.fire('disconnected'); + fail(new Error('connect ECONNREFUSED')); + await clock.tickAsync(0); + sinon.assert.calledOnce(state.disconnect); + sinon.assert.notCalled(state.errors); + request.resetHistory(); + state.viewer.queueResize(); + await state.viewer.queueRefresh(); + await clock.tickAsync(200); + sinon.assert.notCalled(request); + connect.resetHistory(); + state.fire('connected', 'another-owner'); + sinon.assert.notCalled(connect); + state.resolve.returns(state.source as session.Session); + state.fire('connected'); + sinon.assert.calledOnce(connect); + request.rejects(new Error('a real render error')); + state.viewer.resizeNow(); + await clock.tickAsync(0); + sinon.assert.calledOnce(state.errors); + }); }); From 9a7d63bf3ac1ca81c93ea3e8e3a983c520bf5ebd Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:57:09 +1100 Subject: [PATCH 07/32] fix(plot): preserve resize targets for restored JGD snapshots --- src/plotViewer/jgdSocketServer.ts | 15 +++-- src/test/node/plotReconnect.test.ts | 32 ++++++++++- src/test/suite/jgdSocketServer.test.ts | 78 ++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts index 407780231..1a07beb3c 100644 --- a/src/plotViewer/jgdSocketServer.ts +++ b/src/plotViewer/jgdSocketServer.ts @@ -322,12 +322,10 @@ export class JgdSocketServer { : undefined; let accepted = true; - if (restoring) { - plot.rIndex = msg.plotNumber; - accepted = msg.incremental - ? this.history.appendOps(session.id, plot as PlotFrame) - : this.history.replaceLatest(session.id, plot as PlotFrame); - } else if (isResizeReplay && plotIndex !== undefined) { + // JGD snapshots retain frame extensions, including + // restoring=true. Resize routing must take precedence + // so historical replays cannot replace the latest plot. + if (isResizeReplay && plotIndex !== undefined) { accepted = this.history.replaceAtIndex( session.id, plotIndex, @@ -344,6 +342,11 @@ export class JgdSocketServer { plot as PlotFrame, plotNumber, ); + } else if (restoring) { + plot.rIndex = msg.plotNumber; + accepted = msg.incremental + ? this.history.appendOps(session.id, plot as PlotFrame) + : this.history.replaceLatest(session.id, plot as PlotFrame); } else if (msg.incremental) { accepted = this.history.appendOps(session.id, plot as PlotFrame); } else if (msg.newPage) { diff --git a/src/test/node/plotReconnect.test.ts b/src/test/node/plotReconnect.test.ts index eaf5abcaf..654bb0070 100644 --- a/src/test/node/plotReconnect.test.ts +++ b/src/test/node/plotReconnect.test.ts @@ -6,7 +6,7 @@ import * as net from 'node:net'; import { spawn, execFile, ChildProcessWithoutNullStreams } from 'node:child_process'; import { promisify } from 'node:util'; import { PlotHistory } from '../../plotViewer/jgdPlotHistory'; -import { JgdSocketServer } from '../../plotViewer/jgdSocketServer'; +import { JgdMessage, JgdSocketServer } from '../../plotViewer/jgdSocketServer'; import { HttpgdClient } from '../../plotViewer/httpgdClient'; const run = promisify(execFile); @@ -216,6 +216,36 @@ suite('Live R plot reconnect', function () { await evaluate('plot(2:4, main="after reload"); TRUE'); await until(() => (history.count(originalId) === 3 ? true : undefined)); assert.deepEqual(history.sessionIds(), [originalId]); + + // JGD stores frame extensions in snapshots, so resizing the + // restored plot still sends restoring=true after a new plot. + const latest = structuredClone(history.latestPlot(originalId!)); + history.navigatePrevious(originalId); + assert.equal(history.currentRIndex(originalId), 0); + let resized: JgdMessage | undefined; + jgd!.setOnFrame((_id, message) => { + if (message.resizeReplay) { + resized = message; + } + }); + jgd!.handleResize(900, 700, originalId!); + await evaluate('.Call(jgd:::C_jgd_poll_resize); TRUE'); + const response = await until(() => resized); + assert.equal(response.plotIndex, 0); + assert.equal( + (response.ext?.vscodeR as { restoring?: boolean })?.restoring, + true, + ); + assert.equal(history.currentPlot(originalId)?.device.width, 900); + assert.equal(history.currentPlot(originalId)?.device.height, 700); + assert.equal(history.currentRIndex(originalId), 0); + assert.equal(history.currentIndex(originalId), 2); + assert.equal(history.count(originalId), 3); + assert.deepEqual( + history.latestPlot(originalId!), + latest, + 'resizing the restored historical plot must preserve the new plot', + ); } else if (backend === 'httpgd') { assert.equal( await until( diff --git a/src/test/suite/jgdSocketServer.test.ts b/src/test/suite/jgdSocketServer.test.ts index afe1b18cd..d7b16d969 100644 --- a/src/test/suite/jgdSocketServer.test.ts +++ b/src/test/suite/jgdSocketServer.test.ts @@ -164,6 +164,84 @@ suite('JGD SocketServer', () => { } suite('frame routing', () => { + test('resize replays retain their target when snapshots carry restoration metadata', async () => { + const identity = { + sessionId: 'session-a', + host: 'localhost', + pid: '123', + rVer: '4.5.0', + processExited: false, + deviceId: '1', + }; + async function sendFrame(client: ClientHelper, message: object) { + client.send(message); + client.send({ type: 'metrics_request', id: 42, kind: 'strWidth', str: 'barrier' }); + await client.readLine(); + } + const original = await connect(); + await sendFrame( + original, + makePlotMsg('original', 400, 300, { + newPage: true, + plotNumber: 0, + ext: { vscodeR: identity }, + }), + ); + const id = history.getActiveSessionId(); + const replacement = await connect(); + const ext = { vscodeR: { ...identity, restoring: true } }; + await sendFrame( + replacement, + makePlotMsg('restored', 400, 300, { + newPage: true, + plotNumber: 0, + ext, + }), + ); + assert.strictEqual(history.count(id), 1, 'initial replay replaces the latest plot'); + await sendFrame( + replacement, + makePlotMsg('new plot', 400, 300, { + newPage: true, + plotNumber: 1, + ext: { vscodeR: identity }, + }), + ); + const latest = structuredClone(history.latestPlot(id)); + history.navigatePrevious(id); + const resize = makePlotMsg('resized restored plot', 900, 700, { + resizeReplay: true, + plotIndex: 0, + plotNumber: 1, + ext, + }); + await sendFrame(replacement, resize); + assert.strictEqual(history.currentPlot(id)?.device.bg, 'resized restored plot'); + assert.strictEqual(history.currentRIndex(id), 0); + assert.strictEqual(history.currentIndex(id), 1); + assert.strictEqual(history.count(id), 2); + assert.deepStrictEqual(history.latestPlot(id), latest); + + history.removeCurrent(id); + await sendFrame(replacement, resize); + assert.strictEqual( + history.count(id), + 1, + 'late resize must not resurrect a deleted plot', + ); + assert.deepStrictEqual(history.latestPlot(id), latest); + + await sendFrame( + replacement, + makePlotMsg('stale current-page resize', 900, 700, { + resizeReplay: true, + plotNumber: 0, + ext, + }), + ); + assert.deepStrictEqual(history.latestPlot(id), latest); + }); + test('reconnects the original logical device without merging other sessions or devices', async () => { const identity = { sessionId: 'session-a', From e0f54dc390397b0063b757e613fcede4d9682d2b Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:04:59 +1100 Subject: [PATCH 08/32] fix(plot): retire reused R device registrations --- sess/R/runtime.R | 4 ++ sess/inst/tinytest/test-runtime-devices.R | 36 ++++++++++++++ src/test/node/plotReconnect.test.ts | 57 +++++++++++++++++++---- 3 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 sess/inst/tinytest/test-runtime-devices.R diff --git a/sess/R/runtime.R b/sess/R/runtime.R index b5c93dd9a..1038506d4 100644 --- a/sess/R/runtime.R +++ b/sess/R/runtime.R @@ -179,6 +179,10 @@ if (is.null(devices)) return(invisible(NULL)) name <- names(devices)[match(id, devices)] if (length(name) && !is.na(name)) { + # A newly opened device can reuse a number from dev.off(). Retire that + # registration so its logical plot identity cannot be replayed onto the + # replacement device during reconnect. + state$devices <- Filter(function(entry) entry$id != unname(id), state$devices) state$devices[[length(state$devices) + 1L]] <- list( id = unname(id), name = name diff --git a/sess/inst/tinytest/test-runtime-devices.R b/sess/inst/tinytest/test-runtime-devices.R new file mode 100644 index 000000000..d8af7d129 --- /dev/null +++ b/sess/inst/tinytest/test-runtime-devices.R @@ -0,0 +1,36 @@ +# R reuses graphics device numbers after dev.off(). A new registration must +# replace the previous lifetime without removing other live device identities. +local({ + original_devices <- grDevices::dev.list() + on.exit({ + for (id in rev(setdiff(grDevices::dev.list(), original_devices))) { + try(grDevices::dev.off(id), silent = TRUE) + } + }, add = TRUE) + + state <- new.env(parent = emptyenv()) + state$devices <- list() + env <- new.env(parent = asNamespace("sess")) + env$.runtime_state <- function() state + track <- sess:::.runtime_track_device + environment(track) <- env + + grDevices::pdf(NULL) + track() + state$devices[[1L]]$key <- "other-live-device" + other <- state$devices[[1L]] + + grDevices::pdf(NULL) + id <- grDevices::dev.cur() + track() + state$devices[[2L]]$key <- "closed-device" + grDevices::dev.off(id) + grDevices::pdf(NULL) + expect_identical(grDevices::dev.cur(), id) + track() + + expect_length(state$devices, 2L) + expect_identical(state$devices[[1L]], other) + expect_identical(state$devices[[2L]]$id, unname(id)) + expect_null(state$devices[[2L]]$key) +}) diff --git a/src/test/node/plotReconnect.test.ts b/src/test/node/plotReconnect.test.ts index 654bb0070..4c1f8fdb1 100644 --- a/src/test/node/plotReconnect.test.ts +++ b/src/test/node/plotReconnect.test.ts @@ -56,8 +56,16 @@ suite('Live R plot reconnect', function () { } }); - for (const backend of ['jgd', 'standard', 'httpgd']) { - test(`${backend} retains its live R plot across renderer and session reconnect`, async () => { + for (const [backend, reuseDevice] of [ + ['jgd', false], + ['jgd', true], + ['standard', false], + ['httpgd', false], + ] as const) { + const name = reuseDevice + ? 'jgd preserves closed-device history when R reuses a device number' + : `${backend} retains its live R plot across renderer and session reconnect`; + test(name, async () => { let history = new PlotHistory(); let jgd: JgdSocketServer; let server: net.Server; @@ -157,12 +165,28 @@ suite('Live R plot reconnect', function () { ); await connect(); await evaluate('plot(1:3, main="first plot"); TRUE'); + if (backend === 'jgd') { + await until(() => (history.count() === 1 ? true : undefined)); + } + const closedHistory = reuseDevice + ? structuredClone(history.snapshot().sessions[0]) + : undefined; + if (reuseDevice) { + await evaluate('old_device <- dev.cur(); dev.off(); TRUE'); + } await evaluate('plot(3:1, main="before reload"); TRUE'); + if (reuseDevice) { + await evaluate('stopifnot(dev.cur() == old_device); TRUE'); + } + const plotsBeforeReload = reuseDevice ? 1 : 2; let originalId: string | undefined; let httpgdUrl: string | undefined; if (backend === 'jgd') { originalId = await until(() => - history.count() === 2 ? history.getActiveSessionId() : undefined, + history.count() === plotsBeforeReload && + history.getActiveSessionId() !== closedHistory?.id + ? history.getActiveSessionId() + : undefined, ); assert.ok(history.sessionSource(originalId)?.sessionId); } else if (backend === 'httpgd') { @@ -185,6 +209,7 @@ suite('Live R plot reconnect', function () { Buffer.from(plot.data, 'base64').toString().includes('before reload'), ); } + const deviceIds = history.sessionIds(); const saved: unknown = JSON.parse(JSON.stringify(history.snapshot())); await stopServers(); await evaluate('sess:::.transport_disconnect(silent=TRUE, reconnect=TRUE); TRUE'); @@ -200,22 +225,34 @@ suite('Live R plot reconnect', function () { ? true : undefined, ); - assert.deepEqual(history.sessionIds(), [originalId]); + assert.deepEqual(history.sessionIds(), deviceIds); assert.equal( history.count(originalId), - 2, + plotsBeforeReload, 'replay replaces the latest plot instead of duplicating history', ); + if (closedHistory) { + assert.deepEqual( + history + .snapshot() + .sessions.find((entry) => entry.id === closedHistory.id), + closedHistory, + 'a reused R device number must not overwrite the closed device history', + ); + assert.equal(jgd!.isConnected(closedHistory.id), false); + } history.navigateFirst(originalId); assert.equal( history.currentRIndex(originalId), - undefined, + reuseDevice ? 0 : undefined, 'old replay indices must not target the replacement device', ); history.navigateLast(originalId); await evaluate('plot(2:4, main="after reload"); TRUE'); - await until(() => (history.count(originalId) === 3 ? true : undefined)); - assert.deepEqual(history.sessionIds(), [originalId]); + await until(() => + history.count(originalId) === plotsBeforeReload + 1 ? true : undefined, + ); + assert.deepEqual(history.sessionIds(), deviceIds); // JGD stores frame extensions in snapshots, so resizing the // restored plot still sends restoring=true after a new plot. @@ -239,8 +276,8 @@ suite('Live R plot reconnect', function () { assert.equal(history.currentPlot(originalId)?.device.width, 900); assert.equal(history.currentPlot(originalId)?.device.height, 700); assert.equal(history.currentRIndex(originalId), 0); - assert.equal(history.currentIndex(originalId), 2); - assert.equal(history.count(originalId), 3); + assert.equal(history.currentIndex(originalId), plotsBeforeReload); + assert.equal(history.count(originalId), plotsBeforeReload + 1); assert.deepEqual( history.latestPlot(originalId!), latest, From 5d81f1fc62797cd13f3a8e4654d8133a6b443cef Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:21:19 +1100 Subject: [PATCH 09/32] fix(plot): route JGD metrics through the owning session viewer --- src/plotViewer/jgdViewer.ts | 6 ++- src/test/suite/plotRestoration.test.ts | 58 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 81b7e2237..36385d4d2 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -165,8 +165,12 @@ export class JgdManager { this.server.setMeasureText((request: JgdMessage, _dpi, sessionId) => { // Metrics can arrive before the first frame identifies the device. // They must not create an ownerless viewer for the temporary socket ID. + // Known devices share their owner's renderer even when that viewer + // is displaying another device. Measuring must not switch the plot. + const viewer = + this.findViewer(sessionId) ?? this.viewers.get(this.viewerKey(sessionId)); return ( - this.findViewer(sessionId)?.measureText(request as unknown as MetricsRequest) ?? + viewer?.measureText(request as unknown as MetricsRequest) ?? Promise.resolve({ type: 'metrics_response', id: request.id, diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 9f5f0c87c..7aa5f3a5c 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -445,6 +445,64 @@ suite('Standalone plot restoration', () => { assert.strictEqual(panels.length, 2, 'one replacement tab after closing'); }); + test('JGD measures an earlier device through its session viewer without switching plots', async () => { + const value = manager(); + const metrics = sandbox.spy(value.jgdManager.server, 'setMeasureText'); + const deliver = startJgd(value); + const source = owner('a', '123'); + for (const id of ['device-1', 'device-2', 'other-session']) { + value.jgdManager.history.addPlot(id, frame(id)); + value.jgdManager.history.setSessionSource( + id, + id === 'other-session' ? owner('b', '456') : source, + ); + deliver(id, { type: 'frame' }); + } + const viewer = value.jgdManager.getViewers()[0]; + const selectedPlot = value.jgdManager.history.currentPlot(viewer.sessionId); + for (const item of panels) { + item.post.resetHistory(); + } + const measure = metrics.firstCall.args[0]; + const measured = measure( + { type: 'metrics_request', id: 7, kind: 'strWidth', str: 'W' }, + 96, + 'device-1', + ); + sinon.assert.calledOnce(panels[0].post); + sinon.assert.notCalled(panels[1].post); + const request = panels[0].post.lastCall.args[0] as { type: string; id: number }; + assert.strictEqual(request.type, 'metrics_request'); + await panels[0].receive({ + type: 'metrics_response', + id: request.id, + originalId: 7, + width: 42, + ascent: 10, + descent: 3, + }); + assert.deepStrictEqual(await measured, { + type: 'metrics_response', + id: 7, + width: 42, + ascent: 10, + descent: 3, + }); + assert.strictEqual(viewer.sessionId, 'device-2'); + assert.strictEqual(value.jgdManager.history.currentPlot(viewer.sessionId), selectedPlot); + assert.strictEqual(panels.length, 2); + + panels[0].panel.dispose(); + assert.deepStrictEqual(await measure({ type: 'metrics_request', id: 8 }, 96, 'device-1'), { + type: 'metrics_response', + id: 8, + width: 0, + ascent: 0, + descent: 0, + }); + assert.strictEqual(panels.length, 2, 'metrics must not reopen a closed viewer'); + }); + test('JGD reattachment after reload keeps closed viewers closed until Show Viewer or a new plot', async () => { const source = owner('a', '123'); focus(source); From 8227ef1f81ba4ebe8afa65cce88d9fb0d93bc689 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:22:57 +1100 Subject: [PATCH 10/32] fix(plot): reconnect explicitly opened VS Code JGD devices --- sess/R/hooks.R | 11 +++ src/test/node/plotReconnect.test.ts | 110 ++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 79239a5a9..18d4e2cce 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -265,6 +265,17 @@ runtime_start <- function(use_rstudioapi = TRUE, # each retained device and replay its current plot with the same logical ID. previous <- grDevices::dev.cur() retained <- .runtime_state()$devices + # Explicit jgd::jgd() calls bypass our device factory. Recover live devices + # belonging to VS Code too, leaving other renderers' devices untouched. + devs <- grDevices::dev.list() + for (id in devs[names(devs) == "jgd"]) { + if (any(vapply(retained, function(entry) entry$id == id, logical(1)))) next + grDevices::dev.set(id) + info <- tryCatch(jgd::jgd_server_info(), error = function(e) NULL) + if (identical(info$server_name, "jgd-vscode")) { + retained[[length(retained) + 1L]] <- list(id = unname(id), name = "jgd") + } + } state$devices <- list() for (entry in retained) { devs <- grDevices::dev.list() diff --git a/src/test/node/plotReconnect.test.ts b/src/test/node/plotReconnect.test.ts index 4c1f8fdb1..ccb5e38d2 100644 --- a/src/test/node/plotReconnect.test.ts +++ b/src/test/node/plotReconnect.test.ts @@ -56,21 +56,29 @@ suite('Live R plot reconnect', function () { } }); - for (const [backend, reuseDevice] of [ - ['jgd', false], - ['jgd', true], - ['standard', false], - ['httpgd', false], + for (const [backend, deviceMode] of [ + ['jgd', 'default'], + ['jgd', 'reused'], + ['jgd', 'explicit'], + ['standard', 'default'], + ['httpgd', 'default'], ] as const) { + const reuseDevice = deviceMode === 'reused'; + const explicitDevice = deviceMode === 'explicit'; const name = reuseDevice ? 'jgd preserves closed-device history when R reuses a device number' - : `${backend} retains its live R plot across renderer and session reconnect`; + : explicitDevice + ? 'explicit JGD devices resume plot delivery after reconnect' + : `${backend} retains its live R plot across renderer and session reconnect`; test(name, async () => { let history = new PlotHistory(); let jgd: JgdSocketServer; let server: net.Server; let r: ChildProcessWithoutNullStreams | undefined; let httpgd: HttpgdClient | undefined; + let externalRenderer: net.Server | undefined; + const externalSockets = new Set(); + const externalFrames: JgdMessage[] = []; const sockets = new Set(); const notifications: Array<{ method: string; @@ -164,6 +172,9 @@ suite('Live R plot reconnect', function () { `Sys.setenv(JGD_SOCKET=${JSON.stringify(jgd!.getSocketPath())}); sess::connect(${JSON.stringify(endpoint)}, use_rstudioapi=FALSE, plot_backend="${backend}"); TRUE`, ); await connect(); + if (explicitDevice) { + await evaluate('jgd::jgd(); TRUE'); + } await evaluate('plot(1:3, main="first plot"); TRUE'); if (backend === 'jgd') { await until(() => (history.count() === 1 ? true : undefined)); @@ -188,7 +199,9 @@ suite('Live R plot reconnect', function () { ? history.getActiveSessionId() : undefined, ); - assert.ok(history.sessionSource(originalId)?.sessionId); + if (!explicitDevice) { + assert.ok(history.sessionSource(originalId)?.sessionId); + } } else if (backend === 'httpgd') { httpgdUrl = await until( () => notifications.find((item) => item.method === 'httpgd')?.params.url, @@ -210,6 +223,49 @@ suite('Live R plot reconnect', function () { ); } const deviceIds = history.sessionIds(); + if (explicitDevice) { + externalRenderer = net.createServer((socket) => { + externalSockets.add(socket); + socket.on('close', () => externalSockets.delete(socket)); + socket.write( + JSON.stringify({ + type: 'server_info', + serverName: 'other-renderer', + protocolVersion: 1, + transport: 'tcp', + }) + '\n', + ); + let buffer = ''; + socket.on('data', (data) => { + buffer += data.toString(); + let end: number; + while ((end = buffer.indexOf('\n')) >= 0) { + const message = JSON.parse(buffer.slice(0, end)) as JgdMessage; + buffer = buffer.slice(end + 1); + if (message.type === 'metrics_request') { + socket.write( + JSON.stringify({ + type: 'metrics_response', + id: message.id, + width: 8, + ascent: 8, + descent: 2, + }) + '\n', + ); + } else if (message.type === 'frame') { + externalFrames.push(message); + } + } + }); + }); + await new Promise((resolve) => + externalRenderer!.listen(0, '127.0.0.1', resolve), + ); + const port = (externalRenderer.address() as net.AddressInfo).port; + await evaluate( + `own_device <- dev.cur(); jgd::jgd(socket="tcp://127.0.0.1:${port}"); external_device <- dev.cur(); plot(1:2, main="external renderer"); dev.set(own_device); TRUE`, + ); + } const saved: unknown = JSON.parse(JSON.stringify(history.snapshot())); await stopServers(); await evaluate('sess:::.transport_disconnect(silent=TRUE, reconnect=TRUE); TRUE'); @@ -218,7 +274,39 @@ suite('Live R plot reconnect', function () { notifications.length = 0; await startServers(); await connect(); - if (backend === 'jgd') { + if (explicitDevice) { + // The untracked device has no logical ID yet. Reopening + // assigns one, while retaining its archived JSON history. + const restoredId = await until(() => + history.sessionIds().find((id) => !deviceIds.includes(id)), + ); + assert.ok(history.sessionSource(restoredId)?.sessionId); + assert.ok(jgd!.isConnected(restoredId)); + assert.equal(history.count(restoredId), 1); + assert.equal(history.count(originalId), 2); + assert.ok( + JSON.stringify(history.latestPlot(restoredId)?.ops).includes( + 'before reload', + ), + ); + await evaluate('plot(2:4, main="after reload"); TRUE'); + await until(() => (history.count(restoredId) === 2 ? true : undefined)); + assert.ok( + JSON.stringify(history.latestPlot(restoredId)?.ops).includes( + 'after reload', + ), + ); + await evaluate( + 'restored_device <- dev.cur(); stopifnot(restored_device != external_device); dev.set(external_device); stopifnot(jgd::jgd_server_info()$server_name == "other-renderer"); plot(2:1, main="still external"); dev.set(restored_device); TRUE', + ); + await until(() => + externalFrames.some((message) => + JSON.stringify(message.plot?.ops).includes('still external'), + ) + ? true + : undefined, + ); + } else if (backend === 'jgd') { await until(() => jgd!.isConnected(originalId!) && history.latestPlot(originalId!)?.rIndex === 0 @@ -327,6 +415,12 @@ suite('Live R plot reconnect', function () { await exited; } await stopServers(); + for (const socket of externalSockets) { + socket.destroy(); + } + if (externalRenderer) { + await new Promise((resolve) => externalRenderer!.close(() => resolve())); + } } }); } From 36164c71d6cd02af1e6b93bcab19323fb4f7317c Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:24:54 +1100 Subject: [PATCH 11/32] fix(plot): replace stale httpgd viewers when sessions change --- src/plotViewer/httpgdViewer.ts | 15 ++++++-- src/test/suite/httpgdViewer.test.ts | 56 +++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index ea849e4de..7b3988a01 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -162,7 +162,12 @@ export class HttpgdManager { } if (ind >= 0) { const viewer = this.viewers.splice(ind, 1)[0]; - if (viewer.token === token) { + const owner = viewer.connection.source; + const sameSession = + !source || + !owner || + sessionProcessIdentity(source) === sessionProcessIdentity(owner); + if (!viewer.isDisposed && viewer.token === token && sameSession) { viewer.bindSession(source); this.viewers.unshift(viewer); if (!restoring) { @@ -173,8 +178,8 @@ export class HttpgdManager { } return; } - // A restarted device can reuse its port with a new authentication - // token. Retire the connection rather than reusing stale credentials. + // A restarted process can reuse both its port and token. Retire + // disposed or differently owned connections as well as stale credentials. viewer.dispose(); viewer.webviewPanel?.dispose(); this.recentlyActiveViewers = this.recentlyActiveViewers.filter( @@ -283,6 +288,10 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { private sessionDisconnected = false; private restoring: boolean; + get isDisposed(): boolean { + return this.disposed; + } + get connection(): HttpgdConnection { const url = new URL(this.origin); if (this.token) { diff --git a/src/test/suite/httpgdViewer.test.ts b/src/test/suite/httpgdViewer.test.ts index f614f9f57..dc657402d 100644 --- a/src/test/suite/httpgdViewer.test.ts +++ b/src/test/suite/httpgdViewer.test.ts @@ -111,6 +111,62 @@ suite('httpgd viewer requests', () => { assert.strictEqual(manager.viewers[0].token, 'second'); }); + for (const token of ['', '?token=fixed']) { + for (const exited of [false, true]) { + test(`a replacement session gets a new httpgd client (${token || 'no token'}, exited=${exited})`, async () => { + const source = { + sessionId: 'original', + host: 'remote', + pid: '123', + rVer: '4.6.0', + processExited: false, + }; + const url = `http://localhost:1234/live${token}`; + await manager.showViewer(url, source); + const old = manager.viewers[0]; + const disconnect = sandbox.spy(old.api, 'disconnect'); + const closePanel = sandbox.stub(); + old.webviewPanel = { dispose: closePanel } as unknown as vscode.WebviewPanel; + manager.registerActiveViewer(old); + if (exited) { + old.dispose(); + } + const replacement = { ...source, sessionId: 'replacement', pid: '456' }; + await manager.showViewer(url, replacement); + assert.strictEqual(manager.viewers.length, 1); + assert.notStrictEqual(manager.viewers[0], old); + assert.deepStrictEqual(manager.viewers[0].connection.source, replacement); + assert.strictEqual(manager.viewers[0].isDisposed, false); + assert.strictEqual(manager.recentlyActiveViewers.includes(old), false); + sinon.assert.calledOnce(disconnect); + sinon.assert.calledOnce(closePanel); + sinon.assert.calledTwice(connect); + old.webviewPanel = undefined; + }); + } + } + + test('a disposed httpgd client is replaced even when ownership and credentials match', async () => { + const source = { + sessionId: 'owner', + host: 'remote', + pid: '123', + rVer: '4.6.0', + processExited: false, + }; + const url = 'http://localhost:1234/live?token=fixed'; + await manager.showViewer(url, source); + const old = manager.viewers[0]; + const show = sandbox.stub(old, 'show'); + await manager.showViewer(url, { ...source }); + assert.strictEqual(manager.viewers[0], old, 'same live process still reuses its viewer'); + sinon.assert.calledOnce(show); + old.dispose(); + await manager.showViewer(url, source); + assert.notStrictEqual(manager.viewers[0], old); + sinon.assert.calledTwice(connect); + }); + function lifetime() { const source = { sessionId: 'owner', From ef2a467e890eff08618bd8d6eb326e997a650263 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:25:14 +1100 Subject: [PATCH 12/32] chore(sess): bump version to 3.4.0.9002 --- sess/DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index 1e5ddc1b8..3dfa191d4 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -1,7 +1,7 @@ Package: sess Type: Package Title: High-Performance IPC Bridge for R Sessions -Version: 3.4.0.9001 +Version: 3.4.0.9002 Authors@R: c( person(given = "Randy", family = "Lai", From 4fdafba683d106488cf3b4a3c8f29e10b58ee9ef Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:46:20 +1100 Subject: [PATCH 13/32] fix(plot): share JGD history and sizing across session devices --- sess/DESCRIPTION | 2 +- sess/R/hooks.R | 17 +- src/plotViewer/jgdPlotHistory.ts | 230 +++++++++++++++++++++---- src/plotViewer/jgdSocketServer.ts | 30 +++- src/plotViewer/jgdViewer.ts | 16 +- src/test/node/plotReconnect.test.ts | 33 +++- src/test/suite/jgdPlotHistory.test.ts | 130 ++++++++++++++ src/test/suite/jgdSocketServer.test.ts | 61 ++++++- src/test/suite/plotRestoration.test.ts | 34 +++- 9 files changed, 490 insertions(+), 63 deletions(-) diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index 3dfa191d4..c68319ce4 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -1,7 +1,7 @@ Package: sess Type: Package Title: High-Performance IPC Bridge for R Sessions -Version: 3.4.0.9002 +Version: 3.4.0.9003 Authors@R: c( person(given = "Randy", family = "Lai", diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 18d4e2cce..29083eaf0 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -231,7 +231,7 @@ runtime_start <- function(use_rstudioapi = TRUE, package_version(getNamespaceVersion("httpgd")) >= "2.0.1" selected_backend <- .select_plot_backend(plot_backend, has_httpgd, has_jgd) if (selected_backend == "jgd") { - open_jgd_device <- function(key = NULL, recorded = NULL) { + open_jgd_device <- function(key = NULL, recorded = NULL, previous_device_id = NULL) { jgd::jgd() .runtime_track_device() if (is.null(key)) { @@ -249,7 +249,8 @@ runtime_start <- function(use_rstudioapi = TRUE, sessionId = .session_id(), host = unname(Sys.info()[["nodename"]]), pid = as.character(Sys.getpid()), rVer = sprintf("%s.%s", R.version$major, R.version$minor), - processExited = FALSE, deviceId = key, restoring = restoring + processExited = FALSE, deviceId = key, restoring = restoring, + previousDeviceId = previous_device_id )), auto_unbox = TRUE)) } } @@ -273,7 +274,15 @@ runtime_start <- function(use_rstudioapi = TRUE, grDevices::dev.set(id) info <- tryCatch(jgd::jgd_server_info(), error = function(e) NULL) if (identical(info$server_name, "jgd-vscode")) { - retained[[length(retained) + 1L]] <- list(id = unname(id), name = "jgd") + # JGD retains the renderer welcome even after its socket disconnects. + # Carry its original identity so reconnect preserves the saved history. + previous_device_id <- unname(info$server_info["deviceId"]) + if (length(previous_device_id) != 1L || is.na(previous_device_id) || !nzchar(previous_device_id)) { + previous_device_id <- NULL + } + retained[[length(retained) + 1L]] <- list( + id = unname(id), name = "jgd", previous_device_id = previous_device_id + ) } } state$devices <- list() @@ -288,7 +297,7 @@ runtime_start <- function(use_rstudioapi = TRUE, grDevices::dev.set(entry$id) recorded <- tryCatch(grDevices::recordPlot(), error = function(e) NULL) tryCatch(grDevices::dev.off(entry$id), error = function(e) NULL) - open_jgd_device(entry$key, recorded) + open_jgd_device(entry$key, recorded, entry$previous_device_id) if (identical(unname(previous), entry$id)) previous <- grDevices::dev.cur() } if (previous %in% grDevices::dev.list()) grDevices::dev.set(previous) diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts index 4f091ef94..2c0f1504a 100644 --- a/src/plotViewer/jgdPlotHistory.ts +++ b/src/plotViewer/jgdPlotHistory.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events'; +import { sessionProcessIdentity } from '../sessionIdentity'; import { isViewerSessionSource, ViewerSessionSource } from '../viewerSession'; export interface PlotFrame { @@ -15,21 +16,37 @@ export interface PlotFrame { rIndex?: number; } -interface SessionHistory { +interface DeviceHistory { plots: PlotFrame[]; - currentIndex: number; latestDeleted: boolean; label?: string; source?: ViewerSessionSource; } +interface Timeline { + plots: PlotFrame[]; + currentIndex: number; + devices: Set; +} + export interface PlotHistoryState { activeSessionId: string; - sessions: Array; + sessions: Array; + timelines?: Array<{ + deviceId: string; + plots: Array<{ deviceId: string; index: number }>; + currentIndex: number; + }>; } export class PlotHistory { - private sessions: Map = new Map(); + private sessions: Map = new Map(); + // Device histories index replay targets; timelines own session navigation. + // Both reference the same frames, so operations are never copied for grouping. + private timelines = new Map(); + private owners = new Map(); + private order = new WeakMap(); + private sequence = 0; private activeSessionId: string = ''; private maxPlots: number; private emitter = new EventEmitter(); @@ -50,10 +67,37 @@ export class PlotHistory { return this.sessions.get(sessionId)?.source; } + restoreDeviceIdentity(previousId: string, id: string, source: ViewerSessionSource): void { + const device = this.sessions.get(previousId); + if ( + !device?.source || + this.sessions.has(id) || + sessionProcessIdentity(device.source) !== sessionProcessIdentity(source) + ) { + return; + } + // Explicit jgd() devices first acquire a sess logical ID on reconnect. + // Transfer their existing frame index and timeline membership together. + const timeline = this.timelines.get(previousId)!; + this.sessions.delete(previousId); + this.sessions.set(id, device); + this.timelines.delete(previousId); + this.timelines.set(id, timeline); + timeline.devices.delete(previousId); + timeline.devices.add(id); + for (const plot of device.plots) { + plot.sessionId = id; + } + if (this.activeSessionId === previousId) { + this.activeSessionId = id; + } + } + setSessionSource(sessionId: string, source: ViewerSessionSource): void { const history = this.sessions.get(sessionId); if (history && !history.source) { history.source = { ...source }; + this.bindOwner(sessionId, source); this.emitter.emit('change', sessionId); } } @@ -70,7 +114,22 @@ export class PlotHistory { snapshot(): PlotHistoryState { return { activeSessionId: this.activeSessionId, - sessions: [...this.sessions].map(([id, history]) => ({ id, ...history })), + sessions: [...this.sessions].map(([id, history]) => ({ + id, + ...history, + currentIndex: Math.max( + history.plots.length ? 0 : -1, + history.plots.indexOf(this.currentPlot(id)!), + ), + })), + timelines: [...new Set(this.timelines.values())].map((timeline) => ({ + deviceId: timeline.devices.values().next().value!, + plots: timeline.plots.map((plot) => ({ + deviceId: plot.sessionId, + index: this.sessions.get(plot.sessionId)!.plots.indexOf(plot), + })), + currentIndex: timeline.currentIndex, + })), }; } @@ -97,10 +156,17 @@ export class PlotHistory { ) { continue; } - const removed = Math.max(0, entry.plots.length - this.maxPlots); + const plots = entry.plots.map((plot) => ({ ...plot, sessionId: entry.id })); + for (const plot of plots) { + this.order.set(plot, this.sequence++); + } + this.timelines.set(entry.id, { + plots: [...plots], + currentIndex: entry.currentIndex, + devices: new Set([entry.id]), + }); this.sessions.set(entry.id, { - plots: entry.plots.slice(removed), - currentIndex: entry.plots.length ? Math.max(0, entry.currentIndex - removed) : -1, + plots, latestDeleted: entry.latestDeleted, label: typeof entry.label === 'string' ? entry.label.slice(0, 256) : undefined, source: isViewerSessionSource(entry.source) ? { ...entry.source } : undefined, @@ -110,9 +176,88 @@ export class PlotHistory { state.activeSessionId && this.sessions.has(state.activeSessionId) ? state.activeSessionId : (this.sessionIds().at(-1) ?? ''); + // Older snapshots only stored per-device histories. Group those by owner; + // newer snapshots also retain the exact interleaving and session cursor. + for (const [id, device] of this.sessions) { + if (device.source) { + this.bindOwner(id, device.source, false); + } + } + if (Array.isArray(state.timelines)) { + for (const saved of state.timelines) { + const timeline = this.timelines.get(saved?.deviceId); + if (!timeline || !Array.isArray(saved.plots)) { + continue; + } + const plots = saved.plots.map((ref) => + ref && this.timelines.get(ref.deviceId) === timeline + ? this.sessions.get(ref.deviceId)?.plots[ref.index] + : undefined, + ); + if ( + plots.length !== timeline.plots.length || + plots.some((plot) => !plot) || + new Set(plots).size !== plots.length || + !Number.isInteger(saved.currentIndex) || + saved.currentIndex < (plots.length ? 0 : -1) || + saved.currentIndex >= plots.length + ) { + continue; + } + timeline.plots = plots as PlotFrame[]; + timeline.currentIndex = saved.currentIndex; + } + } + for (const timeline of new Set(this.timelines.values())) { + for (const plot of timeline.plots) { + this.order.set(plot, this.sequence++); + } + this.trim(timeline); + } this.emitter.emit('change'); } + private bindOwner(id: string, source: ViewerSessionSource, trim = true): void { + const incoming = this.timelines.get(id)!; + const key = sessionProcessIdentity(source); + const existing = this.owners.get(key); + if (!existing) { + this.owners.set(key, incoming); + } else if (existing !== incoming) { + const selected = incoming.devices.has(this.activeSessionId) + ? incoming.plots[incoming.currentIndex] + : existing.plots[existing.currentIndex]; + existing.plots.push(...incoming.plots); + existing.plots.sort((a, b) => this.order.get(a)! - this.order.get(b)!); + existing.currentIndex = selected + ? existing.plots.indexOf(selected) + : existing.plots.length - 1; + for (const device of incoming.devices) { + existing.devices.add(device); + this.timelines.set(device, existing); + } + if (trim) { + this.trim(existing); + } + } + } + + private trim(timeline: Timeline): void { + while (timeline.plots.length > this.maxPlots) { + this.removeFrame(timeline.plots.shift()!); + timeline.currentIndex = Math.max(0, timeline.currentIndex - 1); + } + } + + private removeFrame(plot: PlotFrame): void { + const device = this.sessions.get(plot.sessionId)!; + const index = device.plots.indexOf(plot); + if (index === device.plots.length - 1) { + device.latestDeleted = true; + } + device.plots.splice(index, 1); + } + onDidChange(listener: (sessionId?: string) => void): { dispose(): void } { this.emitter.on('change', listener); return { @@ -125,30 +270,33 @@ export class PlotHistory { addPlot(sessionId: string, plot: PlotFrame) { let session = this.sessions.get(sessionId); if (!session) { - session = { plots: [], currentIndex: -1, latestDeleted: false }; + session = { plots: [], latestDeleted: false }; this.sessions.set(sessionId, session); + this.timelines.set(sessionId, { + plots: [], + currentIndex: -1, + devices: new Set([sessionId]), + }); } + plot.sessionId = sessionId; + this.order.set(plot, this.sequence++); session.latestDeleted = false; session.plots.push(plot); - while (session.plots.length > this.maxPlots) { - session.plots.shift(); - } - session.currentIndex = session.plots.length - 1; + const timeline = this.timelines.get(sessionId)!; + timeline.plots.push(plot); + timeline.currentIndex = timeline.plots.length - 1; + this.trim(timeline); this.activeSessionId = sessionId; this.emitter.emit('change', sessionId); } replaceCurrent(sessionId: string, plot: PlotFrame) { - const session = this.sessions.get(sessionId); - if (!session || session.plots.length === 0) { + const old = this.currentPlot(sessionId); + if (!old) { return this.addPlot(sessionId, plot); } - const old = session.plots[session.currentIndex]; - if (old?.rIndex !== undefined) { - plot.rIndex = old.rIndex; - } - session.plots[session.currentIndex] = plot; + this.replaceFrame(old, plot); this.activeSessionId = sessionId; this.emitter.emit('change', sessionId); } @@ -186,7 +334,7 @@ export class PlotHistory { return false; } plot.rIndex = rIndex; - session.plots[idx] = plot; + this.replaceFrame(session.plots[idx], plot); this.emitter.emit('change', sessionId); return true; } @@ -213,13 +361,21 @@ export class PlotHistory { } else if (expectedRIndex !== undefined) { plot.rIndex = expectedRIndex; } - session.plots[session.plots.length - 1] = plot; + this.replaceFrame(old, plot); this.emitter.emit('change', sessionId); return true; } + private replaceFrame(old: PlotFrame, plot: PlotFrame): void { + // Preserve reference identity in both indexes and the original arrival order. + const rIndex = old.rIndex ?? plot.rIndex; + Object.assign(old, plot, { sessionId: old.sessionId, rIndex }); + } + + // Navigation accepts any device ID in the session; replay updates above + // remain device-specific because R plot indices are only unique per device. currentPlot(sessionId = this.activeSessionId): PlotFrame | null { - const session = this.sessions.get(sessionId); + const session = this.timelines.get(sessionId); if (!session || session.currentIndex < 0) { return null; } @@ -253,7 +409,7 @@ export class PlotHistory { } private navigateTo(index: number, sessionId: string): PlotFrame | null { - const session = this.sessions.get(sessionId); + const session = this.timelines.get(sessionId); if ( !session || index < 0 || @@ -272,7 +428,7 @@ export class PlotHistory { } currentIndex(sessionId = this.activeSessionId): number { - const session = this.sessions.get(sessionId); + const session = this.timelines.get(sessionId); return session ? session.currentIndex + 1 : 0; } @@ -282,16 +438,20 @@ export class PlotHistory { } count(sessionId = this.activeSessionId): number { - const session = this.sessions.get(sessionId); + const session = this.timelines.get(sessionId); return session ? session.plots.length : 0; } clear(sessionId = this.activeSessionId) { - const session = this.sessions.get(sessionId); - if (session) { - session.plots = []; - session.currentIndex = -1; - session.latestDeleted = false; + const timeline = this.timelines.get(sessionId); + if (timeline) { + for (const id of timeline.devices) { + const device = this.sessions.get(id)!; + device.latestDeleted = device.latestDeleted || device.plots.length > 0; + device.plots = []; + } + timeline.plots = []; + timeline.currentIndex = -1; } this.emitter.emit('change', sessionId); } @@ -302,15 +462,11 @@ export class PlotHistory { } removeCurrent(sessionId = this.activeSessionId): PlotFrame | null { - const session = this.sessions.get(sessionId); + const session = this.timelines.get(sessionId); if (!session || session.plots.length === 0) { return null; } - const wasLatest = session.currentIndex === session.plots.length - 1; - session.plots.splice(session.currentIndex, 1); - if (wasLatest) { - session.latestDeleted = true; - } + this.removeFrame(session.plots.splice(session.currentIndex, 1)[0]); if (session.plots.length === 0) { session.currentIndex = -1; this.emitter.emit('change', sessionId); diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts index 1a07beb3c..22cd6b219 100644 --- a/src/plotViewer/jgdSocketServer.ts +++ b/src/plotViewer/jgdSocketServer.ts @@ -103,15 +103,17 @@ export class JgdSocketServer { } handleResize(w: number, h: number, sessionId: string) { - const idx = this.history.currentIndex(sessionId); - const total = this.history.count(sessionId); - if ((total > 0 && idx < total) || (total > 0 && this.history.isLatestDeleted(sessionId))) { - const rIndex = this.history.currentRIndex(sessionId); - if (rIndex !== undefined) { - this.broadcastResize(w, h, rIndex, sessionId); + const plot = this.history.currentPlot(sessionId); + const deviceId = plot?.sessionId ?? sessionId; + if ( + plot && + (plot !== this.history.latestPlot(deviceId) || this.history.isLatestDeleted(deviceId)) + ) { + if (plot.rIndex !== undefined) { + this.broadcastResize(w, h, plot.rIndex, deviceId); } } else { - this.broadcastResize(w, h, undefined, sessionId); + this.broadcastResize(w, h, undefined, deviceId); } } @@ -230,6 +232,7 @@ export class JgdSocketServer { serverName: SERVER_NAME, protocolVersion: 1, transport: isWindows ? 'npipe' : 'unix', + serverInfo: { deviceId: sessionId }, }; socket.write(JSON.stringify(welcome) + '\n'); @@ -298,6 +301,19 @@ export class JgdSocketServer { rVer: identity.rVer, processExited: false, }; + if ( + 'restoring' in identity && + identity.restoring === true && + 'previousDeviceId' in identity && + typeof identity.previousDeviceId === 'string' && + !this.isConnected(identity.previousDeviceId) + ) { + this.history.restoreDeviceIdentity( + identity.previousDeviceId, + id, + session.source, + ); + } this.sessions.set(id, session); // Historical R replay indices belong to the old // physical device, not this replacement socket. diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 36385d4d2..619c69a88 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -139,6 +139,9 @@ export class JgdManager { } this.started = true; this.server.setOnFrame((sessionId, message) => { + // Resolve the owner before reading the session cursor: the first frame + // from an explicit jgd() device may join an existing timeline here. + this.sourceForDevice(sessionId); const current = this.history.currentPlot(sessionId); if (current) { const metadata = message.ext?.vscodeR; @@ -246,7 +249,11 @@ export class JgdManager { } private findViewer(id: string): JgdViewer | undefined { - return [...this.viewers.values()].find((viewer) => viewer.sessionId === id); + const source = this.history.sessionSource(id); + return ( + this.viewers.get(id) ?? + (source ? this.viewers.get(sessionProcessIdentity(source)) : undefined) + ); } getViewer(sessionId = this.history.getActiveSessionId()): JgdViewer | null { @@ -261,7 +268,7 @@ export class JgdManager { const key = this.viewerKey(sessionId); let viewer = this.viewers.get(key); // A legacy device may acquire its owner after its first frame. - const previous = this.findViewer(sessionId); + const previous = this.viewers.get(sessionId); if (previous && previous !== viewer) { for (const [oldKey, value] of this.viewers) { if (value === previous) { @@ -423,6 +430,11 @@ export class JgdViewer implements PlotViewer { } this.sendPlotToWebview(plot); this.updateToolbar(); + // A device can join an already-sized panel without a ResizeObserver event. + // The server deduplicates requests per device while R is still drawing. + if (plot.device.width !== this.panelWidth || plot.device.height !== this.panelHeight) { + this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId); + } } updateToolbar(force = false) { diff --git a/src/test/node/plotReconnect.test.ts b/src/test/node/plotReconnect.test.ts index ccb5e38d2..83b63edcb 100644 --- a/src/test/node/plotReconnect.test.ts +++ b/src/test/node/plotReconnect.test.ts @@ -189,7 +189,7 @@ suite('Live R plot reconnect', function () { if (reuseDevice) { await evaluate('stopifnot(dev.cur() == old_device); TRUE'); } - const plotsBeforeReload = reuseDevice ? 1 : 2; + const plotsBeforeReload = 2; let originalId: string | undefined; let httpgdUrl: string | undefined; if (backend === 'jgd') { @@ -199,7 +199,17 @@ suite('Live R plot reconnect', function () { ? history.getActiveSessionId() : undefined, ); - if (!explicitDevice) { + if (explicitDevice) { + // The extension resolves explicit devices through the attached + // session and native PID label before persisting their history. + const source = await evaluate( + 'list(sessionId=sess:::.session_id(), host=unname(Sys.info()[["nodename"]]), pid=as.character(Sys.getpid()), rVer=as.character(getRversion()), processExited=FALSE)', + ); + history.setSessionSource( + originalId, + source as unknown as import('../../viewerSession').ViewerSessionSource, + ); + } else { assert.ok(history.sessionSource(originalId)?.sessionId); } } else if (backend === 'httpgd') { @@ -275,22 +285,29 @@ suite('Live R plot reconnect', function () { await startServers(); await connect(); if (explicitDevice) { - // The untracked device has no logical ID yet. Reopening - // assigns one, while retaining its archived JSON history. + // Reopening assigns a logical ID and transfers the existing + // timeline entry, rather than duplicating its current plot. const restoredId = await until(() => history.sessionIds().find((id) => !deviceIds.includes(id)), ); assert.ok(history.sessionSource(restoredId)?.sessionId); assert.ok(jgd!.isConnected(restoredId)); - assert.equal(history.count(restoredId), 1); - assert.equal(history.count(originalId), 2); + assert.equal(history.count(restoredId), 2); + assert.equal(history.count(originalId), 0); + assert.equal(history.sessionIds().length, 1); + history.navigateFirst(restoredId); + assert.equal(history.currentRIndex(restoredId), undefined); + assert.ok( + JSON.stringify(history.currentPlot(restoredId)?.ops).includes('first plot'), + ); + history.navigateLast(restoredId); assert.ok( JSON.stringify(history.latestPlot(restoredId)?.ops).includes( 'before reload', ), ); await evaluate('plot(2:4, main="after reload"); TRUE'); - await until(() => (history.count(restoredId) === 2 ? true : undefined)); + await until(() => (history.count(restoredId) === 3 ? true : undefined)); assert.ok( JSON.stringify(history.latestPlot(restoredId)?.ops).includes( 'after reload', @@ -332,7 +349,7 @@ suite('Live R plot reconnect', function () { history.navigateFirst(originalId); assert.equal( history.currentRIndex(originalId), - reuseDevice ? 0 : undefined, + reuseDevice ? closedHistory!.plots[0].rIndex : undefined, 'old replay indices must not target the replacement device', ); history.navigateLast(originalId); diff --git a/src/test/suite/jgdPlotHistory.test.ts b/src/test/suite/jgdPlotHistory.test.ts index f46dd3b72..3d77f4a7a 100644 --- a/src/test/suite/jgdPlotHistory.test.ts +++ b/src/test/suite/jgdPlotHistory.test.ts @@ -377,6 +377,136 @@ suite('JGD PlotHistory', () => { }); }); + suite('session timelines', () => { + const owner = { + sessionId: 'r', + host: 'localhost', + pid: '123', + rVer: '4.5.0', + processExited: false, + }; + function add(device: string, label: string, rIndex = 0) { + history.addPlot(device, { ...makePlot(label), rIndex }); + history.setSessionSource(device, owner); + } + + test('interleaves devices and navigates one shared cursor with device-local replay indices', () => { + add('a', 'A0'); + add('b', 'B0'); + add('a', 'A1', 1); + assert.strictEqual(history.count('a'), 3); + assert.strictEqual(history.count('b'), 3); + assert.strictEqual(history.navigatePrevious('b')?.device.bg, 'B0'); + assert.strictEqual(history.currentPlot('a')?.sessionId, 'b'); + history.replaceAtIndex('a', 0, makePlot('A resized')); + assert.strictEqual(history.currentPlot('b')?.device.bg, 'B0'); + assert.strictEqual(history.navigatePrevious('a')?.device.bg, 'A resized'); + assert.strictEqual(history.currentPlot('a')?.sessionId, 'a'); + assert.strictEqual(history.navigateLast('b')?.device.bg, 'A1'); + }); + + test('incremental frames and late resize replies retain the selected plot', () => { + add('a', 'A0'); + add('b', 'B0'); + history.appendOps('a', { ...makePlot('A0'), ops: [{ op: 'line' }] }); + history.replaceLatest('a', makePlot('A resized'), 0); + assert.strictEqual(history.currentPlot('a')?.device.bg, 'B0'); + assert.strictEqual(history.latestPlot('a')?.device.bg, 'A resized'); + assert.strictEqual(history.count('b'), 2); + }); + + test('deleting the latest frame of an older device rejects its stale replies', () => { + add('a', 'A0'); + add('b', 'B0'); + history.navigateFirst('b'); + history.removeCurrent('b'); + assert.strictEqual(history.isLatestDeleted('a'), true); + assert.strictEqual(history.replaceLatest('a', makePlot('stale')), false); + assert.strictEqual(history.appendOps('a', makePlot('stale')), false); + assert.strictEqual(history.currentPlot('a')?.device.bg, 'B0'); + add('a', 'A1', 1); + assert.strictEqual(history.count('b'), 2); + }); + + test('clear removes the entire session and rejects in-flight device frames', () => { + add('a', 'A0'); + add('b', 'B0'); + history.addPlot('other-session', makePlot('other')); + history.clear('a'); + assert.strictEqual(history.count('b'), 0); + assert.strictEqual(history.replaceLatest('a', makePlot('stale')), false); + assert.strictEqual(history.replaceLatest('b', makePlot('stale')), false); + assert.strictEqual(history.count('other-session'), 1); + }); + + test('applies the history limit across devices without resurrecting evicted plots', () => { + history = new PlotHistory(2); + add('a', 'A0'); + add('b', 'B0'); + add('b', 'B1', 1); + assert.strictEqual(history.count('a'), 2); + assert.strictEqual(history.latestPlot('a'), undefined); + assert.strictEqual(history.replaceLatest('a', makePlot('stale')), false); + assert.strictEqual(history.navigateFirst('a')?.device.bg, 'B0'); + }); + + test('late owner binding preserves arrival order', () => { + history.addPlot('a', makePlot('A0')); + history.addPlot('b', makePlot('B0')); + history.addPlot('a', makePlot('A1')); + history.setSessionSource('b', owner); + history.setSessionSource('a', owner); + assert.strictEqual(history.navigateFirst('a')?.device.bg, 'A0'); + assert.strictEqual(history.navigateNext('a')?.device.bg, 'B0'); + assert.strictEqual(history.navigateNext('b')?.device.bg, 'A1'); + }); + + test('reload preserves interleaving and cursor, including a reduced history limit', () => { + add('a', 'A0'); + add('b', 'B0'); + add('a', 'A1', 1); + add('b', 'B1', 1); + history.navigatePrevious('a'); + const saved = JSON.parse(JSON.stringify(history.snapshot())) as unknown; + for (const limit of [50, 2]) { + const restored = new PlotHistory(limit); + restored.restore(saved); + assert.strictEqual(restored.count('a'), Math.min(limit, 4)); + assert.strictEqual(restored.currentPlot('b')?.device.bg, 'A1'); + assert.strictEqual(restored.navigateNext('a')?.device.bg, 'B1'); + } + }); + + test('reconnect identity transfer preserves history and rejects another owner', () => { + add('explicit', 'A0'); + add('other', 'B0'); + history.navigateFirst('other'); + history.restoreDeviceIdentity('explicit', 'wrong', { ...owner, pid: '456' }); + assert.strictEqual(history.sessionSource('wrong'), undefined); + history.restoreDeviceIdentity('explicit', 'logical', owner); + history.invalidateReplayIndices('logical'); + history.replaceLatest('logical', { ...makePlot('restored'), rIndex: 0 }); + assert.strictEqual(history.count('explicit'), 0); + assert.strictEqual(history.count('logical'), 2); + assert.strictEqual(history.currentPlot('other')?.sessionId, 'logical'); + assert.strictEqual(history.currentPlot('other')?.device.bg, 'restored'); + assert.strictEqual(history.latestPlot('other')?.device.bg, 'B0'); + }); + + test('legacy snapshots merge devices only when their full owner identities match', () => { + add('a', 'A0'); + add('b', 'B0'); + history.addPlot('c', makePlot('C0')); + history.setSessionSource('c', { ...owner, host: 'another-host' }); + const saved = history.snapshot(); + delete saved.timelines; + const restored = new PlotHistory(); + restored.restore(saved); + assert.strictEqual(restored.count('a'), 2); + assert.strictEqual(restored.count('c'), 1); + }); + }); + suite('events', () => { test('restores each session navigation independently from reload state', () => { history.addPlot('a', makePlot('A1')); diff --git a/src/test/suite/jgdSocketServer.test.ts b/src/test/suite/jgdSocketServer.test.ts index d7b16d969..4a48b146b 100644 --- a/src/test/suite/jgdSocketServer.test.ts +++ b/src/test/suite/jgdSocketServer.test.ts @@ -242,7 +242,7 @@ suite('JGD SocketServer', () => { assert.deepStrictEqual(history.latestPlot(id), latest); }); - test('reconnects the original logical device without merging other sessions or devices', async () => { + test('reconnects the original device within its shared session history', async () => { const identity = { sessionId: 'session-a', host: 'localhost', @@ -279,12 +279,12 @@ suite('JGD SocketServer', () => { const replacement = await connect(); await sendFrame(replacement, 'restored', identity, true); assert.strictEqual(history.sessionIds().length, 3); - assert.strictEqual(history.count(id), 1); + assert.strictEqual(history.count(id), 2); assert.strictEqual(history.latestPlot(id)?.device.bg, 'restored'); await waitMs(50); // Let the old socket close after its replacement connects. assert.ok(server.isConnected(id)); await sendFrame(replacement, 'new plot', identity); - assert.strictEqual(history.count(id), 2); + assert.strictEqual(history.count(id), 3); assert.strictEqual(history.sessionIds().length, 3); }); @@ -335,6 +335,61 @@ suite('JGD SocketServer', () => { ); }); + test('shared navigation resizes the selected device and deduplicates its live size', async () => { + const first = await connect(); + const second = await connect(); + const source = { + sessionId: 'r', + host: 'host', + pid: '123', + rVer: '4.5.0', + processExited: false, + }; + async function send( + client: ClientHelper, + label: string, + deviceId: string, + plotNumber: number, + ) { + client.send( + makePlotMsg(label, 400, 300, { + plotNumber, + newPage: true, + ext: { vscodeR: { ...source, deviceId } }, + }), + ); + client.send({ type: 'metrics_request', id: 42, kind: 'strWidth', str: 'barrier' }); + await client.readLine(); + } + await send(first, 'A0', 'a', 0); + const firstId = history.getActiveSessionId(); + await send(second, 'B0', 'b', 0); + const secondId = history.getActiveSessionId(); + await send(first, 'A1', 'a', 1); + history.navigatePrevious(firstId); // B0 is still B's live plot. + server.handleResize(900, 700, firstId); + assert.deepStrictEqual(JSON.parse(await second.readLine()), { + type: 'resize', + width: 900, + height: 700, + }); + server.handleResize(900, 700, firstId); + second.send({ type: 'metrics_request', id: 43, kind: 'strWidth', str: 'barrier' }); + assert.strictEqual( + (JSON.parse(await second.readLine()) as JgdMessage).type, + 'metrics_response', + ); + history.navigateFirst(secondId); // A0 needs A's snapshot index, not B's. + server.handleResize(900, 700, secondId); + assert.deepStrictEqual(JSON.parse(await first.readLine()), { + type: 'resize', + width: 900, + height: 700, + plotIndex: 0, + }); + assert.strictEqual(history.currentPlot(firstId)?.device.bg, 'A0'); + }); + test('routes normal frame to addPlot', async () => { const client = await connect(); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 7aa5f3a5c..89326fc54 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -405,8 +405,8 @@ suite('Standalone plot restoration', () => { } value.jgdManager.history.addPlot('unknown', frame('unknown')); value.jgdManager.history.addPlot('empty', frame('empty')); - value.jgdManager.history.setSessionSource('empty', a); value.jgdManager.history.clear('empty'); + value.jgdManager.history.setSessionSource('empty', a); for (const { panel } of panels) { panel.dispose(); } @@ -445,6 +445,38 @@ suite('Standalone plot restoration', () => { assert.strictEqual(panels.length, 2, 'one replacement tab after closing'); }); + test('an explicit device joins the session history and gets the existing panel dimensions', async () => { + const value = manager(); + const deliver = startJgd(value); + const history = value.jgdManager.history; + const source = owner('a', '123'); + const resize = sandbox.stub(value.jgdManager.server, 'handleResize'); + sandbox.stub(value.jgdManager.server, 'isConnected').returns(true); + sandbox + .stub(session, 'resolveJgdSessionSource') + .callsFake((label) => (label === 'r-123-2' ? source : undefined)); + history.addPlot('managed', frame('managed', 'first')); + history.setSessionSource('managed', source); + deliver('managed', { type: 'frame' }); + await panels[0].receive({ type: 'resize', width: 900, height: 700 }); + resize.resetHistory(); + history.addPlot('explicit', frame('explicit', 'second')); + history.setSessionLabel('explicit', 'r-123-2'); + deliver('explicit', { type: 'frame', newPage: true }); + assert.strictEqual(panels.length, 1); + assert.strictEqual(history.count('managed'), 2); + sinon.assert.calledOnceWithExactly(resize, 900, 700, 'explicit'); + sinon.assert.calledWith(panels[0].post, { type: 'toolbar', current: 2, total: 2 }); + const viewer = value.jgdManager.getViewers()[0]; + await viewer.handleCommand('prevPlot'); + assert.strictEqual(history.currentPlot('explicit')?.device.bg, 'first'); + history.replaceLatest('explicit', frame('explicit', 'second resized')); + deliver('explicit', { type: 'frame', resizeReplay: true }); + assert.strictEqual(history.currentPlot('explicit')?.device.bg, 'first'); + await viewer.handleCommand('nextPlot'); + assert.strictEqual(history.currentPlot('managed')?.device.bg, 'second resized'); + }); + test('JGD measures an earlier device through its session viewer without switching plots', async () => { const value = manager(); const metrics = sandbox.spy(value.jgdManager.server, 'setMeasureText'); From cb025c4a048a72ca750b5d746e256ded10b299c3 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:00:53 +1100 Subject: [PATCH 14/32] fix(plot): coalesce JGD resizes until frames confirm dimensions --- src/plotViewer/jgdSocketServer.ts | 63 ++++++++++------ src/test/node/jgdResize.test.ts | 100 +++++++++++++++++++++++++ src/test/suite/jgdSocketServer.test.ts | 60 +++++++++++++++ 3 files changed, 201 insertions(+), 22 deletions(-) create mode 100644 src/test/node/jgdResize.test.ts diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts index 22cd6b219..7c957d09e 100644 --- a/src/plotViewer/jgdSocketServer.ts +++ b/src/plotViewer/jgdSocketServer.ts @@ -27,14 +27,20 @@ export interface JgdMessage { gc?: Record; } +interface ResizeRequest { + width: number; + height: number; + plotIndex?: number; +} + interface RSession { id: string; socket: net.Socket; buffer: string; welcomeSent: boolean; - lastResizeW: number; - lastResizeH: number; - lastResizeHadPlotIndex: boolean; + lastResize?: ResizeRequest; + pendingResize?: ResizeRequest; + queuedResize?: ResizeRequest; dpi: number; deviceClosed?: boolean; source?: ViewerSessionSource; @@ -207,9 +213,6 @@ export class JgdSocketServer { socket, buffer: '', welcomeSent: false, - lastResizeW: 0, - lastResizeH: 0, - lastResizeHadPlotIndex: false, dpi: 96, }; this.sessions.set(sessionId, session); @@ -238,8 +241,7 @@ export class JgdSocketServer { const dims = this.getDimensionsFn?.(sessionId); if (dims) { - session.lastResizeW = dims.width; - session.lastResizeH = dims.height; + session.lastResize = dims; socket.write( JSON.stringify({ type: 'resize', @@ -337,6 +339,19 @@ export class JgdSocketServer { ? msg.plotIndex : undefined; + // A frame reports what R actually drew. A resize read by + // JGD during drawing can be buffered without waking its + // idle handler; it must not permanently suppress a new + // request after the frame arrives. + const queuedResize = session.queuedResize; + session.pendingResize = undefined; + session.queuedResize = undefined; + session.lastResize = { + width: plot.device.width, + height: plot.device.height, + plotIndex: isResizeReplay ? plotIndex : undefined, + }; + let accepted = true; // JGD snapshots retain frame extensions, including // restoring=true. Resize routing must take precedence @@ -385,6 +400,16 @@ export class JgdSocketServer { } this.onFrameFn?.(session.id, msg); } + // The viewer callback may have requested a newer size. + // Otherwise send only the last size queued during replay. + if (queuedResize && !session.pendingResize) { + this.broadcastResize( + queuedResize.width, + queuedResize.height, + queuedResize.plotIndex, + session.id, + ); + } } break; } @@ -428,25 +453,19 @@ export class JgdSocketServer { if (!session || session.deviceClosed) { return; } - if (plotIndex !== undefined) { - session.lastResizeW = w; - session.lastResizeH = h; - session.lastResizeHadPlotIndex = true; - const data = JSON.stringify({ type: 'resize', width: w, height: h, plotIndex }) + '\n'; - session.socket.write(data); + const request = { width: w, height: h, plotIndex }; + if (session.pendingResize) { + session.queuedResize = request; return; } - if ( - session.lastResizeW === w && - session.lastResizeH === h && - !session.lastResizeHadPlotIndex + session.lastResize?.width === w && + session.lastResize.height === h && + session.lastResize.plotIndex === plotIndex ) { return; } - session.lastResizeHadPlotIndex = false; - session.lastResizeW = w; - session.lastResizeH = h; - session.socket.write(JSON.stringify({ type: 'resize', width: w, height: h }) + '\n'); + session.pendingResize = request; + session.socket.write(JSON.stringify({ type: 'resize', ...request }) + '\n'); } } diff --git a/src/test/node/jgdResize.test.ts b/src/test/node/jgdResize.test.ts new file mode 100644 index 000000000..0533ea137 --- /dev/null +++ b/src/test/node/jgdResize.test.ts @@ -0,0 +1,100 @@ +import * as assert from 'node:assert/strict'; +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { PlotHistory } from '../../plotViewer/jgdPlotHistory'; +import { JgdSocketServer } from '../../plotViewer/jgdSocketServer'; + +const run = promisify(execFile); + +async function until(read: () => boolean): Promise { + const deadline = Date.now() + 10000; + while (!read()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for JGD to resize while R is idle'); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +suite('Live JGD resize', () => { + test('a large plot reaches the latest dimensions without navigation or manual polling', async function () { + try { + const probe = await run('Rscript', [ + '--vanilla', + '-e', + 'cat(requireNamespace("jgd", quietly=TRUE))', + ]); + if (probe.stdout.trim() !== 'TRUE') { + this.skip(); + } + } catch { + this.skip(); + } + const history = new PlotHistory(); + const server = new JgdSocketServer(history); + let desired = { width: 846, height: 667 }; + const replays: Array<{ width: number; height: number }> = []; + server.setMeasureText((request) => + Promise.resolve({ + type: 'metrics_response', + id: request.id, + width: 8, + ascent: 8, + descent: 2, + }), + ); + server.setOnFrame((id, message) => { + const plot = history.currentPlot(id)!; + if (message.resizeReplay) { + replays.push({ width: plot.device.width, height: plot.device.height }); + } + if (plot.device.width !== desired.width || plot.device.height !== desired.height) { + server.handleResize(desired.width, desired.height, id); + } + }); + await new Promise((resolve) => { + server.onReady(resolve); + server.start(); + }); + // An interactive process exercises JGD's native idle input handler. + // No C_jgd_poll_resize call or subsequent R command drives the redraw. + const r = spawn('R', ['--vanilla', '--slave', '--interactive'], { + env: { ...process.env, JGD_SOCKET: server.getSocketPath() }, + }); + let errors = ''; + r.stderr.on('data', (data: Buffer) => { + errors += data.toString(); + }); + r.stdout.resume(); + const exited = new Promise((resolve) => r.once('exit', () => resolve())); + try { + r.stdin.write('jgd::jgd(); plot(1:10000)\n'); + await until( + () => + history.currentPlot()?.device.width === desired.width && + history.currentPlot()?.device.height === desired.height, + ); + assert.ok(history.currentPlot()!.ops.length >= 10000); + const id = history.getActiveSessionId(); + replays.length = 0; + desired = { width: 900, height: 650 }; + server.handleResize(700, 500, id); + server.handleResize(800, 550, id); + server.handleResize(desired.width, desired.height, id); + await until( + () => + history.currentPlot()?.device.width === desired.width && + history.currentPlot()?.device.height === desired.height, + ); + assert.deepStrictEqual(replays, [{ width: 700, height: 500 }, desired]); + assert.strictEqual(history.count(), 1); + assert.ok(history.currentPlot()!.ops.length >= 10000); + } catch (error) { + throw new Error(`${String(error)}\n${errors}`); + } finally { + r.kill(); + server.stop(); + await exited; + } + }); +}); diff --git a/src/test/suite/jgdSocketServer.test.ts b/src/test/suite/jgdSocketServer.test.ts index 4a48b146b..e8448b3d9 100644 --- a/src/test/suite/jgdSocketServer.test.ts +++ b/src/test/suite/jgdSocketServer.test.ts @@ -335,6 +335,66 @@ suite('JGD SocketServer', () => { ); }); + test('coalesces resize bursts until a frame confirms the applied dimensions', async () => { + const client = await connect(); + async function barrier() { + client.send({ type: 'metrics_request', id: 42, kind: 'strWidth', str: 'barrier' }); + assert.strictEqual( + (JSON.parse(await client.readLine()) as JgdMessage).type, + 'metrics_response', + ); + } + client.send(makePlotMsg('large plot')); + await barrier(); + const id = history.getActiveSessionId(); + server.handleResize(700, 500, id); + for (let width = 701; width <= 900; width++) { + server.handleResize(width, 650, id); + } + assert.deepStrictEqual(JSON.parse(await client.readLine()), { + type: 'resize', + width: 700, + height: 500, + }); + await barrier(); // No intermediate dimensions were sent to R. + client.send(makePlotMsg('first replay', 700, 500, { resizeReplay: true })); + assert.deepStrictEqual(JSON.parse(await client.readLine()), { + type: 'resize', + width: 900, + height: 650, + }); + client.send(makePlotMsg('final replay', 900, 650, { resizeReplay: true })); + await barrier(); + server.handleResize(900, 650, id); + await barrier(); // Confirmed dimensions do not cause another replay. + assert.strictEqual(history.count(id), 1); + assert.strictEqual(history.currentPlot(id)?.device.width, 900); + }); + + test('a stale-size frame allows the viewer to request the size again', async () => { + const client = await connect(); + client.send(makePlotMsg('plot')); + await waitMs(20); + const id = history.getActiveSessionId(); + server.handleResize(900, 650, id); + assert.strictEqual((JSON.parse(await client.readLine()) as JgdMessage).type, 'resize'); + server.setOnFrame(() => server.handleResize(900, 650, id)); + // JGD may consume a resize during font measurement and flush the + // current drawing at its old size without an idle-handler wakeup. + client.send(makePlotMsg('drawing completed', 400, 300, { incremental: true })); + assert.deepStrictEqual(JSON.parse(await client.readLine()), { + type: 'resize', + width: 900, + height: 650, + }); + client.send(makePlotMsg('resized', 900, 650, { resizeReplay: true })); + client.send({ type: 'metrics_request', id: 43, kind: 'strWidth', str: 'barrier' }); + assert.strictEqual( + (JSON.parse(await client.readLine()) as JgdMessage).type, + 'metrics_response', + ); + }); + test('shared navigation resizes the selected device and deduplicates its live size', async () => { const first = await connect(); const second = await connect(); From 053acbdbfc3a7d19e7d20760ce12ae39e8740aeb Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:11:16 +1100 Subject: [PATCH 15/32] test(plot): cover large plots on existing and new JGD devices --- src/test/node/jgdResize.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/test/node/jgdResize.test.ts b/src/test/node/jgdResize.test.ts index 0533ea137..aec8f8ecc 100644 --- a/src/test/node/jgdResize.test.ts +++ b/src/test/node/jgdResize.test.ts @@ -89,6 +89,27 @@ suite('Live JGD resize', () => { assert.deepStrictEqual(replays, [{ width: 700, height: 500 }, desired]); assert.strictEqual(history.count(), 1); assert.ok(history.currentPlot()!.ops.length >= 10000); + + // Subsequent plots and explicit devices must fit an unchanged + // viewer, without another resize event or history navigation. + r.stdin.write('plot(1:10000)\n'); + await until(() => history.count(id) === 2); + assert.strictEqual(history.getActiveSessionId(), id); + assert.strictEqual(history.currentPlot()!.device.width, desired.width); + assert.strictEqual(history.currentPlot()!.device.height, desired.height); + assert.ok(history.currentPlot()!.ops.length >= 10000); + + r.stdin.write('jgd::jgd(); plot(1:10000)\n'); + await until( + () => + history.getActiveSessionId() !== id && + history.currentPlot()?.device.width === desired.width && + history.currentPlot()?.device.height === desired.height, + ); + assert.strictEqual(history.sessionIds().length, 2); + assert.strictEqual(history.count(id), 2); + assert.strictEqual(history.count(), 1); + assert.ok(history.currentPlot()!.ops.length >= 10000); } catch (error) { throw new Error(`${String(error)}\n${errors}`); } finally { From 542d34c4b5b2297583624c363e6a45360952b02c Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:41:40 +1100 Subject: [PATCH 16/32] fix(plot): redraw retained JGD viewers when their tab becomes visible --- src/plotViewer/jgdViewer.ts | 66 ++++++++++--- src/test/suite/plotRestoration.test.ts | 123 +++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 619c69a88..19a66922d 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -301,6 +301,7 @@ export class JgdViewer implements PlotViewer { private panelWidth = 800; private panelHeight = 600; private lastToolbarState: string | undefined; + private lastPanelVisibility: boolean | undefined; private sessionContext: ViewerSessionContext | undefined; constructor( @@ -634,6 +635,7 @@ export class JgdViewer implements PlotViewer { this.panel.webview.onDidReceiveMessage((raw: WebviewMessage) => { switch (raw.type) { case 'ready': { + this.updateVisibility(true); const plot = this.history.currentPlot(this.sessionId); if (plot) { this.sendPlotToWebview(plot); @@ -700,8 +702,12 @@ export class JgdViewer implements PlotViewer { this.panel = null; this.metricsCache.clear(); this.lastToolbarState = undefined; + this.lastPanelVisibility = undefined; + }); + this.panel.onDidChangeViewState(() => { + this.updateVisibility(); + this.updateToolbar(true); }); - this.panel.onDidChangeViewState(() => this.updateToolbar(true)); this.sessionContext?.attach(this.panel); this.panel.webview.html = this.getWebviewHtml(); } @@ -742,7 +748,15 @@ export class JgdViewer implements PlotViewer { } private sendPlotToWebview(plot: PlotFrame) { - void this.panel?.webview.postMessage({ type: 'render', plot }); + void this.panel?.webview.postMessage({ type: 'render', plot, visible: this.panel.visible }); + } + + private updateVisibility(force = false) { + if (!this.panel || (!force && this.panel.visible === this.lastPanelVisibility)) { + return; + } + this.lastPanelVisibility = this.panel.visible; + void this.panel.webview.postMessage({ type: 'visibility', visible: this.panel.visible }); } private async handleExportData(msg: WebviewExportData) { @@ -839,6 +853,9 @@ const metricsCanvas = document.getElementById('metrics-canvas'); const metricsCtx = metricsCanvas.getContext('2d'); let currentPlot = null; +// A retained tab can be hidden without changing document.hidden. +// VS Code's panel state is authoritative for whether this layout is usable. +let panelVisible = false; document.getElementById('btn-prev').addEventListener('click', () => { vscode.postMessage({ type: 'navigate', direction: 'previous' }); }); @@ -860,21 +877,41 @@ const container = document.getElementById('canvas-container'); let resizeTimer = null; let lastSentW = 0; let lastSentH = 0; +function reportViewportSize() { + if (!panelVisible || document.hidden) return; + const w = container.clientWidth; + const h = container.clientHeight; + if (w <= 0 || h <= 0) return; + if (w !== lastSentW || h !== lastSentH) { + lastSentW = w; + lastSentH = h; + vscode.postMessage({ type: 'resize', width: w, height: h }); + } +} const resizeObserver = new ResizeObserver(() => { + if (!panelVisible || document.hidden) return; if (currentPlot) replay(currentPlot); clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - const w = container.clientWidth; - const h = container.clientHeight; - if (w !== lastSentW || h !== lastSentH) { - lastSentW = w; - lastSentH = h; - vscode.postMessage({ type: 'resize', width: w, height: h }); - } - }, 300); + resizeTimer = setTimeout(reportViewportSize, 300); }); resizeObserver.observe(container); +function refreshVisibility() { + if (!panelVisible || document.hidden) { + clearTimeout(resizeTimer); + ++replayGeneration; + return; + } + // A retained, hidden webview has a temporary layout. Its visible size + // may be unchanged, so ResizeObserver alone cannot restore the canvas. + requestAnimationFrame(() => { + if (!panelVisible || document.hidden) return; + if (currentPlot) replay(currentPlot); + reportViewportSize(); + }); +} +document.addEventListener('visibilitychange', refreshVisibility); + (function warmupMetrics() { const fonts = [ { size: 12, family: 'sans-serif', face: 1 }, @@ -910,8 +947,13 @@ window.addEventListener('message', (event) => { switch (msg.type) { case 'render': currentPlot = msg.plot; + panelVisible = msg.visible; replay(msg.plot); break; + case 'visibility': + panelVisible = msg.visible; + refreshVisibility(); + break; case 'clear': currentPlot = null; ctx.clearRect(0, 0, canvas.width, canvas.height); @@ -1057,7 +1099,7 @@ async function replay(plot) { } async function doReplay(plot, gen) { - if (replayGeneration !== gen) return; + if (replayGeneration !== gen || !panelVisible || document.hidden) return; const dpr = window.devicePixelRatio || 1; const containerW = container.clientWidth; diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 89326fc54..46f4dfe52 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -18,6 +18,7 @@ import { mockExtensionContext } from '../common/mockvscode'; import { waitForValue } from '../common/sessionConnections'; const createRealWebviewPanel = vscode.window.createWebviewPanel; +const executeRealCommand = vscode.commands.executeCommand; class RefreshViewer extends HttpgdViewer { receivePlots(ids: string[]): Promise { @@ -1082,6 +1083,128 @@ suite('Standalone plot restoration', () => { } }); + test('a retained JGD tab redraws plots made while hidden without resizing R', async () => { + interface Inspection { + hidden: boolean; + background: string; + container: number[]; + canvas: number[]; + pixel: number[]; + } + let panel: vscode.WebviewPanel | undefined; + let inspection: Inspection | undefined; + (vscode.window.createWebviewPanel as sinon.SinonStub).callsFake( + (...args: Parameters) => { + panel = createRealWebviewPanel(...args); + panel.webview.onDidReceiveMessage((message: { type: string } & Inspection) => { + if (message.type === 'canvas-inspection') { + inspection = message; + } + }); + return panel; + }, + ); + const value = manager(); + const history = value.jgdManager.history; + history.addPlot('device', frame('device', 'blue')); + const viewer = value.jgdManager.getViewer('device')!; + const resize = sandbox.spy(value.jgdManager.server, 'handleResize'); + viewer.show(); + panel!.webview.html = panel!.webview.html.replace( + '', + ``, + ); + const inspect = async () => { + inspection = undefined; + await panel!.webview.postMessage({ type: 'inspect-canvas', visible: panel!.visible }); + return waitForValue(() => inspection); + }; + let coveringEditor: vscode.TextEditor | undefined; + try { + const size = await waitForValue(() => resize.lastCall?.args); + // Model an R frame already rendered at this viewport's dimensions. + // Showing the same-sized tab again should only redraw the canvas. + const plot = { + ...frame('device', 'blue'), + device: { ...frame('device').device, width: size[0], height: size[1], bg: 'blue' }, + }; + history.replaceLatest('device', plot); + viewer.showPlot(history.currentPlot('device')!); + const visible = await inspect(); + assert.deepStrictEqual(visible.canvas, visible.container); + assert.deepStrictEqual(visible.pixel, [0, 0, 255, 255]); + resize.resetHistory(); + + const document = await vscode.workspace.openTextDocument({ + content: 'Cover the plot tab', + language: 'plaintext', + }); + coveringEditor = await vscode.window.showTextDocument(document, { + viewColumn: panel!.viewColumn, + }); + await waitForValue(() => (!panel!.visible ? true : undefined)); + const next = { + ...plot, + device: { ...plot.device, bg: 'magenta' }, + ops: Array.from({ length: 10000 }, () => ({ + op: 'circle', + x: 50, + y: 50, + r: 1, + gc: { col: 'black' }, + })), + }; + history.addPlot('device', next); + viewer.showPlot(next); + const hidden = await inspect(); + assert.strictEqual( + hidden.background, + 'magenta', + 'the newest plot reached the hidden webview', + ); + + panel!.reveal(); + await waitForValue(() => (panel!.visible ? true : undefined)); + const shown = await inspect(); + assert.deepStrictEqual( + shown.container, + visible.container, + 'the viewport did not change', + ); + assert.deepStrictEqual( + shown.canvas, + shown.container, + 'visibility alone restores the full canvas', + ); + assert.deepStrictEqual( + shown.pixel, + [255, 0, 255, 255], + 'the canvas contains the newly selected plot', + ); + assert.strictEqual(history.count('device'), 2); + sinon.assert.notCalled(resize); + } finally { + panel?.dispose(); + if (coveringEditor) { + await vscode.window.showTextDocument(coveringEditor.document); + await executeRealCommand('workbench.action.revertAndCloseActiveEditor'); + } + } + }); + test('shutdown during credential loading does not reopen a connection or panel', async () => { const source = owner('httpgd-owner', '14001'); focus(source); From 5078f2d8201841d909e90b4462ac13103a70a20e Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:46:25 +1100 Subject: [PATCH 17/32] Revert "test(plot): cover large plots on existing and new JGD devices" This reverts commit 053acbdbfc3a7d19e7d20760ce12ae39e8740aeb. --- src/test/node/jgdResize.test.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/test/node/jgdResize.test.ts b/src/test/node/jgdResize.test.ts index aec8f8ecc..0533ea137 100644 --- a/src/test/node/jgdResize.test.ts +++ b/src/test/node/jgdResize.test.ts @@ -89,27 +89,6 @@ suite('Live JGD resize', () => { assert.deepStrictEqual(replays, [{ width: 700, height: 500 }, desired]); assert.strictEqual(history.count(), 1); assert.ok(history.currentPlot()!.ops.length >= 10000); - - // Subsequent plots and explicit devices must fit an unchanged - // viewer, without another resize event or history navigation. - r.stdin.write('plot(1:10000)\n'); - await until(() => history.count(id) === 2); - assert.strictEqual(history.getActiveSessionId(), id); - assert.strictEqual(history.currentPlot()!.device.width, desired.width); - assert.strictEqual(history.currentPlot()!.device.height, desired.height); - assert.ok(history.currentPlot()!.ops.length >= 10000); - - r.stdin.write('jgd::jgd(); plot(1:10000)\n'); - await until( - () => - history.getActiveSessionId() !== id && - history.currentPlot()?.device.width === desired.width && - history.currentPlot()?.device.height === desired.height, - ); - assert.strictEqual(history.sessionIds().length, 2); - assert.strictEqual(history.count(id), 2); - assert.strictEqual(history.count(), 1); - assert.ok(history.currentPlot()!.ops.length >= 10000); } catch (error) { throw new Error(`${String(error)}\n${errors}`); } finally { From 56d0796e2ba4e12df706cd29d7ef25600ffb1e1c Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:07:24 +1100 Subject: [PATCH 18/32] fix(plot): forget httpgd connections after confirmed session exit --- src/plotViewer/httpgdViewer.ts | 121 +++++++++++++++-- src/test/suite/httpgdViewer.test.ts | 13 +- src/test/suite/plotRestoration.test.ts | 173 +++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 14 deletions(-) diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 7b3988a01..f3000f1f6 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -63,12 +63,18 @@ export class HttpgdManager { private secretKey: string | undefined; private disposed = false; private connectionWrites: Promise = Promise.resolve(); + private exitedSessions = new Set(); + private savedSessionSubscriptions = new Map(); dispose(): void { this.disposed = true; for (const viewer of this.viewers) { viewer.dispose(); } + for (const subscription of this.savedSessionSubscriptions.values()) { + subscription.dispose(); + } + this.savedSessionSubscriptions.clear(); } initialize(): void { @@ -78,19 +84,82 @@ export class HttpgdManager { this.savedConnections = Promise.resolve(extensionContext.secrets.get(this.secretKey)) .then((value) => { const connections: unknown = value ? JSON.parse(value) : []; - return Array.isArray(connections) ? connections.filter(isHttpgdConnection) : []; + const saved = Array.isArray(connections) + ? connections.filter(isHttpgdConnection) + : []; + if (!this.disposed) { + for (const connection of saved) { + this.observeSavedSession(connection.source); + } + } + return saved; }) .catch(() => []); } } + private hasExited(source?: ViewerSessionSource): boolean { + return Boolean( + source && + (source.processExited || this.exitedSessions.has(sessionProcessIdentity(source))), + ); + } + + private observeSavedSession(source?: ViewerSessionSource): void { + if (!source) { + return; + } + const key = sessionProcessIdentity(source); + if (this.exitedSessions.has(key) || this.savedSessionSubscriptions.has(key)) { + return; + } + const context = getViewerSessionContext(source.sessionId, source); + if (context) { + this.savedSessionSubscriptions.set( + key, + context.observeExit(() => this.sessionExited(source)), + ); + } + if (source.processExited || context?.hasExited) { + this.sessionExited(source); + } + } + + public sessionExited(source: ViewerSessionSource): void { + const key = sessionProcessIdentity(source); + if (this.disposed || this.exitedSessions.has(key)) { + return; + } + // Remember confirmed exits so delayed restoration cannot resurrect them. + this.exitedSessions.add(key); + this.savedSessionSubscriptions.get(key)?.dispose(); + this.savedSessionSubscriptions.delete(key); + const belongsToSession = (viewer: HttpgdViewer) => { + const owner = viewer.connection.source; + return owner && sessionProcessIdentity(owner) === key; + }; + for (const viewer of this.viewers) { + if (belongsToSession(viewer)) { + viewer.dispose(); + } + } + this.viewers = this.viewers.filter((viewer) => !belongsToSession(viewer)); + this.recentlyActiveViewers = this.recentlyActiveViewers.filter( + (viewer) => !belongsToSession(viewer), + ); + void this.saveConnections(); + } + async restoreConnection(source: ViewerSessionSource): Promise { + if (this.disposed || this.hasExited(source)) { + return undefined; + } const key = sessionProcessIdentity(source); const matches = (owner?: ViewerSessionSource) => owner && sessionProcessIdentity(owner) === key; const existing = () => - [...this.recentlyActiveViewers, ...this.viewers].find((viewer) => - matches(viewer.connection.source), + [...this.recentlyActiveViewers, ...this.viewers].find( + (viewer) => !viewer.isDisposed && matches(viewer.connection.source), ); if (existing()) { return existing(); @@ -114,15 +183,27 @@ export class HttpgdManager { private async writeConnections(): Promise { const connections = new Map( - (await this.savedConnections).map((connection) => [ - new URL(connection.url).origin, - connection, - ]), + (await this.savedConnections) + .filter((connection) => !this.hasExited(connection.source)) + .map((connection) => [new URL(connection.url).origin, connection]), ); for (const viewer of this.viewers) { - connections.set(viewer.origin, viewer.connection); + // Extension shutdown disposes clients before its queued save runs. + // Only confirmed process exits invalidate restoration credentials. + if (!this.hasExited(viewer.connection.source)) { + connections.set(viewer.origin, viewer.connection); + } } + this.savedConnections = Promise.resolve([...connections.values()]); if (!connections.size) { + if (this.secretKey) { + await extensionContext.secrets.delete(this.secretKey); + await extensionContext.workspaceState.update( + 'r.plot.httpgdConnectionsKey', + undefined, + ); + this.secretKey = undefined; + } return; } this.secretKey ??= `r.plot.httpgd.${randomUUID()}`; @@ -149,7 +230,7 @@ export class HttpgdManager { restoring = false, ): Promise { await Promise.resolve(); - if (this.disposed) { + if (this.disposed || this.hasExited(source)) { return; } const url = new URL(urlString); @@ -169,6 +250,9 @@ export class HttpgdManager { sessionProcessIdentity(source) === sessionProcessIdentity(owner); if (!viewer.isDisposed && viewer.token === token && sameSession) { viewer.bindSession(source); + if (viewer.isDisposed) { + return; + } this.viewers.unshift(viewer); if (!restoring) { viewer.show(); @@ -200,6 +284,9 @@ export class HttpgdManager { this.viewerOptions.source = source; this.viewerOptions.restoring = restoring; const viewer = new HttpgdViewer(url.origin, this.viewerOptions); + if (viewer.isDisposed) { + return; + } this.viewers.unshift(viewer); if (!onlyIfMissing) { await this.saveConnections(); @@ -207,6 +294,9 @@ export class HttpgdManager { } public registerActiveViewer(viewer: HttpgdViewer): void { + if (this.disposed || viewer.isDisposed) { + return; + } const ind = this.recentlyActiveViewers.indexOf(viewer); if (ind >= 0) { this.recentlyActiveViewers.splice(ind, 1); @@ -454,7 +544,11 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } this.source = { ...source }; this.sessionContext = getViewerSessionContext(source.sessionId, source); - this.sessionExitSubscription = this.sessionContext?.observeExit(() => this.dispose()); + const onExit = () => { + this.dispose(); + this.parent.sessionExited(source); + }; + this.sessionExitSubscription = this.sessionContext?.observeExit(onExit); this.sessionConnectionSubscription = onDidChangeSessionConnection((event) => { if (event.session.sessionId !== source.sessionId || this.disposed) { return; @@ -470,8 +564,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.connect(); } }); - if (this.sessionContext?.hasExited) { - this.dispose(); + if (source.processExited || this.sessionContext?.hasExited) { + onExit(); } if (this.webviewPanel) { this.sessionContext?.attach(this.webviewPanel); @@ -480,6 +574,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } public show(preserveFocus?: boolean): void { + if (this.disposed) { + return; + } this.restoring = false; preserveFocus ??= this.showOptions.preserveFocus; if (!this.webviewPanel) { diff --git a/src/test/suite/httpgdViewer.test.ts b/src/test/suite/httpgdViewer.test.ts index dc657402d..3e0f9d3aa 100644 --- a/src/test/suite/httpgdViewer.test.ts +++ b/src/test/suite/httpgdViewer.test.ts @@ -52,9 +52,10 @@ suite('httpgd viewer requests', () => { } as vscode.WorkspaceConfiguration); manager = new HttpgdManager(); }); - teardown(() => { + teardown(async () => { try { - manager?.viewers.forEach((viewer) => viewer.dispose()); + manager.dispose(); + await manager.saveConnections(); } finally { sandbox.restore(); } @@ -230,6 +231,7 @@ suite('httpgd viewer requests', () => { const request = sandbox.stub(state.viewer.api, 'getPlotText').resolves(''); const closePanel = sandbox.stub(); state.viewer.webviewPanel = { dispose: closePanel } as unknown as vscode.WebviewPanel; + manager.registerActiveViewer(state.viewer); state.viewer.queueResize(); await state.viewer.queueRefresh(); state.exit(); @@ -242,8 +244,15 @@ suite('httpgd viewer requests', () => { sinon.assert.calledOnce(state.released); sinon.assert.notCalled(closePanel); sinon.assert.notCalled(state.errors); + assert.deepStrictEqual(manager.viewers, []); + assert.deepStrictEqual(manager.recentlyActiveViewers, []); + state.viewer.show(); + manager.registerActiveViewer(state.viewer); + assert.deepStrictEqual(manager.recentlyActiveViewers, []); connect.resetHistory(); state.fire('connected'); + await manager.showViewer('http://localhost:1234', { ...state.source }); + assert.strictEqual(await manager.restoreConnection({ ...state.source }), undefined); sinon.assert.notCalled(connect); state.viewer.webviewPanel = undefined; }); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 46f4dfe52..22c7594d1 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -16,6 +16,8 @@ import * as session from '../../session'; import * as util from '../../util'; import { mockExtensionContext } from '../common/mockvscode'; import { waitForValue } from '../common/sessionConnections'; +import { sessionProcessIdentity } from '../../sessionIdentity'; +import type { ViewerSessionSource } from '../../viewerSession'; const createRealWebviewPanel = vscode.window.createWebviewPanel; const executeRealCommand = vscode.commands.executeCommand; @@ -65,6 +67,10 @@ suite('Standalone plot restoration', () => { return Promise.resolve(); }, ); + (extensionContext.secrets.delete as sinon.SinonStub).callsFake((key: string) => { + secrets.delete(key); + return Promise.resolve(); + }); sandbox.stub(vscode.commands, 'registerCommand').callsFake((command, callback) => { callbacks.set(command, callback as (...args: unknown[]) => Promise); return new vscode.Disposable(() => {}); @@ -146,6 +152,173 @@ suite('Standalone plot restoration', () => { sandbox.stub(session, 'activeSession').value(source); } + function processExits() { + const exited = new Set(); + const listeners = new Map void>>(); + sandbox.stub(session, 'getViewerSessionContext').callsFake((_id, source) => { + if (!source) { + return undefined; + } + const key = sessionProcessIdentity(source); + let callbacks = listeners.get(key); + if (!callbacks) { + callbacks = new Set(); + listeners.set(key, callbacks); + } + const observers = callbacks; + return { + source, + sessionId: source.sessionId, + get hasExited() { + return exited.has(key); + }, + observeExit: (callback) => { + observers.add(callback); + return { dispose: () => observers.delete(callback) }; + }, + attach: () => {}, + getHtml: () => '', + }; + }); + return { + exit(source: ViewerSessionSource) { + const key = sessionProcessIdentity(source); + exited.add(key); + for (const callback of listeners.get(key) ?? []) { + callback(); + } + }, + listeners, + }; + } + + test('httpgd process exit removes only its connections and saved credentials, keeping panels', async () => { + const exits = processExits(); + const source = owner('httpgd-owner', '14001'); + const replacement = owner(source.sessionId, '14002'); + const value = manager(); + await value.showHttpgdPlot('http://localhost:1234?token=old-1', source); + await value.showHttpgdPlot('http://localhost:1235?token=old-2', source); + const old = [...value.httpgdManager.viewers]; + old[0].show(); + const panel = old[0].webviewPanel; + const close = sandbox.spy(panel!, 'dispose'); + const disconnects = old.map((viewer) => sandbox.spy(viewer.api, 'disconnect')); + await value.showHttpgdPlot('http://localhost:1236?token=replacement', replacement); + await value.httpgdManager.showViewer('http://localhost:1237?token=unowned'); + exits.exit(source); + await value.httpgdManager.saveConnections(); + for (const disconnect of disconnects) { + sinon.assert.calledOnce(disconnect); + } + sinon.assert.notCalled(close); + assert.strictEqual(old[0].webviewPanel, panel); + assert.strictEqual(value.httpgdManager.viewers.length, 2); + assert.ok( + old.every((viewer) => !value.httpgdManager.recentlyActiveViewers.includes(viewer)), + ); + const saved = JSON.parse([...secrets.values()][0]) as Array<{ + source?: ViewerSessionSource; + }>; + assert.strictEqual(saved.length, 2); + assert.ok(saved.some((connection) => connection.source?.pid === replacement.pid)); + assert.ok(saved.some((connection) => !connection.source)); + await value.dispose(); + const restored = manager(); + assert.strictEqual(await restored.httpgdManager.restoreConnection(source), undefined); + assert.ok(await restored.httpgdManager.restoreConnection(replacement)); + }); + + test('httpgd saved connections are cleaned on exit even without reopening a viewer', async () => { + const exits = processExits(); + const source = owner('httpgd-owner', '14001'); + const first = manager(); + await first.showHttpgdPlot('http://localhost:1234?token=secret', source); + await first.dispose(); + const restored = manager(); + await restored.httpgdManager.saveConnections(); + assert.strictEqual(restored.httpgdManager.viewers.length, 0); + assert.strictEqual(secrets.size, 1, 'reload preserves the live session'); + connect.resetHistory(); + exits.exit(source); + await waitForValue(() => + secrets.size === 0 && !state.get('r.plot.httpgdConnectionsKey') ? true : undefined, + ); + await restored.httpgdManager.saveConnections(); + assert.strictEqual(secrets.size, 0); + assert.strictEqual(state.get('r.plot.httpgdConnectionsKey'), undefined); + assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))?.size, 0); + assert.strictEqual(await restored.httpgdManager.restoreConnection(source), undefined); + sinon.assert.notCalled(connect); + }); + + test('httpgd exit during credential loading prevents a pending restore from reconnecting', async () => { + const exits = processExits(); + const source = owner('httpgd-owner', '14001'); + const first = manager(); + await first.showHttpgdPlot('http://localhost:1234?token=secret', source); + await first.dispose(); + const saved = [...secrets.values()][0]; + let load!: (value: string) => void; + (extensionContext.secrets.get as sinon.SinonStub).returns( + new Promise((resolve) => { + load = resolve; + }), + ); + const restored = manager(); + const pending = restored.httpgdManager.restoreConnection(source); + exits.exit(source); + connect.resetHistory(); + load(saved); + assert.strictEqual(await pending, undefined); + await restored.httpgdManager.saveConnections(); + assert.strictEqual(secrets.size, 0); + assert.strictEqual(state.get('r.plot.httpgdConnectionsKey'), undefined); + sinon.assert.notCalled(connect); + }); + + test('httpgd exit during a credential write removes that write after it completes', async () => { + const exits = processExits(); + const source = owner('httpgd-owner', '14001'); + const value = manager(); + let stored!: () => void; + let started!: () => void; + const writing = new Promise((resolve) => { + started = resolve; + }); + (extensionContext.secrets.store as sinon.SinonStub).onFirstCall().callsFake( + (key: string, data: string) => + new Promise((resolve) => { + stored = () => { + secrets.set(key, data); + resolve(); + }; + started(); + }), + ); + const opening = value.showHttpgdPlot('http://localhost:1234?token=secret', source); + await writing; + exits.exit(source); + stored(); + await opening; + await value.httpgdManager.saveConnections(); + assert.strictEqual(secrets.size, 0); + assert.strictEqual(state.get('r.plot.httpgdConnectionsKey'), undefined); + assert.deepStrictEqual(value.httpgdManager.viewers, []); + }); + + test('httpgd already exited owners cannot register a new connection', async () => { + const exits = processExits(); + const source = owner('httpgd-owner', '14001'); + exits.exit(source); + const value = manager(); + await value.showHttpgdPlot('http://localhost:1234?token=secret', source); + await value.httpgdManager.saveConnections(); + assert.deepStrictEqual(value.httpgdManager.viewers, []); + assert.strictEqual(secrets.size, 0); + sinon.assert.notCalled(connect); + }); + test('all plot backends show the exact shared session indicator and retain it after reload', async () => { const a = owner('indicator-a', '12101'); const b = owner('indicator-b', '12102'); From c5b14cc328c567a560545d046976141fd59a13fc Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:42:42 +1100 Subject: [PATCH 19/32] fix(plot): preserve device identity and streamline history checkpoints --- sess/DESCRIPTION | 2 +- sess/R/hooks.R | 20 +++-- src/plotViewer/index.ts | 64 +++++++++++-- src/plotViewer/jgdPlotHistory.ts | 67 ++++++++++++-- src/plotViewer/standardViewer.ts | 22 +++-- src/test/node/plotReconnect.test.ts | 31 +++++-- src/test/suite/jgdPlotHistory.test.ts | 39 ++++++++ src/test/suite/plotRestoration.test.ts | 90 ++++++++++++++++++- .../suite/sessionConnectionLifecycle.test.ts | 50 +++++++++++ src/test/suite/standardPlotViewer.test.ts | 38 ++++++++ 10 files changed, 387 insertions(+), 36 deletions(-) diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index c68319ce4..aec2640dc 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -1,7 +1,7 @@ Package: sess Type: Package Title: High-Performance IPC Bridge for R Sessions -Version: 3.4.0.9003 +Version: 3.4.0.9004 Authors@R: c( person(given = "Randy", family = "Lai", diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 29083eaf0..3d0e30bbf 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -240,6 +240,9 @@ runtime_start <- function(use_rstudioapi = TRUE, } devices <- .runtime_state()$devices devices[[length(devices)]]$key <- key + devices[[length(devices)]]$renderer_device_id <- unname( + jgd::jgd_server_info()$server_info["deviceId"] + ) state$devices <- devices # Carry the logical device identity across a new renderer socket. Device # numbers and JGD's native counter can change when the device is reopened. @@ -270,16 +273,23 @@ runtime_start <- function(use_rstudioapi = TRUE, # belonging to VS Code too, leaving other renderers' devices untouched. devs <- grDevices::dev.list() for (id in devs[names(devs) == "jgd"]) { - if (any(vapply(retained, function(entry) entry$id == id, logical(1)))) next grDevices::dev.set(id) info <- tryCatch(jgd::jgd_server_info(), error = function(e) NULL) + previous_device_id <- unname(info$server_info["deviceId"]) + if (length(previous_device_id) != 1L || is.na(previous_device_id) || !nzchar(previous_device_id)) { + previous_device_id <- NULL + } + # An explicit jgd() can reuse a managed device's number without calling + # our factory. Only the renderer's per-connection ID proves continuity. + same_device <- vapply(retained, function(entry) { + entry$id == id && !is.null(previous_device_id) && + identical(entry$renderer_device_id, previous_device_id) + }, logical(1)) + if (any(same_device)) next + retained <- Filter(function(entry) entry$id != id, retained) if (identical(info$server_name, "jgd-vscode")) { # JGD retains the renderer welcome even after its socket disconnects. # Carry its original identity so reconnect preserves the saved history. - previous_device_id <- unname(info$server_info["deviceId"]) - if (length(previous_device_id) != 1L || is.na(previous_device_id) || !nzchar(previous_device_id)) { - previous_device_id <- NULL - } retained[[length(retained) + 1L]] <- list( id = unname(id), name = "jgd", previous_device_id = previous_device_id ) diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index f116d7861..af1f4e08e 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -10,6 +10,8 @@ import { getMigratedSetting } from '../configuration'; import { activeSession, resolvePlotSession, Session } from '../session'; import { sessionProcessIdentity } from '../sessionIdentity'; import { isViewerSessionSource, ViewerSessionSource } from '../viewerSession'; +import { randomUUID } from 'crypto'; +import type { PlotHistoryState } from './jgdPlotHistory'; export type PlotBackend = 'auto' | 'standard' | 'httpgd' | 'jgd' | 'native'; @@ -59,7 +61,10 @@ export class CommonPlotManager implements PlotManager { private disposed = false; private historySaveTimer: NodeJS.Timeout | undefined; private historySubscription: { dispose(): void } | undefined; - private historyWrites: Promise = Promise.resolve(); + private historyWrites: Promise | undefined; + private historyContentDirty = false; + private historySelectionDirty = false; + private historyCheckpointId: string | undefined; constructor() { this.httpgdManager = new HttpgdManager(); @@ -92,13 +97,29 @@ export class CommonPlotManager implements PlotManager { public initialize(): void { this.jgdManager.initialize(extensionContext.extensionUri); - this.jgdManager.history.restore(extensionContext.workspaceState.get('r.plot.jgdState')); + const saved = extensionContext.workspaceState.get< + PlotHistoryState & { checkpointId?: string } + >('r.plot.jgdState'); + const selection = extensionContext.workspaceState.get<{ + checkpointId?: string; + positions?: unknown; + }>('r.plot.jgdSelection'); + this.historyCheckpointId = saved?.checkpointId; + const trimmed = this.jgdManager.history.restore( + saved, + this.historyCheckpointId && selection?.checkpointId === this.historyCheckpointId + ? selection.positions + : undefined, + ); + this.historyContentDirty = !this.historyCheckpointId || trimmed; const sources = extensionContext.workspaceState.get('r.plot.standardSessions'); this.restoredStandardSessions = Array.isArray(sources) ? sources.filter(isViewerSessionSource) : []; this.httpgdManager.initialize(); - this.historySubscription = this.jgdManager.history.onDidChange(() => { + this.historySubscription = this.jgdManager.history.onDidChange((_id, selectionOnly) => { + this.historyContentDirty ||= !selectionOnly; + this.historySelectionDirty = true; // Checkpoint during normal operation: shutdown-only writes can be // lost when VS Code tears down the extension host's storage bridge. if (!this.historySaveTimer && !this.disposed) { @@ -214,10 +235,39 @@ export class CommonPlotManager implements PlotManager { } private saveJgdHistory(): Promise { - const snapshot = structuredClone(this.jgdManager.history.snapshot()); - this.historyWrites = this.historyWrites - .then(() => extensionContext.workspaceState.update('r.plot.jgdState', snapshot)) - .catch((error) => console.warn('[Plot Viewer] Could not save JGD state', error)); + // Keep one writer and coalesce changes arriving during storage I/O. + // Navigation writes only small cursor metadata, never plot geometry. + this.historyWrites ??= Promise.resolve() + .then(async () => { + while (this.historyContentDirty || this.historySelectionDirty) { + const contentDirty = this.historyContentDirty; + this.historyContentDirty = false; + this.historySelectionDirty = false; + if (contentDirty) { + const checkpointId = randomUUID(); + await extensionContext.workspaceState.update('r.plot.jgdState', { + ...this.jgdManager.history.snapshot(), + checkpointId, + }); + this.historyCheckpointId = checkpointId; + } + // Tie cursors to their frame checkpoint so an interrupted write + // cannot apply old indices to a different set of retained plots. + if (!this.historyContentDirty) { + await extensionContext.workspaceState.update('r.plot.jgdSelection', { + checkpointId: this.historyCheckpointId, + positions: this.jgdManager.history.snapshotSelection(), + }); + } + } + }) + .catch((error) => { + this.historyContentDirty = true; + console.warn('[Plot Viewer] Could not save JGD state', error); + }) + .finally(() => { + this.historyWrites = undefined; + }); return this.historyWrites; } diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts index 2c0f1504a..fbea6b7fb 100644 --- a/src/plotViewer/jgdPlotHistory.ts +++ b/src/plotViewer/jgdPlotHistory.ts @@ -47,6 +47,9 @@ export class PlotHistory { private owners = new Map(); private order = new WeakMap(); private sequence = 0; + // Checkpoints own immutable copies. Reuse unchanged frames across saves, + // while the live history remains mutable for incremental drawing. + private snapshots = new WeakMap(); private activeSessionId: string = ''; private maxPlots: number; private emitter = new EventEmitter(); @@ -87,10 +90,12 @@ export class PlotHistory { timeline.devices.add(id); for (const plot of device.plots) { plot.sessionId = id; + this.snapshots.delete(plot); } if (this.activeSessionId === previousId) { this.activeSessionId = id; } + this.emitter.emit('change', id); } setSessionSource(sessionId: string, source: ViewerSessionSource): void { @@ -117,6 +122,15 @@ export class PlotHistory { sessions: [...this.sessions].map(([id, history]) => ({ id, ...history, + source: history.source ? { ...history.source } : undefined, + plots: history.plots.map((plot) => { + let snapshot = this.snapshots.get(plot); + if (!snapshot) { + snapshot = structuredClone(plot); + this.snapshots.set(plot, snapshot); + } + return snapshot; + }), currentIndex: Math.max( history.plots.length ? 0 : -1, history.plots.indexOf(this.currentPlot(id)!), @@ -133,13 +147,21 @@ export class PlotHistory { }; } - restore(value: unknown): void { + snapshotSelection(): Array<{ deviceId: string; currentIndex: number }> { + return [...new Set(this.timelines.values())].map((timeline) => ({ + deviceId: timeline.devices.values().next().value!, + currentIndex: timeline.currentIndex, + })); + } + + /** Returns whether enforcing the history limit changed the saved content. */ + restore(value: unknown, selection?: unknown): boolean { if (!value || typeof value !== 'object') { - return; + return false; } const state = value as Partial; if (!Array.isArray(state.sessions)) { - return; + return false; } for (const entry of state.sessions) { if ( @@ -156,7 +178,10 @@ export class PlotHistory { ) { continue; } - const plots = entry.plots.map((plot) => ({ ...plot, sessionId: entry.id })); + const plots = entry.plots.map((plot) => ({ + ...structuredClone(plot), + sessionId: entry.id, + })); for (const plot of plots) { this.order.set(plot, this.sequence++); } @@ -208,13 +233,37 @@ export class PlotHistory { timeline.currentIndex = saved.currentIndex; } } + if (Array.isArray(selection)) { + for (const entry of selection as unknown[]) { + if (!entry || typeof entry !== 'object') { + continue; + } + const saved = entry as Partial<{ deviceId: string; currentIndex: number }>; + const timeline = + typeof saved.deviceId === 'string' + ? this.timelines.get(saved.deviceId) + : undefined; + if ( + timeline && + typeof saved.currentIndex === 'number' && + Number.isInteger(saved.currentIndex) && + saved.currentIndex >= (timeline.plots.length ? 0 : -1) && + saved.currentIndex < timeline.plots.length + ) { + timeline.currentIndex = saved.currentIndex; + } + } + } + let trimmed = false; for (const timeline of new Set(this.timelines.values())) { for (const plot of timeline.plots) { this.order.set(plot, this.sequence++); } + trimmed ||= timeline.plots.length > this.maxPlots; this.trim(timeline); } this.emitter.emit('change'); + return trimmed; } private bindOwner(id: string, source: ViewerSessionSource, trim = true): void { @@ -258,7 +307,9 @@ export class PlotHistory { device.plots.splice(index, 1); } - onDidChange(listener: (sessionId?: string) => void): { dispose(): void } { + onDidChange(listener: (sessionId?: string, selectionOnly?: boolean) => void): { + dispose(): void; + } { this.emitter.on('change', listener); return { dispose: () => { @@ -311,6 +362,7 @@ export class PlotHistory { return true; } const latest = session.plots[session.plots.length - 1]; + this.snapshots.delete(latest); const newOps = plot.ops || []; for (const op of newOps) { latest.ops.push(op); @@ -369,6 +421,7 @@ export class PlotHistory { private replaceFrame(old: PlotFrame, plot: PlotFrame): void { // Preserve reference identity in both indexes and the original arrival order. const rIndex = old.rIndex ?? plot.rIndex; + this.snapshots.delete(old); Object.assign(old, plot, { sessionId: old.sessionId, rIndex }); } @@ -389,7 +442,9 @@ export class PlotHistory { invalidateReplayIndices(sessionId: string): void { for (const plot of this.sessions.get(sessionId)?.plots ?? []) { delete plot.rIndex; + this.snapshots.delete(plot); } + this.emitter.emit('change', sessionId); } navigatePrevious(sessionId = this.activeSessionId): PlotFrame | null { @@ -419,7 +474,7 @@ export class PlotHistory { return null; } session.currentIndex = index; - this.emitter.emit('change', sessionId); + this.emitter.emit('change', sessionId, true); return session.plots[session.currentIndex]; } diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index b6c3177d1..cc9a73f30 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -41,14 +41,13 @@ export class StandardPlotViewer implements PlotViewer { return this.source; } - constructor(private owner?: session.Session) { + constructor(owner?: session.Session) { if (owner) { this.bindSession(owner); } } bindSession(owner: session.Session): void { - this.owner = owner; this.source = { sessionId: owner.sessionId, host: owner.host, @@ -80,7 +79,12 @@ export class StandardPlotViewer implements PlotViewer { } public show(preserveFocus?: boolean): void { - if (!this.panel && this.owner && !this.owner.processExited) { + if ( + !this.panel && + this.source && + !this.source.processExited && + !this.sessionContext?.hasExited + ) { this.createPanel( asViewColumn( config().get('session.viewers.viewColumn.plot'), @@ -159,7 +163,10 @@ export class StandardPlotViewer implements PlotViewer { private requestPlot(): Promise { const panel = this.panel; - if (!panel || this.owner?.processExited || (!this.owner && !session.globalPipePath)) { + const targetSession = this.source + ? session.resolvePlotSession(this.source) + : session.activeSession; + if (!panel || (this.source ? !targetSession : !session.globalPipePath)) { return Promise.resolve(); } @@ -167,7 +174,7 @@ export class StandardPlotViewer implements PlotViewer { this.pendingPlotRequest = { panel, panelGeneration: this.panelGeneration, - targetSession: this.owner ?? session.activeSession, + targetSession, width: this.viewWidth, height: this.viewHeight, format: conf.get('plot.format', 'svglite'), @@ -265,10 +272,11 @@ export class StandardPlotViewer implements PlotViewer { private isCurrentRequest(request: PendingPlotRequest): boolean { return ( - (this.owner ? !this.owner.processExited : Boolean(session.globalPipePath)) && + (this.source ? Boolean(request.targetSession) : Boolean(session.globalPipePath)) && this.panel === request.panel && this.panelGeneration === request.panelGeneration && - (this.owner ?? session.activeSession) === request.targetSession + (this.source ? session.resolvePlotSession(this.source) : session.activeSession) === + request.targetSession ); } diff --git a/src/test/node/plotReconnect.test.ts b/src/test/node/plotReconnect.test.ts index 83b63edcb..95ed6d716 100644 --- a/src/test/node/plotReconnect.test.ts +++ b/src/test/node/plotReconnect.test.ts @@ -59,14 +59,15 @@ suite('Live R plot reconnect', function () { for (const [backend, deviceMode] of [ ['jgd', 'default'], ['jgd', 'reused'], + ['jgd', 'explicit-reused'], ['jgd', 'explicit'], ['standard', 'default'], ['httpgd', 'default'], ] as const) { - const reuseDevice = deviceMode === 'reused'; - const explicitDevice = deviceMode === 'explicit'; + const reuseDevice = deviceMode === 'reused' || deviceMode === 'explicit-reused'; + const explicitDevice = deviceMode === 'explicit' || deviceMode === 'explicit-reused'; const name = reuseDevice - ? 'jgd preserves closed-device history when R reuses a device number' + ? `jgd preserves closed-device history when ${explicitDevice ? 'an explicit device' : 'R'} reuses a device number` : explicitDevice ? 'explicit JGD devices resume plot delivery after reconnect' : `${backend} retains its live R plot across renderer and session reconnect`; @@ -172,7 +173,7 @@ suite('Live R plot reconnect', function () { `Sys.setenv(JGD_SOCKET=${JSON.stringify(jgd!.getSocketPath())}); sess::connect(${JSON.stringify(endpoint)}, use_rstudioapi=FALSE, plot_backend="${backend}"); TRUE`, ); await connect(); - if (explicitDevice) { + if (explicitDevice && !reuseDevice) { await evaluate('jgd::jgd(); TRUE'); } await evaluate('plot(1:3, main="first plot"); TRUE'); @@ -184,6 +185,9 @@ suite('Live R plot reconnect', function () { : undefined; if (reuseDevice) { await evaluate('old_device <- dev.cur(); dev.off(); TRUE'); + if (explicitDevice) { + await evaluate('jgd::jgd(); TRUE'); + } } await evaluate('plot(3:1, main="before reload"); TRUE'); if (reuseDevice) { @@ -194,8 +198,10 @@ suite('Live R plot reconnect', function () { let httpgdUrl: string | undefined; if (backend === 'jgd') { originalId = await until(() => - history.count() === plotsBeforeReload && - history.getActiveSessionId() !== closedHistory?.id + history + .snapshot() + .sessions.reduce((n, entry) => n + entry.plots.length, 0) === + plotsBeforeReload && history.getActiveSessionId() !== closedHistory?.id ? history.getActiveSessionId() : undefined, ); @@ -294,9 +300,18 @@ suite('Live R plot reconnect', function () { assert.ok(jgd!.isConnected(restoredId)); assert.equal(history.count(restoredId), 2); assert.equal(history.count(originalId), 0); - assert.equal(history.sessionIds().length, 1); + assert.equal(history.sessionIds().length, reuseDevice ? 2 : 1); + if (closedHistory) { + assert.deepEqual( + history + .snapshot() + .sessions.find((entry) => entry.id === closedHistory.id)?.plots, + closedHistory.plots, + ); + assert.equal(jgd!.isConnected(closedHistory.id), false); + } history.navigateFirst(restoredId); - assert.equal(history.currentRIndex(restoredId), undefined); + assert.equal(history.currentRIndex(restoredId), closedHistory?.plots[0].rIndex); assert.ok( JSON.stringify(history.currentPlot(restoredId)?.ops).includes('first plot'), ); diff --git a/src/test/suite/jgdPlotHistory.test.ts b/src/test/suite/jgdPlotHistory.test.ts index 3d77f4a7a..e93fb4943 100644 --- a/src/test/suite/jgdPlotHistory.test.ts +++ b/src/test/suite/jgdPlotHistory.test.ts @@ -507,6 +507,45 @@ suite('JGD PlotHistory', () => { }); }); + test('checkpoints reuse unchanged frames and isolate every kind of live frame mutation', () => { + const owner = { + sessionId: 'owner', + host: 'host', + pid: '123', + rVer: '4.6.0', + processExited: false, + }; + history.addPlot('a', { ...makePlot('A'), rIndex: 0 }); + history.setSessionSource('a', owner); + history.addPlot('b', makePlot('B')); + const first = history.snapshot(); + const original = structuredClone(first); + history.appendOps('a', { ...makePlot('A'), ops: [{ op: 'text', str: 'extra' }] }); + const appended = history.snapshot(); + assert.strictEqual(appended.sessions[1].plots[0], first.sessions[1].plots[0]); + assert.strictEqual(appended.sessions[0].plots[0].ops.length, 2); + assert.deepStrictEqual(first, original); + history.replaceAtIndex('a', 0, makePlot('resized')); + const replayed = history.snapshot(); + assert.strictEqual(replayed.sessions[0].plots[0].device.bg, 'resized'); + assert.strictEqual(appended.sessions[0].plots[0].device.bg, 'A'); + history.restoreDeviceIdentity('a', 'new-a', owner); + history.invalidateReplayIndices('new-a'); + const reconnected = history.snapshot().sessions.find((entry) => entry.id === 'new-a')!; + assert.strictEqual(reconnected.plots[0].sessionId, 'new-a'); + assert.strictEqual(reconnected.plots[0].rIndex, undefined); + assert.strictEqual(replayed.sessions[0].plots[0].sessionId, 'a'); + assert.strictEqual(replayed.sessions[0].plots[0].rIndex, 0); + const restored = new PlotHistory(); + restored.restore(first); + restored.appendOps('a', makePlot('more')); + assert.deepStrictEqual( + first, + original, + 'restoration must not share mutable geometry with storage', + ); + }); + suite('events', () => { test('restores each session navigation independently from reload state', () => { history.addPlot('a', makePlot('A1')); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 22c7594d1..99156879a 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -8,14 +8,14 @@ import * as vscode from 'vscode'; import { CommonPlotManager } from '../../plotViewer'; import { JgdManager } from '../../plotViewer/jgdViewer'; import type { JgdMessage } from '../../plotViewer/jgdSocketServer'; -import type { PlotFrame } from '../../plotViewer/jgdPlotHistory'; +import type { PlotFrame, PlotHistoryState } from '../../plotViewer/jgdPlotHistory'; import { HttpgdClient } from '../../plotViewer/httpgdClient'; import { HttpgdViewer } from '../../plotViewer/httpgdViewer'; import { extensionContext } from '../../extension'; import * as session from '../../session'; import * as util from '../../util'; import { mockExtensionContext } from '../common/mockvscode'; -import { waitForValue } from '../common/sessionConnections'; +import { deferred, waitForValue } from '../common/sessionConnections'; import { sessionProcessIdentity } from '../../sessionIdentity'; import type { ViewerSessionSource } from '../../viewerSession'; @@ -460,6 +460,91 @@ suite('Standalone plot restoration', () => { }); }); + test('JGD navigation checkpoints only cursors and restores them without rewriting frames', async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const first = manager(); + const history = first.jgdManager.history; + history.addPlot('device', frame('device', 'first')); + history.addPlot('device', frame('device', 'second')); + await clock.tickAsync(250); + const snapshot = sandbox.spy(history, 'snapshot'); + const update = (extensionContext.workspaceState as unknown as { update: sinon.SinonStub }) + .update; + update.resetHistory(); + history.navigateFirst('device'); + await clock.tickAsync(250); + sinon.assert.notCalled(snapshot); + sinon.assert.neverCalledWith(update, 'r.plot.jgdState'); + sinon.assert.calledWith(update, 'r.plot.jgdSelection'); + assert.strictEqual(manager().jgdManager.history.currentPlot('device')?.device.bg, 'first'); + await first.dispose(); + sinon.assert.notCalled(snapshot); + sinon.assert.neverCalledWith(update, 'r.plot.jgdState'); + + // A crash between the content and cursor writes leaves mismatched IDs. + state.set('r.plot.jgdSelection', { + checkpointId: 'obsolete', + positions: [{ deviceId: 'device', currentIndex: 0 }], + }); + assert.strictEqual(manager().jgdManager.history.currentPlot('device')?.device.bg, 'second'); + }); + + test('JGD coalesces pending checkpoints without mutating an in-flight snapshot', async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const gate = deferred(); + const writes: PlotHistoryState[] = []; + (extensionContext.workspaceState.update as sinon.SinonStub).callsFake( + async (key: string, value: unknown) => { + if (key === 'r.plot.jgdState') { + writes.push(value as PlotHistoryState); + if (writes.length === 1) { + await gate.promise; + } + } + state.set(key, structuredClone(value)); + }, + ); + const first = manager(); + const history = first.jgdManager.history; + history.addPlot('device', frame('device', 'first')); + await clock.tickAsync(250); + for (let i = 0; i < 3; i++) { + history.appendOps('device', { + ...frame('device'), + ops: [{ op: 'text', str: String(i) }], + }); + await clock.tickAsync(250); + } + assert.strictEqual(writes.length, 1, 'do not queue full snapshots behind a slow write'); + assert.deepStrictEqual( + writes[0].sessions[0].plots[0].ops, + [], + 'live drawing must not change the pending snapshot', + ); + gate.resolve(); + await first.dispose(); + assert.strictEqual(writes.length, 2); + assert.strictEqual(manager().jgdManager.history.currentPlot('device')?.ops.length, 3); + }); + + test('JGD checkpoints a reduced history limit before saving new cursor positions', async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const first = manager(); + for (const bg of ['first', 'second', 'third']) { + first.jgdManager.history.addPlot('device', frame('device', bg)); + } + await first.dispose(); + (util.config as sinon.SinonStub).returns({ + get: (key: string, fallback: unknown) => + key === 'plot.jgd.historyLimit' ? 2 : fallback, + }); + const restored = manager(); + assert.strictEqual(restored.jgdManager.history.count('device'), 2); + restored.jgdManager.history.navigateFirst('device'); + await clock.tickAsync(250); + assert.strictEqual(manager().jgdManager.history.currentPlot('device')?.device.bg, 'second'); + }); + test('all backends checkpoint restoration state before shutdown', async () => { const source = owner('checkpoint', '12150'); sandbox.stub(session, 'sessionRequest').resolves({ data: 'live', format: 'svg' }); @@ -511,6 +596,7 @@ suite('Standalone plot restoration', () => { panels = []; request.resetHistory(); request.resolves({ data: 'fresh-from-R', format: 'svg' }); + resolve.resetHistory(); manager(); assert.strictEqual(panels.length, 0); await callbacks.get('r.plot.showViewers')!(); diff --git a/src/test/suite/sessionConnectionLifecycle.test.ts b/src/test/suite/sessionConnectionLifecycle.test.ts index 91ac289cf..c247fb65f 100644 --- a/src/test/suite/sessionConnectionLifecycle.test.ts +++ b/src/test/suite/sessionConnectionLifecycle.test.ts @@ -12,6 +12,7 @@ import { pathExists } from '../../fileSystem'; import { SessConsentService } from '../../sessConsent'; import { mockExtensionContext } from '../common/mockvscode'; import { deferred, SessionConnections, waitForValue } from '../common/sessionConnections'; +import { StandardPlotViewer } from '../../plotViewer/standardViewer'; async function waitForFileRemoval(filePath: string): Promise { for (let attempt = 0; attempt < 100; attempt++) { @@ -57,6 +58,55 @@ suite('Session Connection Lifecycle API', () => { sandbox.restore(); }); + test('standard plot resize follows a reattachment but never a replacement R process', async () => { + const messages = new vscode.EventEmitter<{ + type: string; + width?: number; + height?: number; + }>(); + const disposed = new vscode.EventEmitter(); + sandbox.stub(vscode.window, 'createWebviewPanel').returns({ + webview: { + html: '', + asWebviewUri: (uri: vscode.Uri) => uri, + postMessage: () => Promise.resolve(true), + onDidReceiveMessage: messages.event, + }, + onDidDispose: disposed.event, + dispose: () => disposed.fire(), + } as unknown as vscode.WebviewPanel); + const request = sandbox + .stub(session, 'sessionRequest') + .resolves({ data: 'plot', format: 'svg' }); + const attach = async (pid: number) => { + await connections.attach('standard-reconnect', pid, 'external-host'); + await session.activateSessionById('standard-reconnect'); + return session.activeSession!; + }; + const original = await attach(55150); + const viewer = new StandardPlotViewer(original); + try { + await viewer.update(); + messages.fire({ type: 'ready' }); + await waitForValue(() => (request.calledOnce ? true : undefined)); + assert.strictEqual(request.lastCall.args[1], original); + const reattached = await attach(55150); + assert.notStrictEqual(reattached, original); + assert.ok(original.socket.destroyed); + messages.fire({ type: 'resize', width: 700, height: 500 }); + await waitForValue(() => (request.calledTwice ? true : undefined)); + assert.strictEqual(request.lastCall.args[1], reattached); + await attach(55151); + messages.fire({ type: 'resize', width: 800, height: 600 }); + await new Promise((resolve) => setImmediate(resolve)); + sinon.assert.calledTwice(request); + } finally { + viewer.dispose(); + messages.dispose(); + disposed.dispose(); + } + }); + test('JGD device labels resolve only to a unique connected local R process', async () => { const first = await connections.attach('jgd-local', 55101, os.hostname(), { attachmentId: 'jgd-test', diff --git a/src/test/suite/standardPlotViewer.test.ts b/src/test/suite/standardPlotViewer.test.ts index c82f28674..c563168fb 100644 --- a/src/test/suite/standardPlotViewer.test.ts +++ b/src/test/suite/standardPlotViewer.test.ts @@ -233,6 +233,7 @@ suite('Standard plot viewer request coordination', () => { processExited: false, } as session.Session; viewer = new StandardPlotViewer(owner); + sandbox.stub(session, 'resolvePlotSession').returns(owner); activeSession.value(owner); const response = deferred(); const request = sandbox.stub(session, 'sessionRequest').returns(response.promise); @@ -254,6 +255,43 @@ suite('Standard plot viewer request coordination', () => { assert.ok(panels[1].panel.webview.html.includes("type: 'ready'")); }); + test('a bound viewer discards an old connection response and drains the reattached request', async () => { + const owner = { + sessionId: 'bound', + host: 'external-host', + pid: '123', + rVer: '4.6.0', + processExited: false, + } as session.Session; + viewer = new StandardPlotViewer(owner); + const resolve = sandbox.stub(session, 'resolvePlotSession').returns(owner); + const old = deferred(); + const request = sandbox.stub(session, 'sessionRequest'); + request.onFirstCall().returns(old.promise); + request.onSecondCall().resolves({ data: 'reattached', format: 'svg' }); + await viewer.update(); + const initial = panels[0].ready(); + const reattached = { + sessionId: owner.sessionId, + host: owner.host, + pid: owner.pid, + rVer: owner.rVer, + processExited: false, + } as session.Session; + resolve.returns(reattached); + const resize = panels[0].resize(700, 500); + old.resolve({ data: 'stale', format: 'svg' }); + await Promise.all([initial, resize]); + assert.strictEqual(request.lastCall.args[1], reattached); + sinon.assert.calledOnceWithExactly( + panels[0].postMessage.withArgs(sinon.match({ type: 'update' })), + { type: 'update', data: 'reattached', format: 'svg' }, + ); + resolve.returns(undefined); + await panels[0].resize(800, 600); + sinon.assert.calledTwice(request); + }); + test('keeps a different active session queued when the old session returns no data', async () => { const sessionA = { sessionId: 'A' } as session.Session; const sessionB = { sessionId: 'B' } as session.Session; From 5d75e63b30b4c862bd00f9bad1a73fd8af0064f5 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:06:00 +1100 Subject: [PATCH 20/32] fix(plot): preserve notifications during session attachment --- src/session.ts | 17 +++ src/test/suite/plotRestoration.test.ts | 31 +++++ .../suite/sessionConnectionLifecycle.test.ts | 114 ++++++++++++++++++ 3 files changed, 162 insertions(+) diff --git a/src/session.ts b/src/session.ts index 7b45e04b3..3390b0279 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1381,6 +1381,7 @@ function startGlobalSessionServer(generation: number): Promise { const rpc = new NativeRpcConnection(socket); nativeRpcConnections.set(socket, rpc); + let attachment: Promise | undefined; const handleLine = (buf: Buffer) => { let lineEnd = buf.length; @@ -1395,6 +1396,22 @@ function startGlobalSessionServer(generation: number): Promise { void (async () => { try { const message = JSON.parse(line) as Record; + if ( + message.method === 'attach' && + (message.id === undefined || message.id === null) + ) { + attachment = handleNotification(message, socket); + await attachment; + return; + } + // R announces retained devices immediately after attach. + // Native terminal discovery is asynchronous: wait for it + // instead of dropping those first notifications. Once the + // socket is bound, RPC responses must remain independent + // of any UI work still completing in the attach handler. + if (!socket._sessionId && attachment) { + await attachment; + } if (message.method !== 'attach' && !isCurrentSocket(socket)) { return; } diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 99156879a..4656d7b1c 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -686,6 +686,37 @@ suite('Standalone plot restoration', () => { assert.strictEqual(panels.length, 2); }); + for (const backend of ['httpgd', 'jgd', 'standard'] as const) { + test(`${backend} recovers the active session across reloads without focusing its terminal`, async () => { + const source = owner('attached', '123'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); + sandbox.stub(session, 'sessionRequest').resolves({ data: 'plot', format: 'svg' }); + const showTerminal = sandbox.stub(); + const unrelatedTerminal = { show: showTerminal } as unknown as vscode.Terminal; + sandbox.stub(vscode.window, 'activeTerminal').value(unrelatedTerminal); + let value = manager(); + if (backend === 'httpgd') { + await value.showHttpgdPlot('http://localhost:1234/live?token=secret', source); + } else if (backend === 'jgd') { + value.jgdManager.history.addPlot('device', frame('device')); + value.jgdManager.history.setSessionSource('device', source); + } else { + await value.showStandardPlot(source); + } + for (let reload = 0; reload < 4; reload++) { + await value.dispose(); + panels = []; + value = manager(); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 1, `viewer recovered after reload ${reload}`); + assert.ok(panels[0].panel.webview.html.includes('R 4.6.0: 123')); + assert.strictEqual(session.activeSession, source); + sinon.assert.notCalled(showTerminal); + } + }); + } + test('multiple JGD devices in one R session reuse one viewer', async () => { const source = owner('a', '123'); focus(source); diff --git a/src/test/suite/sessionConnectionLifecycle.test.ts b/src/test/suite/sessionConnectionLifecycle.test.ts index c247fb65f..5a88f1f78 100644 --- a/src/test/suite/sessionConnectionLifecycle.test.ts +++ b/src/test/suite/sessionConnectionLifecycle.test.ts @@ -283,6 +283,120 @@ suite('Session Connection Lifecycle API', () => { assert.notStrictEqual(newReference.connectionId, oldReference.connectionId); }); + test('plot notifications arriving during native terminal discovery wait for attachment', async () => { + const processId = deferred(); + const discovering = deferred(); + const terminal = { + get processId() { + discovering.resolve(); + return processId.promise; + }, + sendText: sandbox.stub(), + show: sandbox.stub(), + } as unknown as vscode.Terminal; + terminals.value([terminal]); + activeTerminal.value(terminal); + const showHttpgdPlot = sandbox.stub().resolves(); + const showStandardPlot = sandbox.stub().resolves(); + sandbox.stub(extension, 'globalPlotManager').value({ showHttpgdPlot, showStandardPlot }); + const socket = await connections.startAttach('early-plot', 55160, os.hostname()); + await discovering.promise; + socket.emit( + 'data', + Buffer.from( + [ + JSON.stringify({ + method: 'httpgd', + params: { url: 'http://127.0.0.1:55160', restoring: true }, + }), + JSON.stringify({ method: 'plot_updated', params: {} }), + '', + ].join('\n'), + ), + ); + sinon.assert.notCalled(showHttpgdPlot); + sinon.assert.notCalled(showStandardPlot); + processId.resolve(55160); + await waitForValue(() => session.getSessionReference('early-plot')); + await new Promise((resolve) => setImmediate(resolve)); + sinon.assert.calledOnce(showHttpgdPlot); + sinon.assert.calledOnce(showStandardPlot); + assert.strictEqual( + (showHttpgdPlot.firstCall.args[1] as session.Session).sessionId, + 'early-plot', + ); + assert.strictEqual( + (showStandardPlot.firstCall.args[0] as session.Session).sessionId, + 'early-plot', + ); + }); + + for (const ending of ['disconnected', 'replaced'] as const) { + test(`queued plots are discarded when the pending attachment is ${ending}`, async () => { + const processId = deferred(); + const discovering = deferred(); + const terminal = { + get processId() { + discovering.resolve(); + return processId.promise; + }, + } as unknown as vscode.Terminal; + terminals.value([terminal]); + const showHttpgdPlot = sandbox.stub().resolves(); + const showStandardPlot = sandbox.stub().resolves(); + sandbox + .stub(extension, 'globalPlotManager') + .value({ showHttpgdPlot, showStandardPlot }); + const socket = await connections.startAttach('queued-plot', 55161, os.hostname()); + await discovering.promise; + socket.emit( + 'data', + Buffer.from( + [ + JSON.stringify({ + method: 'httpgd', + params: { url: 'http://127.0.0.1:55161' }, + }), + JSON.stringify({ method: 'plot_updated', params: {} }), + '', + ].join('\n'), + ), + ); + if (ending === 'disconnected') { + socket.destroy(); + } else { + await connections.attach('queued-plot', 55162, 'external-host'); + } + processId.resolve(55161); + await waitForValue(() => (!session.activeConnections.has(socket) ? true : undefined)); + await new Promise((resolve) => setImmediate(resolve)); + sinon.assert.notCalled(showHttpgdPlot); + sinon.assert.notCalled(showStandardPlot); + assert.strictEqual( + Boolean(session.getSessionReference('queued-plot')), + ending === 'replaced', + ); + }); + } + + test('RPC responses do not wait for attachment UI work once the socket is bound', async () => { + const activationGate = deferred(); + executeCommand.withArgs('setContext').returns(activationGate.promise); + try { + const socket = await connections.attach('rpc-during-attach', 55163, 'external-host'); + const write = sandbox.spy(socket, 'write'); + const reply = session.sessionRequest({ method: 'plot_latest' }, 'rpc-during-attach'); + const request = JSON.parse(String(write.lastCall.args[0])) as { id: number }; + socket.emit( + 'data', + Buffer.from(JSON.stringify({ id: request.id, result: 'plot' }) + '\n'), + ); + assert.strictEqual(await reply, 'plot'); + } finally { + activationGate.resolve(undefined); + } + }); + test('standard plot notifications target their originating session, not the active terminal', async () => { const showStandardPlot = sandbox.stub().resolves(); sandbox.stub(extension, 'globalPlotManager').value({ showStandardPlot }); From 7621859b374bf025745e4bef5096b1d239ba5bcf Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:30:16 +1100 Subject: [PATCH 21/32] fix(plot): remove unnecessary fallbacks and obsolete viewer code - Preserve saved httpgd credentials when loading fails and report the error - Stop selecting an arbitrary restored JGD device - Always send JGD session identity metadata - Remove the unbound standard viewer and unused history/resize code - Bump sess to 3.4.0.9005 Validated all three plot backends across repeated VS Code reloads. --- sess/DESCRIPTION | 2 +- sess/R/hooks.R | 16 ++--- src/plotViewer/httpgdViewer.ts | 19 ++++-- src/plotViewer/index.ts | 23 ++----- src/plotViewer/jgdPlotHistory.ts | 12 +--- src/plotViewer/jgdSocketServer.ts | 5 -- src/plotViewer/standardViewer.ts | 35 +++------- src/plotViewer/types.ts | 2 +- src/test/suite/jgdPlotHistory.test.ts | 32 ++------- src/test/suite/plotCommands.test.ts | 10 ++- src/test/suite/plotRestoration.test.ts | 67 ++++++++++++++++++ src/test/suite/session.test.ts | 6 +- src/test/suite/standardPlotViewer.test.ts | 82 +++++++++++++---------- 13 files changed, 165 insertions(+), 146 deletions(-) diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index aec2640dc..b3a60c624 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -1,7 +1,7 @@ Package: sess Type: Package Title: High-Performance IPC Bridge for R Sessions -Version: 3.4.0.9004 +Version: 3.4.0.9005 Authors@R: c( person(given = "Randy", family = "Lai", diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 3d0e30bbf..b573d6b71 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -247,15 +247,13 @@ runtime_start <- function(use_rstudioapi = TRUE, # Carry the logical device identity across a new renderer socket. Device # numbers and JGD's native counter can change when the device is reopened. set_identity <- function(restoring = FALSE) { - if ("jgd_frame_ext" %in% getNamespaceExports("jgd")) { - jgd::jgd_frame_ext(jsonlite::toJSON(list(vscodeR = list( - sessionId = .session_id(), host = unname(Sys.info()[["nodename"]]), - pid = as.character(Sys.getpid()), - rVer = sprintf("%s.%s", R.version$major, R.version$minor), - processExited = FALSE, deviceId = key, restoring = restoring, - previousDeviceId = previous_device_id - )), auto_unbox = TRUE)) - } + jgd::jgd_frame_ext(jsonlite::toJSON(list(vscodeR = list( + sessionId = .session_id(), host = unname(Sys.info()[["nodename"]]), + pid = as.character(Sys.getpid()), + rVer = sprintf("%s.%s", R.version$major, R.version$minor), + processExited = FALSE, deviceId = key, restoring = restoring, + previousDeviceId = previous_device_id + )), auto_unbox = TRUE)) } set_identity(!is.null(recorded)) if (!is.null(recorded)) { diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index f3000f1f6..10ab41ac3 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -83,10 +83,11 @@ export class HttpgdManager { // URLs contain device tokens, so keep them in SecretStorage. this.savedConnections = Promise.resolve(extensionContext.secrets.get(this.secretKey)) .then((value) => { - const connections: unknown = value ? JSON.parse(value) : []; - const saved = Array.isArray(connections) - ? connections.filter(isHttpgdConnection) - : []; + const connections: unknown = value === undefined ? [] : JSON.parse(value); + if (!Array.isArray(connections)) { + throw new Error('Invalid saved httpgd connections'); + } + const saved = connections.filter(isHttpgdConnection); if (!this.disposed) { for (const connection of saved) { this.observeSavedSession(connection.source); @@ -94,7 +95,15 @@ export class HttpgdManager { } return saved; }) - .catch(() => []); + .catch(() => { + // Parse errors can contain token-bearing JSON. Expose only + // a fixed message and keep the load rejected so a later save + // cannot overwrite unread credentials with an empty list. + throw new Error('Could not load saved httpgd plot connections.'); + }); + void this.savedConnections.catch((error: Error) => { + void vscode.window.showErrorMessage(error.message); + }); } } diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index af1f4e08e..bed2479be 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -54,7 +54,6 @@ const commands = [ export class CommonPlotManager implements PlotManager { public httpgdManager: HttpgdManager; - public standardPlotViewer: StandardPlotViewer; public jgdManager: JgdManager; private standardViewers = new Map(); private restoredStandardSessions: ViewerSessionSource[] = []; @@ -68,14 +67,12 @@ export class CommonPlotManager implements PlotManager { constructor() { this.httpgdManager = new HttpgdManager(); - this.standardPlotViewer = new StandardPlotViewer(); this.jgdManager = new JgdManager(); } get viewers(): PlotViewer[] { const viewers: PlotViewer[] = [...this.httpgdManager.viewers]; viewers.push(...this.jgdManager.getViewers()); - viewers.push(this.standardPlotViewer); viewers.push(...this.standardViewers.values()); return viewers; } @@ -86,13 +83,9 @@ export class CommonPlotManager implements PlotManager { return focused; } if (jgdEnabled()) { - return ( - this.jgdManager.getViewer() || - this.httpgdManager.getRecentViewer() || - this.standardPlotViewer - ); + return this.jgdManager.getViewer() || this.httpgdManager.getRecentViewer(); } - return this.httpgdManager.getRecentViewer() || this.standardPlotViewer; + return this.httpgdManager.getRecentViewer(); } public initialize(): void { @@ -176,8 +169,8 @@ export class CommonPlotManager implements PlotManager { } } - public async showStandardPlot(target = activeSession): Promise { - if (!target || this.disposed) { + public async showStandardPlot(target: Session): Promise { + if (this.disposed) { return; } await this.getStandardViewer(target).update(); @@ -229,7 +222,6 @@ export class CommonPlotManager implements PlotManager { for (const viewer of this.standardViewers.values()) { viewer.dispose(); } - this.standardPlotViewer.dispose(); this.httpgdManager.dispose(); await saved; } @@ -276,12 +268,7 @@ export class CommonPlotManager implements PlotManager { this.restoredStandardSessions.map((source) => [sessionProcessIdentity(source), source]), ); for (const viewer of this.standardViewers.values()) { - if (viewer.sessionSource) { - standardSources.set( - sessionProcessIdentity(viewer.sessionSource), - viewer.sessionSource, - ); - } + standardSources.set(sessionProcessIdentity(viewer.sessionSource), viewer.sessionSource); } await extensionContext.workspaceState .update('r.plot.standardSessions', [...standardSources.values()]) diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts index fbea6b7fb..b31956cbe 100644 --- a/src/plotViewer/jgdPlotHistory.ts +++ b/src/plotViewer/jgdPlotHistory.ts @@ -200,7 +200,7 @@ export class PlotHistory { this.activeSessionId = state.activeSessionId && this.sessions.has(state.activeSessionId) ? state.activeSessionId - : (this.sessionIds().at(-1) ?? ''); + : ''; // Older snapshots only stored per-device histories. Group those by owner; // newer snapshots also retain the exact interleaving and session cursor. for (const [id, device] of this.sessions) { @@ -342,16 +342,6 @@ export class PlotHistory { this.emitter.emit('change', sessionId); } - replaceCurrent(sessionId: string, plot: PlotFrame) { - const old = this.currentPlot(sessionId); - if (!old) { - return this.addPlot(sessionId, plot); - } - this.replaceFrame(old, plot); - this.activeSessionId = sessionId; - this.emitter.emit('change', sessionId); - } - appendOps(sessionId: string, plot: PlotFrame): boolean { const session = this.sessions.get(sessionId); if (session && session.latestDeleted) { diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts index 7c957d09e..e3ec5b608 100644 --- a/src/plotViewer/jgdSocketServer.ts +++ b/src/plotViewer/jgdSocketServer.ts @@ -64,7 +64,6 @@ export class JgdSocketServer { private connectionListeners: ConnectionChangeListener[] = []; private readyListeners: (() => void)[] = []; - private resizeListener: ((w: number, h: number) => void) | null = null; private measureTextFn: JgdMeasureText | null = null; private getDimensionsFn: JgdGetDimensions | null = null; private onFrameFn: ((sessionId: string, msg: JgdMessage) => void) | null = null; @@ -88,10 +87,6 @@ export class JgdSocketServer { this.connectionListeners.push(listener); } - setResizeListener(listener: (w: number, h: number) => void) { - this.resizeListener = listener; - } - setMeasureText(fn: JgdMeasureText) { this.measureTextFn = fn; } diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index cc9a73f30..0bab9d407 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -18,7 +18,7 @@ interface PlotResponse { interface PendingPlotRequest { panel: vscode.WebviewPanel; panelGeneration: number; - targetSession: session.Session | undefined; + targetSession: session.Session; width: number; height: number; format: string; @@ -34,17 +34,15 @@ export class StandardPlotViewer implements PlotViewer { private pendingPlotRequest: PendingPlotRequest | undefined; private plotRequest: Promise | undefined; - private source: ViewerSessionSource | undefined; + private source!: ViewerSessionSource; private sessionContext: ViewerSessionContext | undefined; - get sessionSource(): ViewerSessionSource | undefined { + get sessionSource(): ViewerSessionSource { return this.source; } - constructor(owner?: session.Session) { - if (owner) { - this.bindSession(owner); - } + constructor(owner: session.Session) { + this.bindSession(owner); } bindSession(owner: session.Session): void { @@ -79,12 +77,7 @@ export class StandardPlotViewer implements PlotViewer { } public show(preserveFocus?: boolean): void { - if ( - !this.panel && - this.source && - !this.source.processExited && - !this.sessionContext?.hasExited - ) { + if (!this.panel && !this.source.processExited && !this.sessionContext?.hasExited) { this.createPanel( asViewColumn( config().get('session.viewers.viewColumn.plot'), @@ -154,19 +147,15 @@ export class StandardPlotViewer implements PlotViewer { }); // Install the receiver before starting the document so its first ready // message cannot be missed, even without a visible layout/resize event. - this.sessionContext = this.source - ? session.getViewerSessionContext(this.source.sessionId, this.source) - : undefined; + this.sessionContext = session.getViewerSessionContext(this.source.sessionId, this.source); this.sessionContext?.attach(panel); panel.webview.html = this.getHtml(); } private requestPlot(): Promise { const panel = this.panel; - const targetSession = this.source - ? session.resolvePlotSession(this.source) - : session.activeSession; - if (!panel || (this.source ? !targetSession : !session.globalPipePath)) { + const targetSession = session.resolvePlotSession(this.source); + if (!panel || !targetSession) { return Promise.resolve(); } @@ -272,15 +261,13 @@ export class StandardPlotViewer implements PlotViewer { private isCurrentRequest(request: PendingPlotRequest): boolean { return ( - (this.source ? Boolean(request.targetSession) : Boolean(session.globalPipePath)) && this.panel === request.panel && this.panelGeneration === request.panelGeneration && - (this.source ? session.resolvePlotSession(this.source) : session.activeSession) === - request.targetSession + session.resolvePlotSession(this.source) === request.targetSession ); } - private discardPendingForSession(targetSession: session.Session | undefined): void { + private discardPendingForSession(targetSession: session.Session): void { if (this.pendingPlotRequest?.targetSession === targetSession) { this.pendingPlotRequest = undefined; } diff --git a/src/plotViewer/types.ts b/src/plotViewer/types.ts index 29df92a9f..806a3d60d 100644 --- a/src/plotViewer/types.ts +++ b/src/plotViewer/types.ts @@ -22,6 +22,6 @@ export interface PlotManager { viewers: PlotViewer[]; activeViewer: PlotViewer | undefined; initialize(): void; - showStandardPlot(target?: Session): Promise; + showStandardPlot(target: Session): Promise; showHttpgdPlot(url: string, target?: Session, restoring?: boolean): Promise; } diff --git a/src/test/suite/jgdPlotHistory.test.ts b/src/test/suite/jgdPlotHistory.test.ts index e93fb4943..660b441ff 100644 --- a/src/test/suite/jgdPlotHistory.test.ts +++ b/src/test/suite/jgdPlotHistory.test.ts @@ -122,32 +122,6 @@ suite('JGD PlotHistory', () => { }); }); - suite('replaceCurrent', () => { - test('replaces the current plot in place', () => { - history.addPlot('s1', makePlot('A')); - history.addPlot('s1', makePlot('B')); - history.replaceCurrent('s1', makePlot('B2')); - assert.strictEqual(history.count(), 2); - assert.strictEqual(history.currentPlot()?.device.bg, 'B2'); - }); - - test('falls back to addPlot on empty session', () => { - history.replaceCurrent('s1', makePlot('A')); - assert.strictEqual(history.count(), 1); - assert.strictEqual(history.currentPlot()?.device.bg, 'A'); - }); - - test('replaces at navigated position, not latest', () => { - history.addPlot('s1', makePlot('A')); - history.addPlot('s1', makePlot('B')); - history.navigatePrevious(); - history.replaceCurrent('s1', makePlot('A2')); - assert.strictEqual(history.currentPlot()?.device.bg, 'A2'); - history.navigateNext(); - assert.strictEqual(history.currentPlot()?.device.bg, 'B'); - }); - }); - suite('replaceLatest', () => { test('replaces the latest plot regardless of navigation', () => { history.addPlot('s1', makePlot('A')); @@ -582,8 +556,10 @@ suite('JGD PlotHistory', () => { ], }); assert.deepStrictEqual(restored.sessionIds(), ['valid']); - assert.strictEqual(restored.count(), 1); - assert.strictEqual(restored.currentPlot()?.device.bg, 'latest'); + assert.strictEqual(restored.getActiveSessionId(), ''); + assert.strictEqual(restored.currentPlot(), null); + assert.strictEqual(restored.count('valid'), 1); + assert.strictEqual(restored.currentPlot('valid')?.device.bg, 'latest'); }); test('emits change on addPlot', () => { diff --git a/src/test/suite/plotCommands.test.ts b/src/test/suite/plotCommands.test.ts index b142ef5ea..ac70bb2da 100644 --- a/src/test/suite/plotCommands.test.ts +++ b/src/test/suite/plotCommands.test.ts @@ -88,18 +88,17 @@ suite('Contextual plot commands', () => { sandbox.stub(session, 'activeSession').value(source); sandbox.stub(session, 'resolvePlotSession').returns(source); const showForSession = sandbox.stub(manager.jgdManager, 'showForSession').returns(true); + const handleJgdCommand = sandbox.stub(); const jgdViewer = { show: showJgdViewer, - handleCommand: sandbox.stub(), + handleCommand: handleJgdCommand, } as unknown as JgdViewer; sandbox.stub(manager.jgdManager, 'getViewers').returns([jgdViewer]); const viewer = sandbox.createStubInstance(HttpgdViewer); Object.defineProperty(viewer, 'host', { value: 'localhost:1234' }); viewer.getPanelPath.returns('/plot-viewer'); manager.httpgdManager.viewers.push(viewer); - const showStandardViewer = sandbox.stub(manager.standardPlotViewer, 'show'); - const fallback = sandbox.stub(manager.standardPlotViewer, 'handleCommand'); - sandbox.stub(manager, 'activeViewer').get(() => manager.standardPlotViewer); + sandbox.stub(manager, 'activeViewer').get(() => jgdViewer); const openUrl = sandbox.stub(manager.httpgdManager, 'openUrl').resolves(); manager.initialize(); @@ -130,8 +129,7 @@ suite('Contextual plot commands', () => { sinon.assert.calledOnceWithExactly(showForSession, source); assert.strictEqual(viewer.show.callCount, 0); sinon.assert.notCalled(showJgdViewer); - sinon.assert.notCalled(showStandardViewer); - sinon.assert.notCalled(fallback); + sinon.assert.notCalled(handleJgdCommand); } finally { sandbox.restore(); } diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 4656d7b1c..596e82c96 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -307,6 +307,50 @@ suite('Standalone plot restoration', () => { assert.deepStrictEqual(value.httpgdManager.viewers, []); }); + for (const failure of ['read', 'json', 'shape'] as const) { + test(`httpgd ${failure} failure is reported and cannot erase saved credentials`, async () => { + const source = owner('httpgd-owner', '14001'); + const key = 'saved-connections'; + const saved = failure === 'json' ? '{"token":"must-not-be-reported"' : '{}'; + state.set('r.plot.httpgdConnectionsKey', key); + secrets.set(key, saved); + if (failure === 'read') { + (extensionContext.secrets.get as sinon.SinonStub).rejects(new Error('read failed')); + } + const report = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + sandbox.stub(console, 'warn'); + const value = manager(); + await assert.rejects(value.httpgdManager.restoreConnection(source), { + message: 'Could not load saved httpgd plot connections.', + }); + sinon.assert.calledOnce(report); + assert.deepStrictEqual(report.firstCall.args, [ + 'Could not load saved httpgd plot connections.', + ]); + await value.httpgdManager.saveConnections(); + // A newly announced device still works, but must not overwrite + // credentials for other sessions that could not be read. + await value.showHttpgdPlot('http://localhost:1234?token=new-device', source); + assert.ok(await value.httpgdManager.restoreConnection(source)); + await value.dispose(); + assert.strictEqual(state.get('r.plot.httpgdConnectionsKey'), key); + assert.strictEqual(secrets.get(key), saved); + assert.strictEqual((extensionContext.secrets.store as sinon.SinonStub).callCount, 0); + assert.strictEqual((extensionContext.secrets.delete as sinon.SinonStub).callCount, 0); + }); + } + + test('a missing httpgd secret is empty storage and accepts a new connection', async () => { + const source = owner('httpgd-owner', '14001'); + state.set('r.plot.httpgdConnectionsKey', 'missing-secret'); + const report = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const value = manager(); + assert.strictEqual(await value.httpgdManager.restoreConnection(source), undefined); + await value.showHttpgdPlot('http://localhost:1234?token=new-device', source); + assert.ok(secrets.get('missing-secret')?.includes('new-device')); + sinon.assert.notCalled(report); + }); + test('httpgd already exited owners cannot register a new connection', async () => { const exits = processExits(); const source = owner('httpgd-owner', '14001'); @@ -686,6 +730,29 @@ suite('Standalone plot restoration', () => { assert.strictEqual(panels.length, 2); }); + test('a missing saved JGD device does not select another session during restore', async () => { + const a = owner('a', '123'); + const b = owner('b', '456'); + focus(a); + sandbox.stub(session, 'resolvePlotSession').returns(a); + const first = manager(); + for (const source of [a, b]) { + first.jgdManager.history.addPlot(source.sessionId, frame(source.sessionId)); + first.jgdManager.history.setSessionSource(source.sessionId, source); + } + await first.dispose(); + const saved = state.get('r.plot.jgdState') as PlotHistoryState; + saved.activeSessionId = 'missing-device'; + const restored = manager(); + assert.strictEqual(restored.jgdManager.history.getActiveSessionId(), ''); + assert.strictEqual(restored.jgdManager.getViewer(), null); + assert.strictEqual(panels.length, 0); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(panels.length, 1); + assert.ok(panels[0].panel.webview.html.includes('R 4.6.0: 123')); + assert.strictEqual(restored.jgdManager.history.count('b'), 1); + }); + for (const backend of ['httpgd', 'jgd', 'standard'] as const) { test(`${backend} recovers the active session across reloads without focusing its terminal`, async () => { const source = owner('attached', '123'); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 9df06b35f..05ec72549 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -653,7 +653,11 @@ suite('Session Communication', () => { // observer before issuing the R command so an earlier/stale panel response // cannot satisfy this phase. const plotManager = extension.globalPlotManager as plotViewer.CommonPlotManager; - plotManager.standardPlotViewer.dispose(); + for (const viewer of plotManager.viewers) { + if (viewer.id === 'standard') { + viewer.dispose(); + } + } let resolvePlotUpdate!: ( message: { type: string; data?: string; format?: string }, delivered: boolean, diff --git a/src/test/suite/standardPlotViewer.test.ts b/src/test/suite/standardPlotViewer.test.ts index c563168fb..75f069236 100644 --- a/src/test/suite/standardPlotViewer.test.ts +++ b/src/test/suite/standardPlotViewer.test.ts @@ -31,6 +31,8 @@ suite('Standard plot viewer request coordination', () => { let sandbox: sinon.SinonSandbox; let viewer: StandardPlotViewer; let activeSession: sinon.SinonStub; + let resolveSession: sinon.SinonStub; + let owner: session.Session; let panels: Array<{ panel: vscode.WebviewPanel; resize: (width: number, height: number) => Promise; @@ -38,12 +40,23 @@ suite('Standard plot viewer request coordination', () => { postMessage: sinon.SinonStub; }>; + function connection(): session.Session { + return { + sessionId: 'owner', + host: 'external-host', + pid: '123', + rVer: '4.6.0', + processExited: false, + } as session.Session; + } + setup(() => { sandbox = sinon.createSandbox(); panels = []; const root = path.resolve(__dirname, '../../..'); mockExtensionContext(root, sandbox); - sandbox.stub(session, 'globalPipePath').value('test-pipe'); + owner = connection(); + resolveSession = sandbox.stub(session, 'resolvePlotSession').returns(owner); activeSession = sandbox.stub(session, 'activeSession').value(undefined); sandbox.stub(util, 'config').returns({ get: (key: string, defaultValue?: unknown) => @@ -86,7 +99,7 @@ suite('Standard plot viewer request coordination', () => { }); return panel; }); - viewer = new StandardPlotViewer(); + viewer = new StandardPlotViewer(owner); }); teardown(() => { @@ -209,16 +222,13 @@ suite('Standard plot viewer request coordination', () => { }); }); - test('does not post a response from a session that stopped being active', async () => { - const firstSession = {} as session.Session; - const nextSession = {} as session.Session; - activeSession.value(firstSession); + test('does not post a response after its owning session disconnects', async () => { const requestResult = deferred(); sandbox.stub(session, 'sessionRequest').returns(requestResult.promise); await viewer.update(); const resize = panels[0].resize(100, 100); - activeSession.value(nextSession); + resolveSession.returns(undefined); requestResult.resolve({ data: 'stale session', format: 'svg' }); await resize; sinon.assert.notCalled(panels[0].postMessage); @@ -233,7 +243,7 @@ suite('Standard plot viewer request coordination', () => { processExited: false, } as session.Session; viewer = new StandardPlotViewer(owner); - sandbox.stub(session, 'resolvePlotSession').returns(owner); + resolveSession.returns(owner); activeSession.value(owner); const response = deferred(); const request = sandbox.stub(session, 'sessionRequest').returns(response.promise); @@ -264,7 +274,7 @@ suite('Standard plot viewer request coordination', () => { processExited: false, } as session.Session; viewer = new StandardPlotViewer(owner); - const resolve = sandbox.stub(session, 'resolvePlotSession').returns(owner); + const resolve = resolveSession.returns(owner); const old = deferred(); const request = sandbox.stub(session, 'sessionRequest'); request.onFirstCall().returns(old.promise); @@ -292,10 +302,10 @@ suite('Standard plot viewer request coordination', () => { sinon.assert.calledTwice(request); }); - test('keeps a different active session queued when the old session returns no data', async () => { - const sessionA = { sessionId: 'A' } as session.Session; - const sessionB = { sessionId: 'B' } as session.Session; - activeSession.value(sessionA); + test('keeps the reattached connection queued when the old session returns no data', async () => { + const sessionA = owner; + const sessionB = connection(); + resolveSession.returns(sessionA); const firstRequest = deferred(); const secondRequest = deferred(); const secondStarted = deferred(); @@ -308,7 +318,7 @@ suite('Standard plot viewer request coordination', () => { }); await viewer.update(); const firstResize = panels[0].resize(100, 100); - activeSession.value(sessionB); + resolveSession.returns(sessionB); const sessionBUpdate = viewer.update(); sinon.assert.calledOnce(request); @@ -326,10 +336,10 @@ suite('Standard plot viewer request coordination', () => { }); }); - test('keeps a different active session queued when the old session rejects', async () => { - const sessionA = { sessionId: 'A' } as session.Session; - const sessionB = { sessionId: 'B' } as session.Session; - activeSession.value(sessionA); + test('keeps the reattached connection queued when the old session rejects', async () => { + const sessionA = owner; + const sessionB = connection(); + resolveSession.returns(sessionA); const firstRequest = deferred(); const secondRequest = deferred(); const secondStarted = deferred(); @@ -342,7 +352,7 @@ suite('Standard plot viewer request coordination', () => { }); await viewer.update(); const firstResize = panels[0].resize(100, 100); - activeSession.value(sessionB); + resolveSession.returns(sessionB); const sessionBUpdate = viewer.update(); firstRequest.reject(new Error('session A failed')); @@ -359,11 +369,11 @@ suite('Standard plot viewer request coordination', () => { }); }); - test('uses only the latest queued connection when sessions change repeatedly', async () => { - const sessionA = { sessionId: 'A' } as session.Session; - const sessionB = { sessionId: 'B' } as session.Session; - const sessionC = { sessionId: 'C' } as session.Session; - activeSession.value(sessionA); + test('uses only the latest queued connection when the connection changes repeatedly', async () => { + const sessionA = owner; + const sessionB = connection(); + const sessionC = connection(); + resolveSession.returns(sessionA); const firstRequest = deferred(); const secondRequest = deferred(); const secondStarted = deferred(); @@ -376,9 +386,9 @@ suite('Standard plot viewer request coordination', () => { }); await viewer.update(); const firstResize = panels[0].resize(100, 100); - activeSession.value(sessionB); + resolveSession.returns(sessionB); const updateB = viewer.update(); - activeSession.value(sessionC); + resolveSession.returns(sessionC); const updateC = viewer.update(); firstRequest.resolve({ data: 'stale A', format: 'svg' }); @@ -396,18 +406,18 @@ suite('Standard plot viewer request coordination', () => { }); }); - test('discards a queued session that became stale without a newer plot event', async () => { - const sessionA = { sessionId: 'A' } as session.Session; - const sessionB = { sessionId: 'B' } as session.Session; - const sessionC = { sessionId: 'C' } as session.Session; - activeSession.value(sessionA); + test('discards a queued connection that became stale without a newer plot event', async () => { + const sessionA = owner; + const sessionB = connection(); + const sessionC = connection(); + resolveSession.returns(sessionA); const firstRequest = deferred(); const request = sandbox.stub(session, 'sessionRequest').returns(firstRequest.promise); await viewer.update(); const firstResize = panels[0].resize(100, 100); - activeSession.value(sessionB); + resolveSession.returns(sessionB); const updateB = viewer.update(); - activeSession.value(sessionC); + resolveSession.returns(sessionC); firstRequest.resolve({ data: 'stale A', format: 'svg' }); await Promise.all([firstResize, updateB]); @@ -416,8 +426,8 @@ suite('Standard plot viewer request coordination', () => { }); test('does not retry a failed request for the same session after panel recreation', async () => { - const sessionA = { sessionId: 'A' } as session.Session; - activeSession.value(sessionA); + const sessionA = owner; + resolveSession.returns(sessionA); const firstRequest = deferred(); const request = sandbox.stub(session, 'sessionRequest').returns(firstRequest.promise); await viewer.update(); @@ -446,8 +456,6 @@ suite('Standard plot viewer request coordination', () => { }); test('requests the first plot when the webview is ready without a layout resize', async () => { - const owner = { sessionId: 'ready-owner' } as session.Session; - activeSession.value(owner); const request = sandbox .stub(session, 'sessionRequest') .resolves({ data: 'plot', format: 'svg' }); From afd8fdeda0dda8a135c9f1d80760dacaecdfdc20 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:24:55 +1100 Subject: [PATCH 22/32] fix(plot): preserve viewer state across reconnects and reopening - Refresh open standard plot viewers after sess reconnect without reopening closed panels - Fetch the latest standard plot when showing an existing viewer - Retain httpgd viewport changes during disconnects and ignore stale render dimensions - Preserve httpgd divider height when reopening in a resized editor group - Restore JGD devices using captured dimensions and DPI without hardcoded defaults - Add regression coverage for reconnects, device settings, and both httpgd layouts --- sess/R/hooks.R | 16 +- src/plotViewer/httpgdViewer.ts | 9 +- src/plotViewer/index.ts | 7 +- src/plotViewer/standardViewer.ts | 9 +- src/plotViewer/types.ts | 2 +- src/plotViewer/webview/index.ejs | 2 +- src/plotViewer/webview/index.ts | 2 - src/session.ts | 2 +- src/test/node/plotReconnect.test.ts | 38 +++- src/test/suite/httpgdViewer.test.ts | 41 ++++ src/test/suite/plotRestoration.test.ts | 193 +++++++++++++++++- .../suite/sessionConnectionLifecycle.test.ts | 10 + 12 files changed, 308 insertions(+), 23 deletions(-) diff --git a/sess/R/hooks.R b/sess/R/hooks.R index b573d6b71..5e15d3ab9 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -231,8 +231,8 @@ runtime_start <- function(use_rstudioapi = TRUE, package_version(getNamespaceVersion("httpgd")) >= "2.0.1" selected_backend <- .select_plot_backend(plot_backend, has_httpgd, has_jgd) if (selected_backend == "jgd") { - open_jgd_device <- function(key = NULL, recorded = NULL, previous_device_id = NULL) { - jgd::jgd() + open_jgd_device <- function(key = NULL, recorded = NULL, previous_device_id = NULL, ...) { + jgd::jgd(...) .runtime_track_device() if (is.null(key)) { .sess_env$plot_device_sequence <- (.sess_env$plot_device_sequence %||% 0L) + 1L @@ -303,9 +303,14 @@ runtime_start <- function(use_rstudioapi = TRUE, next } grDevices::dev.set(entry$id) + # Capture the live size and resolution before closing this device. Explicit + # jgd() calls may use a different DPI, and the renderer may have resized it. + size <- grDevices::dev.size("in") + dpi <- grDevices::dev.size("px")[[1L]] / size[[1L]] recorded <- tryCatch(grDevices::recordPlot(), error = function(e) NULL) tryCatch(grDevices::dev.off(entry$id), error = function(e) NULL) - open_jgd_device(entry$key, recorded, entry$previous_device_id) + open_jgd_device(entry$key, recorded, entry$previous_device_id, + width = size[[1L]], height = size[[2L]], dpi = dpi) if (identical(unname(previous), entry$id)) previous <- grDevices::dev.cur() } if (previous %in% grDevices::dev.list()) grDevices::dev.set(previous) @@ -429,5 +434,10 @@ runtime_start <- function(use_rstudioapi = TRUE, .runtime_add_task_callback(.workspace_update_task_callback, name = "sess.workspace") completed <- TRUE + # Announce the refreshed record only after setup is complete: a failed write + # can stop the runtime, so no callbacks should be installed after this point. + if (selected_backend == "standard" && !is.null(.sess_env$latest_plot_record)) { + notify_client("plot_updated", list(restoring = TRUE)) + } invisible(NULL) } diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 10ab41ac3..188306b79 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -350,6 +350,7 @@ export class HttpgdManager { } interface EjsData { + plotHeight: number; sessionHtml: string; sessionStyle: string; codicons: string; @@ -762,11 +763,12 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } protected handleResize(height: number, width: number, userTriggered: boolean = false): void { + // Keep viewport changes while disconnected for the reconnect snapshot. + this.viewHeight = height; + this.viewWidth = width; if (!this.canRefresh) { return; } - this.viewHeight = height; - this.viewWidth = width; if (userTriggered || this.resizeTimeoutLength === 0) { if (this.resizeTimeout) { clearTimeout(this.resizeTimeout); @@ -924,8 +926,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { width: width, zoom: zoom, }; - this.viewHeight = plt.height; - this.viewWidth = plt.width; return plt; } @@ -966,6 +966,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { overwriteCssPath = asWebViewPath('styleOverwrites.css'); } return { + plotHeight: this.viewHeight, sessionHtml: this.sessionContext?.getHtml() ?? '', sessionStyle: viewerSessionStyle, codicons: diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index bed2479be..1174f69fb 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -169,11 +169,14 @@ export class CommonPlotManager implements PlotManager { } } - public async showStandardPlot(target: Session): Promise { + public async showStandardPlot(target: Session, restoring = false): Promise { if (this.disposed) { return; } - await this.getStandardViewer(target).update(); + const viewer = restoring + ? this.standardViewers.get(sessionProcessIdentity(target)) + : this.getStandardViewer(target); + await viewer?.update(restoring); } private getStandardViewer(target: Session): StandardPlotViewer { diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index 0bab9d407..6659d067b 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -63,7 +63,12 @@ export class StandardPlotViewer implements PlotViewer { return plotPanelPath(this.panel); } - public async update(): Promise { + public async update(restoring = false): Promise { + if (restoring) { + // Refresh retained panels without creating or revealing a viewer. + await this.requestPlot(); + return; + } const viewColumn = asViewColumn( config().get('session.viewers.viewColumn.plot'), vscode.ViewColumn.Two, @@ -84,6 +89,8 @@ export class StandardPlotViewer implements PlotViewer { vscode.ViewColumn.Two, ), ); + } else if (this.panel) { + void this.requestPlot(); } if (this.panel) { this.panel.reveal(undefined, preserveFocus); diff --git a/src/plotViewer/types.ts b/src/plotViewer/types.ts index 806a3d60d..fc15fa0e9 100644 --- a/src/plotViewer/types.ts +++ b/src/plotViewer/types.ts @@ -22,6 +22,6 @@ export interface PlotManager { viewers: PlotViewer[]; activeViewer: PlotViewer | undefined; initialize(): void; - showStandardPlot(target: Session): Promise; + showStandardPlot(target: Session, restoring?: boolean): Promise; showHttpgdPlot(url: string, target?: Session, restoring?: boolean): Promise; } diff --git a/src/plotViewer/webview/index.ejs b/src/plotViewer/webview/index.ejs index 0513f5ff7..21dd93ee6 100644 --- a/src/plotViewer/webview/index.ejs +++ b/src/plotViewer/webview/index.ejs @@ -10,7 +10,7 @@
<%- sessionHtml %>
-
> +
> <%- largePlot?.data %>
diff --git a/src/plotViewer/webview/index.ts b/src/plotViewer/webview/index.ts index e3ecfc79a..a314346b9 100644 --- a/src/plotViewer/webview/index.ts +++ b/src/plotViewer/webview/index.ts @@ -20,7 +20,6 @@ let oldWidth = -1; const handler = document.querySelector('#handler') as HTMLDivElement; const largePlotDiv = document.querySelector('#largePlot') as HTMLDivElement; -const largeSvg = largePlotDiv.querySelector('svg') as SVGElement; const cssLink = document.querySelector('link.overwrites') as HTMLLinkElement; const smallPlotDiv = document.querySelector('#smallPlots') as HTMLDivElement; @@ -168,7 +167,6 @@ function toggleFullWindowMode(useFullWindow: boolean): void { //// window.onload = () => { - largePlotDiv.style.height = `${largeSvg.clientHeight}px`; postResizeMessage(true); }; diff --git a/src/session.ts b/src/session.ts index 3390b0279..0902f6d38 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4122,7 +4122,7 @@ async function handleNotification(message: Record, socket: IpcS case 'plot_updated': { const target = socket._sessionId && sessions.get(socket._sessionId); if (target && target.socket === socket) { - void globalPlotManager?.showStandardPlot(target); + void globalPlotManager?.showStandardPlot(target, params.restoring === true); } break; } diff --git a/src/test/node/plotReconnect.test.ts b/src/test/node/plotReconnect.test.ts index 95ed6d716..fd9a3dced 100644 --- a/src/test/node/plotReconnect.test.ts +++ b/src/test/node/plotReconnect.test.ts @@ -174,7 +174,7 @@ suite('Live R plot reconnect', function () { ); await connect(); if (explicitDevice && !reuseDevice) { - await evaluate('jgd::jgd(); TRUE'); + await evaluate('jgd::jgd(width=4, height=3, dpi=144); TRUE'); } await evaluate('plot(1:3, main="first plot"); TRUE'); if (backend === 'jgd') { @@ -186,7 +186,7 @@ suite('Live R plot reconnect', function () { if (reuseDevice) { await evaluate('old_device <- dev.cur(); dev.off(); TRUE'); if (explicitDevice) { - await evaluate('jgd::jgd(); TRUE'); + await evaluate('jgd::jgd(width=4, height=3, dpi=144); TRUE'); } } await evaluate('plot(3:1, main="before reload"); TRUE'); @@ -239,6 +239,19 @@ suite('Live R plot reconnect', function () { ); } const deviceIds = history.sessionIds(); + if (backend === 'jgd') { + // Preserve the live viewport size, not just the dimensions + // originally passed to the device factory. + jgd!.handleResize(864, 648, originalId!); + await evaluate('.Call(jgd:::C_jgd_poll_resize); TRUE'); + await until(() => + history.latestPlot(originalId!)?.device.width === 864 && + history.latestPlot(originalId!)?.device.height === 648 + ? true + : undefined, + ); + await evaluate('device_geometry <- c(dev.size("in"), dev.size("px")); TRUE'); + } if (explicitDevice) { externalRenderer = net.createServer((socket) => { externalSockets.add(socket); @@ -285,11 +298,19 @@ suite('Live R plot reconnect', function () { const saved: unknown = JSON.parse(JSON.stringify(history.snapshot())); await stopServers(); await evaluate('sess:::.transport_disconnect(silent=TRUE, reconnect=TRUE); TRUE'); + if (backend === 'standard') { + await evaluate('plot(2:4, main="while disconnected"); TRUE'); + } history = new PlotHistory(); history.restore(saved); notifications.length = 0; await startServers(); await connect(); + if (backend === 'jgd') { + await evaluate( + 'stopifnot(isTRUE(all.equal(c(dev.size("in"), dev.size("px")), device_geometry))); TRUE', + ); + } if (explicitDevice) { // Reopening assigns a logical ID and transfers the existing // timeline entry, rather than duplicating its current plot. @@ -428,15 +449,20 @@ suite('Live R plot reconnect', function () { }); assert.ok(svg.includes('before reload')); } else { - assert.ok( - !notifications.some((item) => item.method === 'plot_updated'), - 'reattachment must not announce an existing standard plot as new', + assert.equal( + await until( + () => + notifications.find((item) => item.method === 'plot_updated')?.params + .restoring, + ), + true, + 'reattachment refreshes existing standard viewers without opening closed ones', ); const plot = await evaluate( 'sess:::handle_plot_latest(list(format="svglite"))', ); assert.ok( - Buffer.from(plot.data, 'base64').toString().includes('before reload'), + Buffer.from(plot.data, 'base64').toString().includes('while disconnected'), ); } } finally { diff --git a/src/test/suite/httpgdViewer.test.ts b/src/test/suite/httpgdViewer.test.ts index 3e0f9d3aa..610f1c9d4 100644 --- a/src/test/suite/httpgdViewer.test.ts +++ b/src/test/suite/httpgdViewer.test.ts @@ -11,6 +11,9 @@ import type { ViewerSessionContext } from '../../viewerSession'; import type { RSessionConnectionChange } from '../../api'; class LifecycleViewer extends HttpgdViewer { + resizeTo(width: number, height: number): void { + this.handleResize(height, width); + } resizeNow(): void { this.handleResize(600, 900, true); } @@ -257,6 +260,44 @@ suite('httpgd viewer requests', () => { state.viewer.webviewPanel = undefined; }); + test('reconnect renders at the viewport size received while disconnected despite a late old render', async () => { + const clock = sandbox.useFakeTimers(); + const state = lifetime(); + let finish!: (svg: string) => void; + const request = sandbox.stub(state.viewer.api, 'getPlotText'); + request.onFirstCall().returns( + new Promise((resolve) => { + finish = resolve; + }), + ); + request.onSecondCall().resolves(''); + state.viewer.resizeNow(); + state.resolve.returns(undefined); + state.fire('disconnected'); + state.viewer.resizeTo(1200, 700); + finish(''); + await clock.tickAsync(200); + sinon.assert.calledOnce(request); + + state.resolve.returns(state.source as session.Session); + state.fire('connected'); + await state.viewer.queueRefresh(); + await clock.tickAsync(10); + sinon.assert.calledTwice(request); + assert.deepStrictEqual(request.secondCall.args[0], { + id: '1', + width: 1200, + height: 700, + zoom: 1, + renderer: 'svgp', + }); + assert.strictEqual( + state.viewer.webviewPanel, + undefined, + 'reconnect must not reopen a closed panel', + ); + }); + test('session disconnect suppresses in-flight refresh errors and reconnect resumes only its owner', async () => { const clock = sandbox.useFakeTimers(); const state = lifetime(); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 596e82c96..36b089162 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -28,6 +28,36 @@ class RefreshViewer extends HttpgdViewer { } } +class LayoutViewer extends HttpgdViewer { + protected override makeHtml(): string { + return super.makeHtml().replace( + '', + ``, + ); + } +} + suite('Standalone plot restoration', () => { let sandbox: sinon.SinonSandbox; let state: Map; @@ -655,9 +685,10 @@ suite('Standalone plot restoration', () => { request.resetHistory(); resolve.resetHistory(); await callbacks.get('r.plot.showViewers')!(); - sinon.assert.notCalled(request); - sinon.assert.calledOnce(resolve); + sinon.assert.calledOnce(request); + assert.strictEqual(request.firstCall.args[1], owner); assert.strictEqual(panels.length, 1); + request.resetHistory(); const reattached = { sessionId: owner.sessionId, host: owner.host, @@ -994,6 +1025,45 @@ suite('Standalone plot restoration', () => { assert.strictEqual(second.webviewPanel, undefined); }); + test('standard reconnect refreshes only its existing panel without revealing or reopening it', async () => { + const a = owner('a', '123'); + const b = owner('b', '456'); + const reattached = owner('a', '123'); + focus(b); + sandbox + .stub(session, 'resolvePlotSession') + .callsFake((source) => (source.sessionId === a.sessionId ? reattached : b)); + const request = sandbox + .stub(session, 'sessionRequest') + .resolves({ data: 'old', format: 'svg' }); + const value = manager(); + await value.showStandardPlot(a); + await value.showStandardPlot(b); + await panels[0].receive({ type: 'ready' }); + request.resetHistory(); + panels[0].post.resetHistory(); + request.resolves({ data: 'drawn-while-disconnected', format: 'svg' }); + + await value.showStandardPlot(reattached, true); + sinon.assert.calledOnce(request); + assert.strictEqual(request.firstCall.args[1], reattached); + sinon.assert.calledWith(panels[0].post, { + type: 'update', + data: 'drawn-while-disconnected', + format: 'svg', + }); + sinon.assert.notCalled((panels[0].panel as unknown as { reveal: sinon.SinonStub }).reveal); + sinon.assert.notCalled(panels[1].post); + + panels[0].panel.dispose(); + request.resetHistory(); + await value.showStandardPlot(reattached, true); + await value.showStandardPlot(owner('never-opened', '789'), true); + sinon.assert.notCalled(request); + assert.strictEqual(panels.length, 2); + assert.strictEqual(value.viewers.length, 2); + }); + test('standard restoration requests only the focused session', async () => { const a = owner('a', '123'); const b = owner('b', '456'); @@ -1231,6 +1301,125 @@ suite('Standalone plot restoration', () => { sinon.assert.calledOnce(connect); }); + for (const fullWindow of [false, true]) { + test(`httpgd ${fullWindow ? 'full-window' : 'divider'} layout follows a resized group after closing and reopening`, async () => { + interface Layout { + width: number; + height: number; + windowWidth: number; + windowHeight: number; + fullWindow: boolean; + } + let inspection: Layout | undefined; + (vscode.window.createWebviewPanel as sinon.SinonStub).callsFake( + (...args: Parameters) => { + const panel = createRealWebviewPanel(...args); + panel.webview.onDidReceiveMessage((message: Layout & { message: string }) => { + if (message.message === 'layout-inspection') { + inspection = message; + } + }); + return panel; + }, + ); + const layout = (width: number, height: number) => + executeRealCommand('vscode.setEditorLayout', { + orientation: 0, + groups: [ + { size: 1 - width }, + { size: width, groups: [{ size: height }, { size: 1 - height }] }, + ], + }); + const value = manager(); + const viewer = new LayoutViewer('http://localhost:1234', { + ...value.httpgdManager.viewerOptions, + fullWindow, + resizeTimeoutLength: 0, + source: owner('layout', '14002'), + }); + value.httpgdManager.viewers.push(viewer); + const svg = (width: number, height: number) => + ``; + viewer.plots = [{ id: '1', data: svg(800, 400), width: 800, height: 400, zoom: 1 }]; + viewer.activePlot = '1'; + const render = sandbox + .stub(viewer.api, 'getPlotText') + .callsFake((request) => Promise.resolve(svg(request.width!, request.height!))); + const inspect = async (dragHeight?: number): Promise => { + inspection = undefined; + await viewer.webviewPanel!.webview.postMessage({ + message: 'inspect-layout', + dragHeight, + }); + return waitForValue(() => inspection); + }; + const originalLayout = await executeRealCommand('vscode.getEditorLayout'); + const documents: vscode.TextDocument[] = []; + try { + for (const viewColumn of [ + vscode.ViewColumn.One, + vscode.ViewColumn.Two, + vscode.ViewColumn.Three, + ]) { + const document = await vscode.workspace.openTextDocument({ + language: 'plaintext', + }); + documents.push(document); + await vscode.window.showTextDocument(document, { viewColumn, preview: false }); + } + await layout(0.7, 0.75); + viewer.show(); + await waitForValue(() => (render.called ? true : undefined)); + const enlarged = await inspect(fullWindow ? undefined : 240); + assert.strictEqual(enlarged.fullWindow, fullWindow); + if (!fullWindow) { + assert.strictEqual(enlarged.height, 240); + } + viewer.webviewPanel!.dispose(); + assert.strictEqual(viewer.webviewPanel, undefined); + await layout(0.4, 0.5); + render.resetHistory(); + viewer.show(); + await waitForValue(() => (render.called ? true : undefined)); + const reopened = await inspect(); + assert.ok(reopened.windowWidth < enlarged.windowWidth); + assert.ok(reopened.windowHeight < enlarged.windowHeight); + assert.strictEqual(reopened.fullWindow, fullWindow); + assert.strictEqual( + viewer.viewWidth, + fullWindow ? reopened.windowWidth : reopened.width, + ); + assert.strictEqual(viewer.viewHeight, reopened.height); + if (fullWindow) { + assert.strictEqual(reopened.height, reopened.windowHeight - 28); + } else { + assert.strictEqual( + reopened.height, + enlarged.height, + 'preserve the divider height when reopening', + ); + } + } finally { + viewer.webviewPanel?.dispose(); + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if ( + tab.input instanceof vscode.TabInputText && + documents.some( + (document) => + document.uri.toString() === + (tab.input as vscode.TabInputText).uri.toString(), + ) + ) { + await vscode.window.tabGroups.close(tab); + } + } + } + await executeRealCommand('vscode.setEditorLayout', originalLayout); + } + }); + } + test('httpgd reload fetches fresh plots from an authenticated server', async () => { const source = owner('httpgd-owner', '14001'); focus(source); diff --git a/src/test/suite/sessionConnectionLifecycle.test.ts b/src/test/suite/sessionConnectionLifecycle.test.ts index 5a88f1f78..e66b7893b 100644 --- a/src/test/suite/sessionConnectionLifecycle.test.ts +++ b/src/test/suite/sessionConnectionLifecycle.test.ts @@ -413,6 +413,16 @@ suite('Session Connection Lifecycle API', () => { 'standard-a', ); assert.strictEqual(session.activeSession?.sessionId, 'standard-b'); + assert.strictEqual(showStandardPlot.firstCall.args[1], false); + first.emit( + 'data', + Buffer.from( + JSON.stringify({ method: 'plot_updated', params: { restoring: true } }) + '\n', + ), + ); + await waitForValue(() => (showStandardPlot.calledTwice ? true : undefined)); + assert.strictEqual(showStandardPlot.secondCall.args[0], showStandardPlot.firstCall.args[0]); + assert.strictEqual(showStandardPlot.secondCall.args[1], true); const owner = showStandardPlot.firstCall.args[0] as session.Session; assert.strictEqual(session.resolvePlotSession(owner), owner); assert.strictEqual( From 066614a9c275b212e5fdbeb9c797447ba1c852e2 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:29:47 +1100 Subject: [PATCH 23/32] fix(plots): unify viewer lifecycle and consolidate httpgd devices - Share plot ownership, viewer reuse, and exit cleanup across engines - Delete in-memory history and saved state on permanent R exit while leaving rendered plots and viewer tabs untouched - Preserve restoration across VS Code reloads and live R reconnects - Combine httpgd devices into one viewer with collision-safe plot IDs and device-specific rendering, export, and deletion - Coalesce httpgd refreshes, discard stale responses, and avoid rerendering inactive devices at new sizes - Discover explicit httpgd devices without taking lifecycle ownership - Prevent commands from ended viewers from targeting another session - Add regression coverage for cleanup, device reuse, and restoration - Remove legacy html/httpgd files replaced by src/plotViewer/webview - Remove unused getNewestViewer method and MaybePromise type - Remove write-only plotWidth and plotHeight viewer fields - Update the shared webview message types comment Validation: 152 VS Code tests and 20 Node tests passed; build, type checking, lint, and formatting passed. --- html/httpgd/index.ejs | 25 -- html/httpgd/index.ts | 238 ------------ html/httpgd/smallPlot.ejs | 15 - html/httpgd/style.css | 136 ------- html/httpgd/styleOverwrites.css | 15 - html/httpgd/vars.css | 470 ----------------------- html/httpgd/webviewMessages.d.ts | 68 ---- sess/R/hooks.R | 40 +- sess/inst/tinytest/test-httpgd-devices.R | 63 +++ sess/inst/tinytest/test-httpgd-version.R | 1 + src/plotViewer/httpgdDevices.ts | 223 +++++++++++ src/plotViewer/httpgdTypes.d.ts | 7 +- src/plotViewer/httpgdViewer.ts | 407 ++++++++++++-------- src/plotViewer/index.ts | 76 ++-- src/plotViewer/jgdPlotHistory.ts | 74 +++- src/plotViewer/jgdSocketServer.ts | 9 + src/plotViewer/jgdViewer.ts | 90 +++-- src/plotViewer/plotLifecycle.ts | 73 ++++ src/plotViewer/plotViewerRegistry.ts | 100 +++++ src/plotViewer/standardViewer.ts | 7 +- src/plotViewer/types.ts | 2 +- src/plotViewer/webviewMessages.d.ts | 2 +- src/test/node/httpgdDevices.test.ts | 137 +++++++ src/test/suite/httpgdViewer.test.ts | 15 +- src/test/suite/jgdSocketServer.test.ts | 24 ++ src/test/suite/plotCommands.test.ts | 13 +- src/test/suite/plotRestoration.test.ts | 449 ++++++++++++++++++++-- 27 files changed, 1524 insertions(+), 1255 deletions(-) delete mode 100644 html/httpgd/index.ejs delete mode 100644 html/httpgd/index.ts delete mode 100644 html/httpgd/smallPlot.ejs delete mode 100644 html/httpgd/style.css delete mode 100644 html/httpgd/styleOverwrites.css delete mode 100644 html/httpgd/vars.css delete mode 100644 html/httpgd/webviewMessages.d.ts create mode 100644 sess/inst/tinytest/test-httpgd-devices.R create mode 100644 src/plotViewer/httpgdDevices.ts create mode 100644 src/plotViewer/plotLifecycle.ts create mode 100644 src/plotViewer/plotViewerRegistry.ts create mode 100644 src/test/node/httpgdDevices.test.ts diff --git a/html/httpgd/index.ejs b/html/httpgd/index.ejs deleted file mode 100644 index 99036c41c..000000000 --- a/html/httpgd/index.ejs +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - > - - - -
> - <%- largePlot?.data %> -
-
-
- <% plots.forEach((plot)=> { %> -
- <%- include(asLocalPath('./smallPlot.ejs'), {plot: plot}) %> -
- <% }) %> -
-
- - - - diff --git a/html/httpgd/index.ts b/html/httpgd/index.ts deleted file mode 100644 index 71242128d..000000000 --- a/html/httpgd/index.ts +++ /dev/null @@ -1,238 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -interface Plot { - // unique ID for this plot (w.r.t. this connection/device) - id: string; - - // svg of the plot - svg: string; - - height?: number; - width?: number; -} - -// get vscode api -declare function acquireVsCodeApi(): VsCode; -const vscode = acquireVsCodeApi(); - -// globals -let oldHeight = -1; -let oldWidth = -1; - -const handler = document.querySelector('#handler') as HTMLDivElement; -const largePlotDiv = document.querySelector('#largePlot') as HTMLDivElement; -const largeSvg = largePlotDiv.querySelector('svg') as SVGElement; -const cssLink = document.querySelector('link.overwrites') as HTMLLinkElement; -const smallPlotDiv = document.querySelector('#smallPlots') as HTMLDivElement; - -function getSmallPlots(): HTMLAnchorElement[] { - const smallPlots: HTMLAnchorElement[] = []; - document.querySelectorAll('a.focusPlot').forEach((elm) => { - smallPlots.push(elm as HTMLAnchorElement); - }); - return smallPlots; -} - -let isHandlerDragging = false; - -let isFullWindow = false; - -function postResizeMessage(userTriggered: boolean = false) { - let newHeight = largePlotDiv.clientHeight; - let newWidth = largePlotDiv.clientWidth; - if (isFullWindow) { - newHeight = window.innerHeight; - newWidth = window.innerWidth; - } - if (userTriggered || newHeight !== oldHeight || newWidth !== oldWidth) { - const msg: ResizeMessage = { - message: 'resize', - height: newHeight, - width: newWidth, - userTriggered: userTriggered, - }; - vscode.postMessage(msg); - oldHeight = newHeight; - oldWidth = newWidth; - } -} - -function postLogMessage(content: any) { - console.log(content); - vscode.postMessage({ - message: 'log', - body: content, - }); -} - -window.addEventListener('message', (ev: MessageEvent) => { - const msg = ev.data; - if (msg.message === 'updatePlot') { - updatePlot({ - id: String(msg.plotId), - svg: msg.svg, - }); - } else if (msg.message === 'focusPlot') { - focusPlot(String(msg.plotId)); - } else if (msg.message === 'toggleStyle') { - toggleStyle(msg.useOverwrites); - } else if (msg.message === 'hidePlot') { - hidePlot(msg.plotId); - } else if (msg.message === 'addPlot') { - addPlot(msg.html); - } else if (msg.message === 'togglePreviewPlotLayout') { - togglePreviewPlotLayout(msg.style); - } else if (msg.message === 'toggleFullWindow') { - toggleFullWindowMode(msg.useFullWindow); - } -}); - -function addPlot(html: string) { - const wrapper = document.createElement('div'); - wrapper.classList.add('wrapper'); - wrapper.innerHTML = html; - smallPlotDiv.appendChild(wrapper); -} - -function focusPlot(plotId: string): void { - const smallPlots = getSmallPlots(); - - const ind = findIndex(plotId, smallPlots); - if (ind < 0) { - return; - } - - for (const elm of smallPlots) { - elm.classList.remove('active'); - } - - const smallPlot = smallPlots[ind]; - - smallPlot.classList.add('active'); - - largePlotDiv.innerHTML = smallPlot.innerHTML; -} - -function updatePlot(plt: Plot): void { - const smallPlots = getSmallPlots(); - - const ind = findIndex(plt.id, smallPlots); - if (ind < 0) { - return; - } - - smallPlots[ind].innerHTML = plt.svg; - - if (smallPlots[ind].classList.contains('active')) { - largePlotDiv.innerHTML = plt.svg; - } -} - -function hidePlot(plotId: string): void { - const smallPlots = getSmallPlots(); - - const ind = findIndex(plotId, smallPlots); - if (ind < 0) { - return; - } - - if (smallPlots[ind].classList.contains('active')) { - largePlotDiv.innerHTML = ''; - } - - smallPlots[ind].parentElement?.remove(); -} - -function findIndex(plotId: string, smallPlots?: Element[]): number { - smallPlots ||= getSmallPlots(); - const ind = smallPlots.findIndex((elm) => elm.getAttribute('plotId') === String(plotId)); - if (ind < 0) { - console.warn(`plotId not found: ${plotId}`); - } - return ind; -} - -function toggleStyle(useOverwrites: boolean): void { - cssLink.disabled = !useOverwrites; -} - -function togglePreviewPlotLayout(newStyle: PreviewPlotLayout): void { - smallPlotDiv.classList.remove('multirow', 'scroll', 'hidden'); - smallPlotDiv.classList.add(newStyle); -} - -function toggleFullWindowMode(useFullWindow: boolean): void { - isFullWindow = useFullWindow; - if (useFullWindow) { - document.body.classList.add('fullWindow'); - window.scrollTo(0, 0); - } else { - document.body.classList.remove('fullWindow'); - } - postResizeMessage(true); -} - -//// -// On window load -//// - -window.onload = () => { - largePlotDiv.style.height = `${largeSvg.clientHeight}px`; - postResizeMessage(true); -}; - -//// -// Resize bar -//// - -document.addEventListener('mousedown', (e) => { - // If mousedown event is fired from .handler, toggle flag to true - if (!isFullWindow && e.target === handler) { - isHandlerDragging = true; - handler.classList.add('dragging'); - document.body.style.cursor = 'ns-resize'; - } -}); - -document.addEventListener('mousemove', (e) => { - // Don't do anything if dragging flag is false - if (isFullWindow || !isHandlerDragging) { - return false; - } - - // postLogMessage('mousemove'); - - // Get offset - const containerOffsetTop = document.body.offsetTop; - - // Get x-coordinate of pointer relative to container - const pointerRelativeYpos = e.clientY - containerOffsetTop + window.scrollY; - - // Arbitrary minimum width set on box A, otherwise its inner content will collapse to width of 0 - const largePlotMinHeight = 60; - - // Resize large plot - const newHeight = Math.max(largePlotMinHeight, pointerRelativeYpos - 5); // <- why 5? - const newHeightString = `${newHeight}px`; - - if (largePlotDiv.style.height !== newHeightString) { - largePlotDiv.style.height = newHeightString; - postResizeMessage(); - } -}); - -window.onresize = () => postResizeMessage(); - -document.addEventListener('mouseup', () => { - // Turn off dragging flag when user mouse is up - if (isHandlerDragging) { - postResizeMessage(true); - document.body.style.cursor = ''; - } - handler.classList.remove('dragging'); - isHandlerDragging = false; -}); diff --git a/html/httpgd/smallPlot.ejs b/html/httpgd/smallPlot.ejs deleted file mode 100644 index 378646ab8..000000000 --- a/html/httpgd/smallPlot.ejs +++ /dev/null @@ -1,15 +0,0 @@ -
- <%- plot.data %> - - - ✖ - diff --git a/html/httpgd/style.css b/html/httpgd/style.css deleted file mode 100644 index c7d30c9b4..000000000 --- a/html/httpgd/style.css +++ /dev/null @@ -1,136 +0,0 @@ - -/* Use box-sizing: border-box everywhere: */ -html { - box-sizing: border-box; -} -*, *::before, *::after { - box-sizing: inherit; -} - -/* General styling: */ -body { - padding-left: 1px; - padding-right: 1px; -} -body.fullWindow { - overflow-x: hidden; - overflow-y: hidden; -} - -svg { - user-select: none; -} - -/* Stretch large plot during resizing, */ -/* Keep small plots the same size: */ -.httpgd { - width: 100%; - height: 100%; -} - -/* Main plot area: */ -#largePlot { - overflow-x: auto; - overflow-y: hidden; - padding: 10px; - width: 100%; - height: 100%; -} -body.fullWindow #largePlot { - overflow-x: hidden; - width: 100vw !important; - height: 100vh !important; -} - -/* Dragbar to resize main plot: */ -#handler { - background-color: var(--vscode-textSeparator-foreground); - height: 4px; - cursor: ns-resize; -} -#handler:hover, #handler.dragging { - background-color: var(--vscode-focusBorder); - transition: background-color .1s ease-out; - transition-delay: .2s; -} -body.fullWindow #handler { - display: none; -} - -#placeholder { - height: 95vh; -} -body.fullWindow #placeHolder { - display: none; -} - -/* Plot history: */ - -#smallPlots { - display: flex; - /* flex-direction: row; */ - position: relative; - overflow-x: auto; - flex-direction: row; - overflow-y: hidden; - padding: 10px; -} -body.fullWindow #smallPlots { - display: none; -} - -#smallPlots.multirow { - overflow-x: hidden; - flex-wrap: wrap; -} - -#smallPlots.hidden { - display: none; -} - -/* Each small plot: */ - -#smallPlots .wrapper { - position: relative; - height: 15vw; - width: 19vw; - flex: none; - padding: 3px; - padding-bottom: 12px; -} - -a.focusPlot { - height: 100%; - width: 100%; -} - -a.hidePlot { - display: none; - position: absolute; - top: 0; - right: 5px; - text-decoration: none; - font-size: 2em; - user-select: none; - color: var(--vscode-foreground); -} - -.plotContent { - height: 100%; - width: 100%; -} - -.smallPlot:not(.active):hover { - background-color: var(--vscode-list-hoverBackground); - background-clip: content-box; -} - -/* Hide plot button: */ -#smallPlots .wrapper:hover a.hidePlot { - display: block; -} - -#smallPlots .wrapper a.hidePlot:hover { - color: var(--vscode-errorForeground); -} - diff --git a/html/httpgd/styleOverwrites.css b/html/httpgd/styleOverwrites.css deleted file mode 100644 index 953ac5611..000000000 --- a/html/httpgd/styleOverwrites.css +++ /dev/null @@ -1,15 +0,0 @@ - - -.httpgd rect { - stroke: none !important; - fill: none !important; -} - -svg text { - font-family: var(--vscode-editor-font-family) !important; - fill: var(--vscode-foreground) !important; -} - -.httpgd line, .httpgd polyline, .httpgd polygon, .httpgd path, .httpgd circle, .httpgd rect:not(:first-of-type) { - stroke: var(--vscode-foreground) !important; -} diff --git a/html/httpgd/vars.css b/html/httpgd/vars.css deleted file mode 100644 index d20aad6b3..000000000 --- a/html/httpgd/vars.css +++ /dev/null @@ -1,470 +0,0 @@ -/* This file contains a list of variables available in a vscode webview and their values from the Dark+ theme */ - -.fromvscode { - --vscode-font-family: "Segoe WPC", "Segoe UI", sans-serif; - --vscode-font-weight: normal; - --vscode-font-size: 13px; - --vscode-editor-font-family: Consolas, "Courier New", monospace; - --vscode-editor-font-weight: normal; - --vscode-editor-font-size: 14px; - --vscode-foreground: #cccccc; - --vscode-errorForeground: #f48771; - --vscode-descriptionForeground: rgba(204, 204, 204, 0.7); - --vscode-icon-foreground: #c5c5c5; - --vscode-focusBorder: #007fd4; - --vscode-textSeparator-foreground: rgba(255, 255, 255, 0.18); - --vscode-textLink-foreground: #3794ff; - --vscode-textLink-activeForeground: #3794ff; - --vscode-textPreformat-foreground: #d7ba7d; - --vscode-textBlockQuote-background: rgba(127, 127, 127, 0.1); - --vscode-textBlockQuote-border: rgba(0, 122, 204, 0.5); - --vscode-textCodeBlock-background: rgba(10, 10, 10, 0.4); - --vscode-widget-shadow: rgba(0, 0, 0, 0.36); - --vscode-input-background: #3c3c3c; - --vscode-input-foreground: #cccccc; - --vscode-inputOption-activeBorder: rgba(0, 122, 204, 0); - --vscode-inputOption-activeBackground: rgba(0, 127, 212, 0.4); - --vscode-inputOption-activeForeground: #ffffff; - --vscode-input-placeholderForeground: #a6a6a6; - --vscode-inputValidation-infoBackground: #063b49; - --vscode-inputValidation-infoBorder: #007acc; - --vscode-inputValidation-warningBackground: #352a05; - --vscode-inputValidation-warningBorder: #b89500; - --vscode-inputValidation-errorBackground: #5a1d1d; - --vscode-inputValidation-errorBorder: #be1100; - --vscode-dropdown-background: #3c3c3c; - --vscode-dropdown-foreground: #f0f0f0; - --vscode-dropdown-border: #3c3c3c; - --vscode-checkbox-background: #3c3c3c; - --vscode-checkbox-foreground: #f0f0f0; - --vscode-checkbox-border: #3c3c3c; - --vscode-button-foreground: #ffffff; - --vscode-button-background: #0e639c; - --vscode-button-hoverBackground: #1177bb; - --vscode-button-secondaryForeground: #ffffff; - --vscode-button-secondaryBackground: #3a3d41; - --vscode-button-secondaryHoverBackground: #45494e; - --vscode-badge-background: #4d4d4d; - --vscode-badge-foreground: #ffffff; - --vscode-scrollbar-shadow: #000000; - --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); - --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); - --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); - --vscode-progressBar-background: #0e70c0; - --vscode-editorError-foreground: #f48771; - --vscode-editorWarning-foreground: #cca700; - --vscode-editorInfo-foreground: #75beff; - --vscode-editorHint-foreground: rgba(238, 238, 238, 0.7); - --vscode-sash-hoverBorder: #007fd4; - --vscode-editor-background: #1e1e1e; - --vscode-editor-foreground: #d4d4d4; - --vscode-editorWidget-background: #252526; - --vscode-editorWidget-foreground: #cccccc; - --vscode-editorWidget-border: #454545; - --vscode-quickInput-background: #252526; - --vscode-quickInput-foreground: #cccccc; - --vscode-quickInputTitle-background: rgba(255, 255, 255, 0.1); - --vscode-pickerGroup-foreground: #3794ff; - --vscode-pickerGroup-border: #3f3f46; - --vscode-editor-selectionBackground: #264f78; - --vscode-editor-inactiveSelectionBackground: #3a3d41; - --vscode-editor-selectionHighlightBackground: rgba(173, 214, 255, 0.15); - --vscode-editor-findMatchBackground: #515c6a; - --vscode-editor-findMatchHighlightBackground: rgba(234, 92, 0, 0.33); - --vscode-editor-findRangeHighlightBackground: rgba(58, 61, 65, 0.4); - --vscode-searchEditor-findMatchBackground: rgba(234, 92, 0, 0.22); - --vscode-editor-hoverHighlightBackground: rgba(38, 79, 120, 0.25); - --vscode-editorHoverWidget-background: #252526; - --vscode-editorHoverWidget-foreground: #cccccc; - --vscode-editorHoverWidget-border: #454545; - --vscode-editorHoverWidget-statusBarBackground: #2c2c2d; - --vscode-editorLink-activeForeground: #4e94ce; - --vscode-editorInlineHint-foreground: #252526; - --vscode-editorInlineHint-background: #cccccc; - --vscode-editorLightBulb-foreground: #ffcc00; - --vscode-editorLightBulbAutoFix-foreground: #75beff; - --vscode-diffEditor-insertedTextBackground: rgba(155, 185, 85, 0.2); - --vscode-diffEditor-removedTextBackground: rgba(255, 0, 0, 0.2); - --vscode-diffEditor-diagonalFill: rgba(204, 204, 204, 0.2); - --vscode-list-focusOutline: #007fd4; - --vscode-list-activeSelectionBackground: #094771; - --vscode-list-activeSelectionForeground: #ffffff; - --vscode-list-inactiveSelectionBackground: #37373d; - --vscode-list-hoverBackground: #2a2d2e; - --vscode-list-dropBackground: #383b3d; - --vscode-list-highlightForeground: #0097fb; - --vscode-list-invalidItemForeground: #b89500; - --vscode-list-errorForeground: #f88070; - --vscode-list-warningForeground: #cca700; - --vscode-listFilterWidget-background: #653723; - --vscode-listFilterWidget-outline: rgba(0, 0, 0, 0); - --vscode-listFilterWidget-noMatchesOutline: #be1100; - --vscode-list-filterMatchBackground: rgba(234, 92, 0, 0.33); - --vscode-tree-indentGuidesStroke: #585858; - --vscode-tree-tableColumnsBorder: rgba(204, 204, 204, 0.13); - --vscode-list-deemphasizedForeground: #8c8c8c; - --vscode-quickInputList-focusBackground: #062f4a; - --vscode-menu-foreground: #cccccc; - --vscode-menu-background: #252526; - --vscode-menu-selectionForeground: #ffffff; - --vscode-menu-selectionBackground: #094771; - --vscode-menu-separatorBackground: #bbbbbb; - --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); - --vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31); - --vscode-editor-snippetTabstopHighlightBackground: rgba(124, 124, 124, 0.3); - --vscode-editor-snippetFinalTabstopHighlightBorder: #525252; - --vscode-breadcrumb-foreground: rgba(204, 204, 204, 0.8); - --vscode-breadcrumb-background: #1e1e1e; - --vscode-breadcrumb-focusForeground: #e0e0e0; - --vscode-breadcrumb-activeSelectionForeground: #e0e0e0; - --vscode-breadcrumbPicker-background: #252526; - --vscode-merge-currentHeaderBackground: rgba(64, 200, 174, 0.5); - --vscode-merge-currentContentBackground: rgba(64, 200, 174, 0.2); - --vscode-merge-incomingHeaderBackground: rgba(64, 166, 255, 0.5); - --vscode-merge-incomingContentBackground: rgba(64, 166, 255, 0.2); - --vscode-merge-commonHeaderBackground: rgba(96, 96, 96, 0.4); - --vscode-merge-commonContentBackground: rgba(96, 96, 96, 0.16); - --vscode-editorOverviewRuler-currentContentForeground: rgba(64, 200, 174, 0.5); - --vscode-editorOverviewRuler-incomingContentForeground: rgba(64, 166, 255, 0.5); - --vscode-editorOverviewRuler-commonContentForeground: rgba(96, 96, 96, 0.4); - --vscode-editorOverviewRuler-findMatchForeground: rgba(209, 134, 22, 0.49); - --vscode-editorOverviewRuler-selectionHighlightForeground: rgba(160, 160, 160, 0.8); - --vscode-minimap-findMatchHighlight: #d18616; - --vscode-minimap-selectionHighlight: #264f78; - --vscode-minimap-errorHighlight: rgba(255, 18, 18, 0.7); - --vscode-minimap-warningHighlight: #cca700; - --vscode-minimapSlider-background: rgba(121, 121, 121, 0.2); - --vscode-minimapSlider-hoverBackground: rgba(100, 100, 100, 0.35); - --vscode-minimapSlider-activeBackground: rgba(191, 191, 191, 0.2); - --vscode-problemsErrorIcon-foreground: #f48771; - --vscode-problemsWarningIcon-foreground: #cca700; - --vscode-problemsInfoIcon-foreground: #75beff; - --vscode-charts-foreground: #cccccc; - --vscode-charts-lines: rgba(204, 204, 204, 0.5); - --vscode-charts-red: #f48771; - --vscode-charts-blue: #75beff; - --vscode-charts-yellow: #cca700; - --vscode-charts-orange: #d18616; - --vscode-charts-green: #89d185; - --vscode-charts-purple: #b180d7; - --vscode-editor-lineHighlightBorder: #282828; - --vscode-editor-rangeHighlightBackground: rgba(255, 255, 255, 0.04); - --vscode-editor-symbolHighlightBackground: rgba(234, 92, 0, 0.33); - --vscode-editorCursor-foreground: #aeafad; - --vscode-editorWhitespace-foreground: rgba(227, 228, 226, 0.16); - --vscode-editorIndentGuide-background: #404040; - --vscode-editorIndentGuide-activeBackground: #707070; - --vscode-editorLineNumber-foreground: #858585; - --vscode-editorActiveLineNumber-foreground: #c6c6c6; - --vscode-editorLineNumber-activeForeground: #c6c6c6; - --vscode-editorRuler-foreground: #5a5a5a; - --vscode-editorCodeLens-foreground: #999999; - --vscode-editorBracketMatch-background: rgba(0, 100, 0, 0.1); - --vscode-editorBracketMatch-border: #888888; - --vscode-editorOverviewRuler-border: rgba(127, 127, 127, 0.3); - --vscode-editorGutter-background: #1e1e1e; - --vscode-editorUnnecessaryCode-opacity: rgba(0, 0, 0, 0.67); - --vscode-editorOverviewRuler-rangeHighlightForeground: rgba(0, 122, 204, 0.6); - --vscode-editorOverviewRuler-errorForeground: rgba(255, 18, 18, 0.7); - --vscode-editorOverviewRuler-warningForeground: #cca700; - --vscode-editorOverviewRuler-infoForeground: #75beff; - --vscode-symbolIcon-arrayForeground: #cccccc; - --vscode-symbolIcon-booleanForeground: #cccccc; - --vscode-symbolIcon-classForeground: #ee9d28; - --vscode-symbolIcon-colorForeground: #cccccc; - --vscode-symbolIcon-constantForeground: #cccccc; - --vscode-symbolIcon-constructorForeground: #b180d7; - --vscode-symbolIcon-enumeratorForeground: #ee9d28; - --vscode-symbolIcon-enumeratorMemberForeground: #75beff; - --vscode-symbolIcon-eventForeground: #ee9d28; - --vscode-symbolIcon-fieldForeground: #75beff; - --vscode-symbolIcon-fileForeground: #cccccc; - --vscode-symbolIcon-folderForeground: #cccccc; - --vscode-symbolIcon-functionForeground: #b180d7; - --vscode-symbolIcon-interfaceForeground: #75beff; - --vscode-symbolIcon-keyForeground: #cccccc; - --vscode-symbolIcon-keywordForeground: #cccccc; - --vscode-symbolIcon-methodForeground: #b180d7; - --vscode-symbolIcon-moduleForeground: #cccccc; - --vscode-symbolIcon-namespaceForeground: #cccccc; - --vscode-symbolIcon-nullForeground: #cccccc; - --vscode-symbolIcon-numberForeground: #cccccc; - --vscode-symbolIcon-objectForeground: #cccccc; - --vscode-symbolIcon-operatorForeground: #cccccc; - --vscode-symbolIcon-packageForeground: #cccccc; - --vscode-symbolIcon-propertyForeground: #cccccc; - --vscode-symbolIcon-referenceForeground: #cccccc; - --vscode-symbolIcon-snippetForeground: #cccccc; - --vscode-symbolIcon-stringForeground: #cccccc; - --vscode-symbolIcon-structForeground: #cccccc; - --vscode-symbolIcon-textForeground: #cccccc; - --vscode-symbolIcon-typeParameterForeground: #cccccc; - --vscode-symbolIcon-unitForeground: #cccccc; - --vscode-symbolIcon-variableForeground: #75beff; - --vscode-editorOverviewRuler-bracketMatchForeground: #a0a0a0; - --vscode-editor-linkedEditingBackground: rgba(255, 0, 0, 0.3); - --vscode-editor-wordHighlightBackground: rgba(87, 87, 87, 0.72); - --vscode-editor-wordHighlightStrongBackground: rgba(0, 73, 114, 0.72); - --vscode-editorOverviewRuler-wordHighlightForeground: rgba(160, 160, 160, 0.8); - --vscode-editorOverviewRuler-wordHighlightStrongForeground: rgba(192, 160, 192, 0.8); - --vscode-editor-foldBackground: rgba(38, 79, 120, 0.3); - --vscode-editorGutter-foldingControlForeground: #c5c5c5; - --vscode-peekViewTitle-background: #1e1e1e; - --vscode-peekViewTitleLabel-foreground: #ffffff; - --vscode-peekViewTitleDescription-foreground: rgba(204, 204, 204, 0.7); - --vscode-peekView-border: #007acc; - --vscode-peekViewResult-background: #252526; - --vscode-peekViewResult-lineForeground: #bbbbbb; - --vscode-peekViewResult-fileForeground: #ffffff; - --vscode-peekViewResult-selectionBackground: rgba(51, 153, 255, 0.2); - --vscode-peekViewResult-selectionForeground: #ffffff; - --vscode-peekViewEditor-background: #001f33; - --vscode-peekViewEditorGutter-background: #001f33; - --vscode-peekViewResult-matchHighlightBackground: rgba(234, 92, 0, 0.3); - --vscode-peekViewEditor-matchHighlightBackground: rgba(255, 143, 0, 0.6); - --vscode-editorMarkerNavigationError-background: #f48771; - --vscode-editorMarkerNavigationWarning-background: #cca700; - --vscode-editorMarkerNavigationInfo-background: #75beff; - --vscode-editorMarkerNavigation-background: #2d2d30; - --vscode-editorSuggestWidget-background: #252526; - --vscode-editorSuggestWidget-border: #454545; - --vscode-editorSuggestWidget-foreground: #d4d4d4; - --vscode-editorSuggestWidget-selectedBackground: #062f4a; - --vscode-editorSuggestWidget-highlightForeground: #0097fb; - --vscode-tab-activeBackground: #1e1e1e; - --vscode-tab-unfocusedActiveBackground: #1e1e1e; - --vscode-tab-inactiveBackground: #2d2d2d; - --vscode-tab-unfocusedInactiveBackground: #2d2d2d; - --vscode-tab-activeForeground: #ffffff; - --vscode-tab-inactiveForeground: rgba(255, 255, 255, 0.5); - --vscode-tab-unfocusedActiveForeground: rgba(255, 255, 255, 0.5); - --vscode-tab-unfocusedInactiveForeground: rgba(255, 255, 255, 0.25); - --vscode-tab-border: #252526; - --vscode-tab-lastPinnedBorder: rgba(204, 204, 204, 0.2); - --vscode-tab-activeModifiedBorder: #3399cc; - --vscode-tab-inactiveModifiedBorder: rgba(51, 153, 204, 0.5); - --vscode-tab-unfocusedActiveModifiedBorder: rgba(51, 153, 204, 0.5); - --vscode-tab-unfocusedInactiveModifiedBorder: rgba(51, 153, 204, 0.25); - --vscode-editorPane-background: #1e1e1e; - --vscode-editorGroupHeader-tabsBackground: #252526; - --vscode-editorGroupHeader-noTabsBackground: #1e1e1e; - --vscode-editorGroup-border: #444444; - --vscode-editorGroup-dropBackground: rgba(83, 89, 93, 0.5); - --vscode-imagePreview-border: rgba(128, 128, 128, 0.35); - --vscode-panel-background: #1e1e1e; - --vscode-panel-border: rgba(128, 128, 128, 0.35); - --vscode-panelTitle-activeForeground: #e7e7e7; - --vscode-panelTitle-inactiveForeground: rgba(231, 231, 231, 0.6); - --vscode-panelTitle-activeBorder: #e7e7e7; - --vscode-panel-dropBorder: #e7e7e7; - --vscode-panelSection-dropBackground: rgba(83, 89, 93, 0.5); - --vscode-panelSectionHeader-background: rgba(128, 128, 128, 0.2); - --vscode-panelSection-border: rgba(128, 128, 128, 0.35); - --vscode-statusBar-foreground: #ffffff; - --vscode-statusBar-noFolderForeground: #ffffff; - --vscode-statusBar-background: #007acc; - --vscode-statusBar-noFolderBackground: #68217a; - --vscode-statusBarItem-activeBackground: rgba(255, 255, 255, 0.18); - --vscode-statusBarItem-hoverBackground: rgba(255, 255, 255, 0.12); - --vscode-statusBarItem-prominentForeground: #ffffff; - --vscode-statusBarItem-prominentBackground: rgba(0, 0, 0, 0.5); - --vscode-statusBarItem-prominentHoverBackground: rgba(0, 0, 0, 0.3); - --vscode-statusBarItem-errorBackground: #c72e0f; - --vscode-statusBarItem-errorForeground: #ffffff; - --vscode-activityBar-background: #333333; - --vscode-activityBar-foreground: #ffffff; - --vscode-activityBar-inactiveForeground: rgba(255, 255, 255, 0.4); - --vscode-activityBar-activeBorder: #ffffff; - --vscode-activityBar-dropBorder: #ffffff; - --vscode-activityBarBadge-background: #007acc; - --vscode-activityBarBadge-foreground: #ffffff; - --vscode-statusBarItem-remoteBackground: #16825d; - --vscode-statusBarItem-remoteForeground: #ffffff; - --vscode-extensionBadge-remoteBackground: #007acc; - --vscode-extensionBadge-remoteForeground: #ffffff; - --vscode-sideBar-background: #252526; - --vscode-sideBarTitle-foreground: #bbbbbb; - --vscode-sideBar-dropBackground: rgba(83, 89, 93, 0.5); - --vscode-sideBarSectionHeader-background: rgba(0, 0, 0, 0); - --vscode-sideBarSectionHeader-border: rgba(204, 204, 204, 0.2); - --vscode-titleBar-activeForeground: #cccccc; - --vscode-titleBar-inactiveForeground: rgba(204, 204, 204, 0.6); - --vscode-titleBar-activeBackground: #3c3c3c; - --vscode-titleBar-inactiveBackground: rgba(60, 60, 60, 0.6); - --vscode-menubar-selectionForeground: #cccccc; - --vscode-menubar-selectionBackground: rgba(255, 255, 255, 0.1); - --vscode-notifications-foreground: #cccccc; - --vscode-notifications-background: #252526; - --vscode-notificationLink-foreground: #3794ff; - --vscode-notificationCenterHeader-background: #303031; - --vscode-notifications-border: #303031; - --vscode-notificationsErrorIcon-foreground: #f48771; - --vscode-notificationsWarningIcon-foreground: #cca700; - --vscode-notificationsInfoIcon-foreground: #75beff; - --vscode-editorGutter-commentRangeForeground: #c5c5c5; - --vscode-debugToolBar-background: #333333; - --vscode-debugIcon-startForeground: #89d185; - --vscode-settings-headerForeground: #e7e7e7; - --vscode-settings-modifiedItemIndicator: #0c7d9d; - --vscode-settings-dropdownBackground: #3c3c3c; - --vscode-settings-dropdownForeground: #f0f0f0; - --vscode-settings-dropdownBorder: #3c3c3c; - --vscode-settings-dropdownListBorder: #454545; - --vscode-settings-checkboxBackground: #3c3c3c; - --vscode-settings-checkboxForeground: #f0f0f0; - --vscode-settings-checkboxBorder: #3c3c3c; - --vscode-settings-textInputBackground: #3c3c3c; - --vscode-settings-textInputForeground: #cccccc; - --vscode-settings-numberInputBackground: #3c3c3c; - --vscode-settings-numberInputForeground: #cccccc; - --vscode-settings-focusedRowBackground: rgba(128, 128, 128, 0.14); - --vscode-notebook-rowHoverBackground: rgba(128, 128, 128, 0.07); - --vscode-notebook-focusedRowBorder: rgba(255, 255, 255, 0.12); - --vscode-terminal-foreground: #cccccc; - --vscode-terminal-selectionBackground: rgba(255, 255, 255, 0.25); - --vscode-terminal-border: rgba(128, 128, 128, 0.35); - --vscode-testing-iconFailed: #f14c4c; - --vscode-testing-iconErrored: #f14c4c; - --vscode-testing-iconPassed: #73c991; - --vscode-testing-runAction: #73c991; - --vscode-testing-iconQueued: #cca700; - --vscode-testing-iconUnset: #848484; - --vscode-testing-iconSkipped: #848484; - --vscode-testing-peekBorder: #f48771; - --vscode-testing-message\.error\.decorationForeground: #f48771; - --vscode-testing-message\.error\.lineBackground: rgba(255, 0, 0, 0.2); - --vscode-testing-message\.warning\.decorationForeground: #cca700; - --vscode-testing-message\.warning\.lineBackground: rgba(255, 208, 0, 0.2); - --vscode-testing-message\.info\.decorationForeground: #75beff; - --vscode-testing-message\.info\.lineBackground: rgba(0, 127, 255, 0.2); - --vscode-testing-message\.hint\.decorationForeground: rgba(238, 238, 238, 0.7); - --vscode-welcomePage-tileBackground: #252526; - --vscode-welcomePage-tileHoverBackground: #2c2c2d; - --vscode-welcomePage-tileShadow\.: rgba(0, 0, 0, 0.36); - --vscode-welcomePage-progress\.background: #3c3c3c; - --vscode-welcomePage-progress\.foreground: #3794ff; - --vscode-workspaceTrust-trustedForegound: #89d185; - --vscode-workspaceTrust-untrustedForeground: #f48771; - --vscode-workspaceTrust-tileBackground: #252526; - --vscode-statusBar-debuggingBackground: #cc6633; - --vscode-statusBar-debuggingForeground: #ffffff; - --vscode-debugExceptionWidget-border: #a31515; - --vscode-debugExceptionWidget-background: #420b0d; - --vscode-editorGutter-modifiedBackground: #0c7d9d; - --vscode-editorGutter-addedBackground: #587c0c; - --vscode-editorGutter-deletedBackground: #94151b; - --vscode-minimapGutter-modifiedBackground: #0c7d9d; - --vscode-minimapGutter-addedBackground: #587c0c; - --vscode-minimapGutter-deletedBackground: #94151b; - --vscode-editorOverviewRuler-modifiedForeground: rgba(12, 125, 157, 0.6); - --vscode-editorOverviewRuler-addedForeground: rgba(88, 124, 12, 0.6); - --vscode-editorOverviewRuler-deletedForeground: rgba(148, 21, 27, 0.6); - --vscode-notebook-cellBorderColor: #37373d; - --vscode-notebook-focusedEditorBorder: #007fd4; - --vscode-notebookStatusSuccessIcon-foreground: #89d185; - --vscode-notebookStatusErrorIcon-foreground: #f48771; - --vscode-notebookStatusRunningIcon-foreground: #cccccc; - --vscode-notebook-outputContainerBackgroundColor: #37373d; - --vscode-notebook-cellToolbarSeparator: rgba(128, 128, 128, 0.35); - --vscode-notebook-selectedCellBackground: #37373d; - --vscode-notebook-selectedCellBorder: #37373d; - --vscode-notebook-focusedCellBorder: #007fd4; - --vscode-notebook-inactiveFocusedCellBorder: #37373d; - --vscode-notebook-cellStatusBarItemHoverBackground: rgba(255, 255, 255, 0.15); - --vscode-notebook-cellInsertionIndicator: #007fd4; - --vscode-notebookScrollbarSlider-background: rgba(121, 121, 121, 0.4); - --vscode-notebookScrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); - --vscode-notebookScrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); - --vscode-notebook-symbolHighlightBackground: rgba(255, 255, 255, 0.04); - --vscode-editor-stackFrameHighlightBackground: rgba(255, 255, 0, 0.2); - --vscode-editor-focusedStackFrameHighlightBackground: rgba(122, 189, 122, 0.3); - --vscode-debugIcon-breakpointForeground: #e51400; - --vscode-debugIcon-breakpointDisabledForeground: #848484; - --vscode-debugIcon-breakpointUnverifiedForeground: #848484; - --vscode-debugIcon-breakpointCurrentStackframeForeground: #ffcc00; - --vscode-debugIcon-breakpointStackframeForeground: #89d185; - --vscode-scm-providerBorder: #454545; - --vscode-extensionButton-prominentBackground: #0e639c; - --vscode-extensionButton-prominentForeground: #ffffff; - --vscode-extensionButton-prominentHoverBackground: #1177bb; - --vscode-extensionIcon-starForeground: #ff8e00; - --vscode-terminal-ansiBlack: #000000; - --vscode-terminal-ansiRed: #cd3131; - --vscode-terminal-ansiGreen: #0dbc79; - --vscode-terminal-ansiYellow: #e5e510; - --vscode-terminal-ansiBlue: #2472c8; - --vscode-terminal-ansiMagenta: #bc3fbc; - --vscode-terminal-ansiCyan: #11a8cd; - --vscode-terminal-ansiWhite: #e5e5e5; - --vscode-terminal-ansiBrightBlack: #666666; - --vscode-terminal-ansiBrightRed: #f14c4c; - --vscode-terminal-ansiBrightGreen: #23d18b; - --vscode-terminal-ansiBrightYellow: #f5f543; - --vscode-terminal-ansiBrightBlue: #3b8eea; - --vscode-terminal-ansiBrightMagenta: #d670d6; - --vscode-terminal-ansiBrightCyan: #29b8db; - --vscode-terminal-ansiBrightWhite: #e5e5e5; - --vscode-debugTokenExpression-name: #c586c0; - --vscode-debugTokenExpression-value: rgba(204, 204, 204, 0.6); - --vscode-debugTokenExpression-string: #ce9178; - --vscode-debugTokenExpression-boolean: #4e94ce; - --vscode-debugTokenExpression-number: #b5cea8; - --vscode-debugTokenExpression-error: #f48771; - --vscode-debugView-exceptionLabelForeground: #cccccc; - --vscode-debugView-exceptionLabelBackground: #6c2022; - --vscode-debugView-stateLabelForeground: #cccccc; - --vscode-debugView-stateLabelBackground: rgba(136, 136, 136, 0.27); - --vscode-debugView-valueChangedHighlight: #569cd6; - --vscode-debugConsole-infoForeground: #75beff; - --vscode-debugConsole-warningForeground: #cca700; - --vscode-debugConsole-errorForeground: #f48771; - --vscode-debugConsole-sourceForeground: #cccccc; - --vscode-debugConsoleInputIcon-foreground: #cccccc; - --vscode-debugIcon-pauseForeground: #75beff; - --vscode-debugIcon-stopForeground: #f48771; - --vscode-debugIcon-disconnectForeground: #f48771; - --vscode-debugIcon-restartForeground: #89d185; - --vscode-debugIcon-stepOverForeground: #75beff; - --vscode-debugIcon-stepIntoForeground: #75beff; - --vscode-debugIcon-stepOutForeground: #75beff; - --vscode-debugIcon-continueForeground: #75beff; - --vscode-debugIcon-stepBackForeground: #75beff; - --vscode-gitDecoration-addedResourceForeground: #81b88b; - --vscode-gitDecoration-modifiedResourceForeground: #e2c08d; - --vscode-gitDecoration-deletedResourceForeground: #c74e39; - --vscode-gitDecoration-renamedResourceForeground: #73c991; - --vscode-gitDecoration-untrackedResourceForeground: #73c991; - --vscode-gitDecoration-ignoredResourceForeground: #8c8c8c; - --vscode-gitDecoration-stageModifiedResourceForeground: #e2c08d; - --vscode-gitDecoration-stageDeletedResourceForeground: #c74e39; - --vscode-gitDecoration-conflictingResourceForeground: #e4676b; - --vscode-gitDecoration-submoduleResourceForeground: #8db9e2; - --vscode-bookmarks-lineBackground: rgba(0, 0, 0, 0); - --vscode-bookmarks-lineBorder: rgba(0, 0, 0, 0); - --vscode-bookmarks-overviewRuler: rgba(21, 126, 251, 0.53); - --vscode-gitlens-gutterBackgroundColor: rgba(255, 255, 255, 0.07); - --vscode-gitlens-gutterForegroundColor: #bebebe; - --vscode-gitlens-gutterUncommittedForegroundColor: rgba(0, 188, 242, 0.6); - --vscode-gitlens-trailingLineBackgroundColor: rgba(0, 0, 0, 0); - --vscode-gitlens-trailingLineForegroundColor: rgba(153, 153, 153, 0.35); - --vscode-gitlens-lineHighlightBackgroundColor: rgba(0, 188, 242, 0.2); - --vscode-gitlens-lineHighlightOverviewRulerColor: rgba(0, 188, 242, 0.6); - --vscode-gitlens-closedPullRequestIconColor: #f85149; - --vscode-gitlens-openPullRequestIconColor: #56d364; - --vscode-gitlens-mergedPullRequestIconColor: #995dff; - --vscode-gitlens-unpushlishedChangesIconColor: #35b15e; - --vscode-gitlens-unpublishedCommitIconColor: #35b15e; - --vscode-gitlens-unpulledChangesIconColor: #b15e35; - --vscode-gitlens-decorations\.addedForegroundColor: #81b88b; - --vscode-gitlens-decorations\.copiedForegroundColor: #73c991; - --vscode-gitlens-decorations\.deletedForegroundColor: #c74e39; - --vscode-gitlens-decorations\.ignoredForegroundColor: #8c8c8c; - --vscode-gitlens-decorations\.modifiedForegroundColor: #e2c08d; - --vscode-gitlens-decorations\.untrackedForegroundColor: #73c991; - --vscode-gitlens-decorations\.renamedForegroundColor: #73c991; - --vscode-gitlens-decorations\.branchAheadForegroundColor: #35b15e; - --vscode-gitlens-decorations\.branchBehindForegroundColor: #b15e35; - --vscode-gitlens-decorations\.branchDivergedForegroundColor: #d8af1b; - --vscode-gitlens-decorations\.branchUnpublishedForegroundColor: #35b15e; - --vscode-gitlens-decorations\.branchMissingUpstreamForegroundColor: #c74e39; -} diff --git a/html/httpgd/webviewMessages.d.ts b/html/httpgd/webviewMessages.d.ts deleted file mode 100644 index 9f383df7a..000000000 --- a/html/httpgd/webviewMessages.d.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -interface VsCode { - postMessage: (msg: OutMessage) => void; - setState: (state: string) => void; -} - -interface IMessage { - message: string; -} -interface ResizeMessage extends IMessage { - message: 'resize'; - height: number; - width: number; - userTriggered: boolean; -} -interface LogMessage extends IMessage { - message: 'log'; - body: any; -} - -type OutMessage = ResizeMessage | LogMessage; - -interface UpdatePlotMessage extends IMessage { - message: 'updatePlot'; - svg: string; - plotId: string; -} - -interface FocusPlotMessage extends IMessage { - message: 'focusPlot'; - plotId: string; -} - -interface ToggleStyleMessage extends IMessage { - message: 'toggleStyle'; - useOverwrites: boolean; -} - -interface ToggleFullWindowMessage extends IMessage { - message: 'toggleFullWindow'; - useFullWindow: boolean; -} - -type PreviewPlotLayout = 'multirow' | 'scroll' | 'hidden'; -interface PreviewPlotLayoutMessage extends IMessage { - message: 'togglePreviewPlotLayout'; - style: PreviewPlotLayout; -} - -interface HidePlotMessage extends IMessage { - message: 'hidePlot'; - plotId: string; -} - -interface AddPlotMessage extends IMessage { - message: 'addPlot'; - html: string; -} - -type InMessage = - | UpdatePlotMessage - | FocusPlotMessage - | ToggleStyleMessage - | HidePlotMessage - | AddPlotMessage - | PreviewPlotLayoutMessage - | ToggleFullWindowMessage; diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 5e15d3ab9..83c65a6bb 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -10,6 +10,27 @@ "standard" } +.install_httpgd_device_hook <- function() { + open_device <- httpgd::hgd + open <- function(owned, ...) { + result <- open_device(...) + if (owned) { + .runtime_track_device() + } else { + # Explicit devices belong to the caller. Retire any factory registration + # whose device number was reused, without taking ownership of the new one. + id <- unname(grDevices::dev.cur()) + state <- .runtime_state() + state$devices <- Filter(function(entry) entry$id != id, state$devices) + } + # Register before notifying: a failed write may stop the runtime immediately. + notify_client("httpgd", list(url = httpgd::hgd_url())) + invisible(result) + } + .runtime_rebind("hgd", function(...) open(FALSE, ...), ns = "httpgd") + function(...) open(TRUE, silent = TRUE) +} + # Send runtime notifications after task callbacks return, so transport failure # cannot remove the currently executing task callback during runtime cleanup. .defer_runtime_notification <- function(method, schedule = later::later) { @@ -315,17 +336,14 @@ runtime_start <- function(use_rstudioapi = TRUE, } if (previous %in% grDevices::dev.list()) grDevices::dev.set(previous) } else if (selected_backend == "httpgd") { - .runtime_set_option("device", function(...) { - httpgd::hgd(silent = TRUE) - .runtime_track_device() - notify_client("httpgd", list(url = httpgd::hgd_url())) - }) - for (entry in .runtime_state()$devices) { - devs <- grDevices::dev.list() - index <- match(entry$id, devs) - if (!is.na(index) && identical(names(devs)[[index]], entry$name) && entry$name %in% c("httpgd", "unigd")) { - notify_client("httpgd", list(url = httpgd::hgd_url(which = entry$id), restoring = TRUE)) - } + # Notify for both explicit and default devices; own only factory devices. + .runtime_set_option("device", .install_httpgd_device_hook()) + # Discover live servers on reconnect without adopting their devices. A plain + # unigd device has no HTTP server and must not prevent the others restoring. + devs <- grDevices::dev.list() + for (id in devs[names(devs) %in% c("httpgd", "unigd")]) { + url <- tryCatch(httpgd::hgd_url(which = id), error = function(e) NULL) + if (!is.null(url)) notify_client("httpgd", list(url = url, restoring = TRUE)) } } else if (selected_backend == "standard") { # If a specific interactive backend was explicitly requested but is diff --git a/sess/inst/tinytest/test-httpgd-devices.R b/sess/inst/tinytest/test-httpgd-devices.R new file mode 100644 index 000000000..210a9e9e4 --- /dev/null +++ b/sess/inst/tinytest/test-httpgd-devices.R @@ -0,0 +1,63 @@ +if (requireNamespace("httpgd", quietly = TRUE) && + packageVersion("httpgd") >= "2.0.1") local({ + original_device <- grDevices::dev.cur() + original_devices <- grDevices::dev.list() + original_hgd <- httpgd::hgd + sess_env <- sess:::.sess_env + cons <- processx::conn_create_pipepair() + previous_connection <- sess_env$con + sess_env$con <- cons[[2L]] + notifications <- function() { + lines <- strsplit(processx::conn_read_chars(cons[[1L]]), "\n", fixed = TRUE)[[1L]] + lapply(lines, jsonlite::fromJSON) + } + on.exit({ + sess:::runtime_stop() + sess_env$con <- previous_connection + close(cons[[1L]]) + close(cons[[2L]]) + for (id in rev(setdiff(grDevices::dev.list(), original_devices))) { + try(grDevices::dev.off(id), silent = TRUE) + } + if (original_device %in% grDevices::dev.list()) grDevices::dev.set(original_device) + }, add = TRUE) + sess:::runtime_start(use_rstudioapi = FALSE, plot_backend = "httpgd") + # Direct calls and dev.new() both register through the same integration. + httpgd::hgd(silent = TRUE) + graphics::plot(1:10, main = "FIRST DEVICE") + first <- grDevices::dev.cur() + grDevices::dev.new() + graphics::plot(1:100, main = "SECOND DEVICE") + second <- grDevices::dev.cur() + registered <- notifications() + expect_equal(vapply(registered, `[[`, character(1), "method"), c("httpgd", "httpgd")) + expect_equal(vapply(registered, function(item) item$params$url, character(1)), + c(httpgd::hgd_url(which = first), httpgd::hgd_url(which = second))) + expect_length(sess:::.runtime_state()$devices, 1L) + expect_identical(sess:::.runtime_state()$devices[[1L]]$id, unname(second)) + sess:::runtime_stop(preserve_plots = TRUE) + expect_identical(httpgd::hgd, original_hgd) + sess:::runtime_start(use_rstudioapi = FALSE, plot_backend = "httpgd") + restored <- notifications() + expect_length(restored, 2L) + expect_true(all(vapply(restored, function(item) isTRUE(item$params$restoring), logical(1)))) + expect_length(sess:::.runtime_state()$devices, 1L) + sess:::runtime_stop() + expect_true(first %in% grDevices::dev.list()) + expect_false(second %in% grDevices::dev.list()) + expect_identical(httpgd::hgd, original_hgd) + + # Replacing a closed factory device with an explicit hgd() must not inherit + # the old registration's cleanup ownership merely by reusing its number. + sess:::runtime_start(use_rstudioapi = FALSE, plot_backend = "httpgd") + grDevices::dev.new() + factory <- grDevices::dev.cur() + grDevices::dev.off(factory) + httpgd::hgd(silent = TRUE) + replacement <- grDevices::dev.cur() + expect_identical(replacement, factory) + expect_length(sess:::.runtime_state()$devices, 0L) + sess:::runtime_stop() + expect_true(replacement %in% grDevices::dev.list()) + expect_true(first %in% grDevices::dev.list()) +}) diff --git a/sess/inst/tinytest/test-httpgd-version.R b/sess/inst/tinytest/test-httpgd-version.R index e551b2bba..e43e36c51 100644 --- a/sess/inst/tinytest/test-httpgd-version.R +++ b/sess/inst/tinytest/test-httpgd-version.R @@ -16,6 +16,7 @@ local({ identical(package, "httpgd") && !is.null(version) } env$getNamespaceVersion <- function(package) version + env$.install_httpgd_device_hook <- function() function(...) NULL versions <- list(NULL, "1.2.0", "2.0.0", "2.0.1", "2.1.4", "10.0.0") supported <- c(FALSE, FALSE, FALSE, TRUE, TRUE, TRUE) for (i in seq_along(versions)) { diff --git a/src/plotViewer/httpgdDevices.ts b/src/plotViewer/httpgdDevices.ts new file mode 100644 index 000000000..9cb73d28c --- /dev/null +++ b/src/plotViewer/httpgdDevices.ts @@ -0,0 +1,223 @@ +import { + HttpgdClient, + HttpgdIdResponse, + HttpgdPlotRequest, + HttpgdPlotsResponse, + HttpgdRendererResponse, +} from './httpgdClient'; + +interface Device { + origin: string; + token?: string; + client: HttpgdClient; + plots: Map; + restoring: boolean; + active: boolean; + upid?: number; +} + +interface PlotTarget { + device: Device; + id: string; +} + +/** One session timeline; HTTP requests and credentials remain device-specific. */ +export class HttpgdDevices { + private devices = new Map(); + // Map insertion order is plot arrival order. Only IDs are grouped, never SVGs. + private targets = new Map(); + private nextId = 0; + private usedIds = new Set(); + private connected = false; + private listeners = new Set< + (plots: HttpgdIdResponse[], restoring: boolean, changed: ReadonlySet) => void + >(); + + constructor(origin: string, token?: string, restoring = false) { + void this.addDevice(origin, token, restoring); + } + + get connections(): Array<{ origin: string; token?: string }> { + return [...this.devices.values()].map(({ origin, token }) => ({ origin, token })); + } + + connection(origin: string): { origin: string; token?: string } | undefined { + return this.devices.get(origin); + } + + async addDevice(origin: string, token?: string, restoring = false): Promise { + const previous = this.devices.get(origin); + if (previous && previous.token === token) { + return; + } + if (previous) { + this.removeDevice(origin); + } + const device: Device = { + origin, + token, + client: new HttpgdClient(origin, token), + plots: new Map(), + restoring, + active: false, + }; + this.devices.set(origin, device); + device.client.onPlotsChanged((snapshot) => this.update(device, snapshot)); + if (this.connected) { + await device.client.connect(); + } + } + + removeDevice(origin: string): void { + const device = this.devices.get(origin); + if (!device) { + return; + } + this.devices.delete(origin); + device.client.disconnect(); + for (const id of device.plots.values()) { + this.targets.delete(id); + } + this.notify(true); + } + + private update(device: Device, snapshot: HttpgdPlotsResponse): void { + if (!this.connected || this.devices.get(device.origin) !== device) { + return; + } + device.active = snapshot.state.active; + const contentChanged = device.upid !== snapshot.state.upid; + device.upid = snapshot.state.upid; + if (device.active) { + for (const other of this.devices.values()) { + if (other !== device) { + other.active = false; + } + } + } + const incoming = new Set(snapshot.plots.map((plot) => plot.id)); + for (const [rawId, id] of device.plots) { + if (!incoming.has(rawId)) { + device.plots.delete(rawId); + this.targets.delete(id); + } + } + for (const rawId of incoming) { + if (device.plots.has(rawId)) { + continue; + } + // Preserve ordinary single-device IDs, allocating an alias only for + // collisions. Routing never relies on parsing a server-supplied ID. + let id = rawId; + while (this.usedIds.has(id)) { + id = `httpgd-${++this.nextId}`; + } + this.usedIds.add(id); + device.plots.set(rawId, id); + this.targets.set(id, { device, id: rawId }); + } + const restoring = device.restoring; + device.restoring = false; + // Drawing updates the newest plot on this device. Historical plots are + // immutable unless explicitly rendered at a new size by the viewer. + const latest = snapshot.plots.at(-1)?.id; + this.notify( + restoring, + new Set(!contentChanged || latest === undefined ? [] : [device.plots.get(latest)!]), + ); + } + + private notify(restoring: boolean, changed: ReadonlySet = new Set()): void { + const plots = this.getPlots(); + for (const listener of this.listeners) { + listener(plots, restoring, changed); + } + } + + onPlotsChanged( + listener: ( + plots: HttpgdIdResponse[], + restoring: boolean, + changed: ReadonlySet, + ) => void, + ): void { + this.listeners.add(listener); + } + + async connect(): Promise { + this.connected = true; + // Start every connection even if one retained device has been closed in R. + await Promise.all([...this.devices.values()].map((device) => device.client.connect())); + } + + disconnect(): void { + this.connected = false; + for (const device of this.devices.values()) { + device.restoring = true; + device.client.disconnect(); + } + } + + getPlots(): HttpgdIdResponse[] { + return [...this.targets.keys()].map((id) => ({ id })); + } + + releasePlots(): void { + this.targets.clear(); + this.usedIds.clear(); + for (const device of this.devices.values()) { + device.plots.clear(); + } + } + + private target(id?: string): PlotTarget { + const target = id === undefined ? undefined : this.targets.get(id); + if (target) { + return target; + } + if (id !== undefined) { + throw new Error('Plot is no longer available'); + } + const device = this.devices.values().next().value; + if (!device) { + throw new Error('No httpgd device is available'); + } + return { device, id: '' }; + } + + plotConnection(id?: string): { origin: string; token?: string } { + return this.target(id).device; + } + + isActive(id: string): boolean { + return this.targets.get(id)?.device.active !== false; + } + + getRenderers(id?: string): HttpgdRendererResponse[] { + return this.target(id).device.client.getRenderers(); + } + + async getPlotText(request: HttpgdPlotRequest): Promise { + return (await this.getPlotBytes(request)).toString('utf8'); + } + + async getPlotBytes(request: HttpgdPlotRequest): Promise { + const target = this.target(request.id); + const routed = { ...request, id: request.id === undefined ? undefined : target.id }; + // unigd replays resize requests on R's current graphics device. For an + // inactive device, request its existing geometry and let SVG fit the + // panel; replaying here can blank this plot and draw into another device. + const dimensions = target.device.active + ? {} + : { width: undefined, height: undefined, zoom: undefined }; + return target.device.client.getPlotBytes({ + ...routed, + ...dimensions, + }); + } + + async removePlot(request: { id: string }): Promise { + const target = this.target(request.id); + await target.device.client.removePlot({ id: target.id }); + } +} diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts index 1670712e6..3a3835579 100644 --- a/src/plotViewer/httpgdTypes.d.ts +++ b/src/plotViewer/httpgdTypes.d.ts @@ -1,11 +1,10 @@ -import { HttpgdClient, HttpgdPlotId } from './httpgdClient'; +import { HttpgdPlotId } from './httpgdClient'; +import { HttpgdDevices } from './httpgdDevices'; import * as vscode from 'vscode'; import { HttpgdManager } from './httpgdViewer'; import type { ViewerSessionSource } from '../viewerSession'; import { PreviewPlotLayout } from './webviewMessages'; -export type MaybePromise = T | Promise; - export interface HttpgdPlot { // unique ID for this plot (w.r.t. this connection/device) id: HttpgdPlotId; @@ -42,7 +41,7 @@ export class IHttpgdViewer { webviewPanel?: vscode.WebviewPanel; // Api that provides plot contents etc. - api: HttpgdClient; + api: HttpgdDevices; // active plots plots: HttpgdPlot[]; diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 188306b79..99f7e13d8 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -1,5 +1,8 @@ import * as vscode from 'vscode'; -import { HttpgdClient, HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from './httpgdClient'; +import { HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from './httpgdClient'; +import { HttpgdDevices } from './httpgdDevices'; +import { PlotLifecycle } from './plotLifecycle'; +import { PlotViewerRegistry } from './plotViewerRegistry'; import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; import * as path from 'path'; import * as fs from 'fs'; @@ -56,25 +59,27 @@ function isHttpgdConnection(value: unknown): value is HttpgdConnection { } export class HttpgdManager { - viewers: HttpgdViewer[] = []; + private viewerStore: PlotViewerRegistry; viewerOptions: HttpgdViewerOptions; - recentlyActiveViewers: HttpgdViewer[] = []; + get viewers(): HttpgdViewer[] { + return this.viewerStore.values().reverse(); + } + + get recentlyActiveViewers(): readonly HttpgdViewer[] { + return this.viewerStore.recentlyActive; + } private savedConnections: Promise = Promise.resolve([]); private secretKey: string | undefined; private disposed = false; private connectionWrites: Promise = Promise.resolve(); - private exitedSessions = new Set(); - private savedSessionSubscriptions = new Map(); + + getViewers(): HttpgdViewer[] { + return this.viewers; + } dispose(): void { this.disposed = true; - for (const viewer of this.viewers) { - viewer.dispose(); - } - for (const subscription of this.savedSessionSubscriptions.values()) { - subscription.dispose(); - } - this.savedSessionSubscriptions.clear(); + this.viewerStore.dispose(); } initialize(): void { @@ -90,7 +95,7 @@ export class HttpgdManager { const saved = connections.filter(isHttpgdConnection); if (!this.disposed) { for (const connection of saved) { - this.observeSavedSession(connection.source); + this.viewerStore.lifecycle.observe(connection.source); } } return saved; @@ -108,55 +113,11 @@ export class HttpgdManager { } private hasExited(source?: ViewerSessionSource): boolean { - return Boolean( - source && - (source.processExited || this.exitedSessions.has(sessionProcessIdentity(source))), - ); + return this.viewerStore.lifecycle.hasExited(source); } - private observeSavedSession(source?: ViewerSessionSource): void { - if (!source) { - return; - } - const key = sessionProcessIdentity(source); - if (this.exitedSessions.has(key) || this.savedSessionSubscriptions.has(key)) { - return; - } - const context = getViewerSessionContext(source.sessionId, source); - if (context) { - this.savedSessionSubscriptions.set( - key, - context.observeExit(() => this.sessionExited(source)), - ); - } - if (source.processExited || context?.hasExited) { - this.sessionExited(source); - } - } - - public sessionExited(source: ViewerSessionSource): void { - const key = sessionProcessIdentity(source); - if (this.disposed || this.exitedSessions.has(key)) { - return; - } - // Remember confirmed exits so delayed restoration cannot resurrect them. - this.exitedSessions.add(key); - this.savedSessionSubscriptions.get(key)?.dispose(); - this.savedSessionSubscriptions.delete(key); - const belongsToSession = (viewer: HttpgdViewer) => { - const owner = viewer.connection.source; - return owner && sessionProcessIdentity(owner) === key; - }; - for (const viewer of this.viewers) { - if (belongsToSession(viewer)) { - viewer.dispose(); - } - } - this.viewers = this.viewers.filter((viewer) => !belongsToSession(viewer)); - this.recentlyActiveViewers = this.recentlyActiveViewers.filter( - (viewer) => !belongsToSession(viewer), - ); - void this.saveConnections(); + public registerViewer(viewer: HttpgdViewer): void { + this.viewerStore.register(viewer, viewer.connection.source); } async restoreConnection(source: ViewerSessionSource): Promise { @@ -170,13 +131,14 @@ export class HttpgdManager { [...this.recentlyActiveViewers, ...this.viewers].find( (viewer) => !viewer.isDisposed && matches(viewer.connection.source), ); - if (existing()) { - return existing(); - } - const connections = await this.savedConnections; - const connection = connections.filter((saved) => matches(saved.source)).at(-1); - if (connection) { - await this.showViewer(connection.url, source, true); + const connections = await this.savedConnections.catch((error: unknown) => { + if (existing()) { + return []; + } + throw error; + }); + for (const connection of connections.filter((saved) => matches(saved.source))) { + await this.showViewer(connection.url, source, true, true); } return existing(); } @@ -199,8 +161,10 @@ export class HttpgdManager { for (const viewer of this.viewers) { // Extension shutdown disposes clients before its queued save runs. // Only confirmed process exits invalidate restoration credentials. - if (!this.hasExited(viewer.connection.source)) { - connections.set(viewer.origin, viewer.connection); + for (const connection of viewer.connections) { + if (!this.hasExited(connection.source)) { + connections.set(new URL(connection.url).origin, connection); + } } } this.savedConnections = Promise.resolve([...connections.values()]); @@ -223,7 +187,10 @@ export class HttpgdManager { await extensionContext.workspaceState.update('r.plot.httpgdConnectionsKey', this.secretKey); } - constructor() { + constructor(lifecycle?: PlotLifecycle) { + this.viewerStore = new PlotViewerRegistry(() => { + void this.saveConnections(); + }, lifecycle); const htmlRoot = extensionContext.asAbsolutePath('dist/webviews/httpgd'); this.viewerOptions = { parent: this, @@ -244,25 +211,35 @@ export class HttpgdManager { } const url = new URL(urlString); const token = url.searchParams.get('token') || undefined; - const ind = this.viewers.findIndex((viewer) => viewer.origin === url.origin); + const shared = this.viewerStore.forSession(source); + const existing = this.viewers.find((viewer) => viewer.api.connection(url.origin)); // Check after the async boundary: a new device may have attached while // restoration was pending. Never replace its credentials with saved ones. - if (onlyIfMissing && ind >= 0) { + if (onlyIfMissing && existing) { return; } - if (ind >= 0) { - const viewer = this.viewers.splice(ind, 1)[0]; + if (existing) { + const viewer = existing; const owner = viewer.connection.source; const sameSession = !source || !owner || sessionProcessIdentity(source) === sessionProcessIdentity(owner); - if (!viewer.isDisposed && viewer.token === token && sameSession) { + if ( + !viewer.isDisposed && + sameSession && + (!shared || shared === viewer) && + (viewer.api.connection(url.origin)?.token === token || + viewer.connections.length > 1) + ) { viewer.bindSession(source); + this.registerViewer(viewer); if (viewer.isDisposed) { return; } - this.viewers.unshift(viewer); + if (viewer.api.connection(url.origin)?.token !== token) { + viewer.addDevice(url.origin, token, restoring); + } if (!restoring) { viewer.show(); } @@ -273,11 +250,19 @@ export class HttpgdManager { } // A restarted process can reuse both its port and token. Retire // disposed or differently owned connections as well as stale credentials. - viewer.dispose(); - viewer.webviewPanel?.dispose(); - this.recentlyActiveViewers = this.recentlyActiveViewers.filter( - (active) => active !== viewer, - ); + if (!viewer.isDisposed && viewer.connections.length > 1) { + viewer.api.removeDevice(url.origin); + } else { + this.viewerStore.remove(viewer); + viewer.dispose(); + } + } + if (shared && !shared.isDisposed) { + shared.addDevice(url.origin, token, restoring); + if (!onlyIfMissing) { + await this.saveConnections(); + } + return; } const conf = config(); const colorTheme = conf.get('plot.defaults.colorTheme', 'vscode'); @@ -293,10 +278,10 @@ export class HttpgdManager { this.viewerOptions.source = source; this.viewerOptions.restoring = restoring; const viewer = new HttpgdViewer(url.origin, this.viewerOptions); + this.registerViewer(viewer); if (viewer.isDisposed) { return; } - this.viewers.unshift(viewer); if (!onlyIfMissing) { await this.saveConnections(); } @@ -306,35 +291,13 @@ export class HttpgdManager { if (this.disposed || viewer.isDisposed) { return; } - const ind = this.recentlyActiveViewers.indexOf(viewer); - if (ind >= 0) { - this.recentlyActiveViewers.splice(ind, 1); - } - this.recentlyActiveViewers.unshift(viewer); - } - - public closeOtherSessionPanels(viewer: HttpgdViewer): void { - const source = viewer.connection.source; - if (!source) { - return; - } - const key = sessionProcessIdentity(source); - for (const other of this.viewers) { - const owner = other.connection.source; - if (other !== viewer && owner && sessionProcessIdentity(owner) === key) { - other.webviewPanel?.dispose(); - } - } + this.viewerStore.markActive(viewer); } public getRecentViewer(): HttpgdViewer | undefined { return this.recentlyActiveViewers.find((viewer) => !!viewer.webviewPanel); } - public getNewestViewer(): HttpgdViewer | undefined { - return this.viewers[0]; - } - public async openUrl(): Promise { const clipText = await vscode.env.clipboard.readText(); const val0 = clipText.trim().split(/[\n ]/)[0]; @@ -372,6 +335,14 @@ interface ShowOptions { preserveFocus?: boolean; } +interface PlotRefresh { + plots: HttpgdIdResponse[]; + redraw: boolean; + force: boolean; + restoring: boolean; + changed?: ReadonlySet; +} + export class HttpgdViewer implements IHttpgdViewer, PlotViewer { readonly id: string; readonly parent: HttpgdManager; @@ -379,25 +350,41 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { readonly origin: string; readonly token?: string; webviewPanel?: vscode.WebviewPanel; - readonly api: HttpgdClient; + readonly api: HttpgdDevices; private disposed = false; private source?: ViewerSessionSource; private sessionContext?: ViewerSessionContext; - private sessionExitSubscription?: { dispose(): void }; private sessionConnectionSubscription?: vscode.Disposable; private sessionDisconnected = false; private restoring: boolean; + private pendingRefresh?: PlotRefresh; + private refreshTask?: Promise; + private requestGeneration = 0; + private changedPlots = new Set(); + private pendingReveal = false; get isDisposed(): boolean { return this.disposed; } get connection(): HttpgdConnection { - const url = new URL(this.origin); - if (this.token) { - url.searchParams.set('token', this.token); - } - return { url: url.toString(), source: this.source }; + return this.connections[0]; + } + + get connections(): HttpgdConnection[] { + return this.api.connections.map(({ origin, token }) => { + const url = new URL(origin); + if (token) { + url.searchParams.set('token', token); + } + return { url: url.toString(), source: this.source }; + }); + } + + public addDevice(origin: string, token?: string, restoring = false): void { + void this.api + .addDevice(origin, token, restoring) + .catch((err) => this.reportRefreshError(err)); } plots: HttpgdPlot[] = []; activePlot?: HttpgdPlotId; @@ -411,8 +398,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { customOverwriteCssPath?: string; viewHeight: number = 600; viewWidth: number = 800; - plotHeight: number = 600; - plotWidth: number = 800; readonly zoom0: number = 1; zoom: number = this.zoom0; protected resizeTimeout?: NodeJS.Timeout; @@ -446,14 +431,16 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { const url = new URL(host.includes('://') ? host : `http://${host}`); this.host = url.host; this.origin = url.origin; - this.id = this.host; + // A device's port can be reused while this session still owns other + // devices. Panel command identity must outlive any individual origin. + this.id = `httpgd:${randomUUID()}`; this.token = options.token; this.restoring = options.restoring ?? false; this.parent = options.parent; - this.api = new HttpgdClient(this.origin, this.token); - this.api.onPlotsChanged((newState) => { - void this.refreshPlotsDelayed(newState.plots).catch((err) => { + this.api = new HttpgdDevices(this.origin, this.token, this.restoring); + this.api.onPlotsChanged((plots, restoring, changed) => { + void this.refreshPlotsDelayed(plots, false, false, restoring, changed).catch((err) => { this.reportRefreshError(err); }); }); @@ -552,13 +539,15 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { if (!source || this.source) { return; } - this.source = { ...source }; - this.sessionContext = getViewerSessionContext(source.sessionId, source); - const onExit = () => { - this.dispose(); - this.parent.sessionExited(source); + this.source = { + sessionId: source.sessionId, + host: source.host, + pid: source.pid, + rVer: source.rVer, + processExited: source.processExited, }; - this.sessionExitSubscription = this.sessionContext?.observeExit(onExit); + source = this.source; + this.sessionContext = getViewerSessionContext(source.sessionId, source); this.sessionConnectionSubscription = onDidChangeSessionConnection((event) => { if (event.session.sessionId !== source.sessionId || this.disposed) { return; @@ -574,9 +563,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.connect(); } }); - if (source.processExited || this.sessionContext?.hasExited) { - onExit(); - } if (this.webviewPanel) { this.sessionContext?.attach(this.webviewPanel); this.refreshHtml(); @@ -603,9 +589,10 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } public openExternal(): void { - const url = new URL('/live', this.origin); - if (this.token) { - url.searchParams.set('token', this.token); + const connection = this.api.plotConnection(this.activePlot); + const url = new URL('/live', connection.origin); + if (connection.token) { + url.searchParams.set('token', connection.token); } // Uri.parse decodes query escapes, including delimiters within a token. // Preserve URLSearchParams' encoding when handing the URL to VS Code. @@ -623,6 +610,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { const plt = this.plots[this.activeIndex]; if ( plt && + this.canRefresh && (plt.height !== this.viewHeight || plt.width !== this.viewWidth || plt.zoom !== this.zoom) @@ -688,7 +676,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { id ??= this.activePlot; if (id) { this.hidePlot(id); - await this.api.removePlot({ id: id }); + if (this.canRefresh) { + await this.api.removePlot({ id: id }); + } } } @@ -798,12 +788,15 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { if (!id) { return; } + const generation = this.requestGeneration; const plt = await this.getPlotContent(id, this.viewWidth, this.viewHeight, this.zoom); - if (!this.canRefresh) { + if (!this.canRefresh || generation !== this.requestGeneration) { return; } - this.plotWidth = plt.width; - this.plotHeight = plt.height; + const index = this.getIndex(id); + if (index >= 0) { + this.plots[index] = plt; + } this.updatePlot(plt); } @@ -811,53 +804,128 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { plotsIdResponse: HttpgdIdResponse[], redraw: boolean = false, force: boolean = false, + restoring = this.restoring, + changed?: ReadonlySet, ): Promise { if (!this.canRefresh) { return; } if (this.refreshTimeoutLength === 0) { - await this.refreshPlots(plotsIdResponse, redraw, force); + await this.refreshPlots(plotsIdResponse, redraw, force, restoring, changed); } else { + for (const id of changed ?? []) { + this.changedPlots.add(id); + } + this.pendingReveal ||= !restoring; clearTimeout(this.refreshTimeout); this.refreshTimeout = setTimeout(() => { - void this.refreshPlots(plotsIdResponse, redraw, force) - .catch((err) => { - this.reportRefreshError(err); - }) - .then(() => (this.refreshTimeout = undefined)); + const changedPlots = changed ? this.changedPlots : undefined; + const restoring = !this.pendingReveal; + this.changedPlots = new Set(); + this.pendingReveal = false; + this.refreshTimeout = undefined; + void this.refreshPlots( + plotsIdResponse, + redraw, + force, + restoring, + changedPlots, + ).catch((err) => { + this.reportRefreshError(err); + }); }, this.refreshTimeoutLength); } } - protected async refreshPlots( + protected refreshPlots( plotsIdResponse: HttpgdIdResponse[], redraw: boolean = false, force: boolean = false, + restoring = this.restoring, + changed?: ReadonlySet, ): Promise { + const pending = this.pendingRefresh; + this.pendingRefresh = { + plots: plotsIdResponse, + redraw: redraw || !!pending?.redraw, + force: force || !!pending?.force, + restoring: restoring && (pending?.restoring ?? true), + changed: pending + ? changed && pending.changed + ? new Set([...pending.changed, ...changed]) + : undefined + : changed, + }; + this.refreshTask ??= Promise.resolve() + .then(async () => { + while (this.pendingRefresh) { + const request = this.pendingRefresh; + this.pendingRefresh = undefined; + try { + await this.applyPlotRefresh(request); + } catch (error) { + // Keep newer device notifications even when an older render fails. + this.reportRefreshError(error); + } + } + }) + .then(() => { + this.refreshTask = undefined; + const pending = this.pendingRefresh; + if (pending) { + return this.refreshPlots( + pending.plots, + pending.redraw, + pending.force, + pending.restoring, + pending.changed, + ); + } + }); + return this.refreshTask; + } + + private async applyPlotRefresh({ + plots: plotsIdResponse, + redraw, + force, + restoring, + changed, + }: PlotRefresh): Promise { if (!this.canRefresh) { return; } - const restoring = this.restoring; - const nPlots = this.plots.length; + const generation = this.requestGeneration; const hidden = new Set(this.hiddenPlots); const existing = new Map(this.plots.map((plot) => [plot.id, plot])); const plotIds = plotsIdResponse.map((x) => x.id).filter((id) => !hidden.has(id)); const newPlotPromises = plotIds.map(async (id) => { const plot = existing.get(id); - if (force || !plot || id === this.activePlot) { - return await this.getPlotContent(id, this.viewWidth, this.viewHeight, this.zoom); + if (force || !plot || (changed ? changed.has(id) : id === this.activePlot)) { + return await this.getPlotContent( + id, + this.viewWidth, + this.viewHeight, + this.zoom, + plot ?? null, + ); } else { return plot; } }); const newPlots = await Promise.all(newPlotPromises); - if (!this.canRefresh) { + if (!this.canRefresh || generation !== this.requestGeneration) { return; } const oldPlotIds = new Set(this.plots.map((plt) => plt.id)); + const newPlotIds = new Set(plotIds); this.plots = newPlots; this.restoring = false; - if (this.plots.length !== nPlots) { + const added = this.plots.filter((plot) => !oldPlotIds.has(plot.id)); + if ( + (!restoring && added.length) || + !this.plots.some((plot) => plot.id === this.activePlot) + ) { this.activePlot = this.plots[this.plots.length - 1]?.id; } // Initial connection replays existing plots. Only a new plot or an @@ -872,9 +940,16 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { if (redraw || !this.webviewPanel) { this.refreshHtml(); } else { + for (const id of oldPlotIds) { + if (!newPlotIds.has(id)) { + this._hidePlot(id); + } + } for (const plt of this.plots) { if (oldPlotIds.has(plt.id)) { - this.updatePlot(plt); + if (existing.get(plt.id) !== plt) { + this.updatePlot(plt); + } } else { this.addPlot(plt); } @@ -901,8 +976,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { html: html, }; this.postWebviewMessage(msg); - void this.focusPlot(plt.id); - void this.setContextValues(); } protected async getPlotContent( @@ -910,7 +983,13 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { width: number, height: number, zoom: number, + cached: HttpgdPlot | null = this.plots.find((plot) => plot.id === id) ?? null, ): Promise> { + if (cached && !this.api.isActive(id)) { + // SVG scales with the panel. Do not replay or refetch a retained + // inactive device, which may already have been closed in R. + return { ...cached, width, height, zoom }; + } const args = { id: id, height: height, @@ -994,7 +1073,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } protected makeNewWebview(showOptions?: ShowOptions): vscode.WebviewPanel { - this.parent.closeOtherSessionPanels(this); this.parent.registerActiveViewer(this); const webviewPanel = vscode.window.createWebviewPanel( 'RPlot', @@ -1003,7 +1081,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.webviewOptions, ); webviewPanel.iconPath = new UriIcon('graph'); - webviewPanel.onDidDispose(() => (this.webviewPanel = undefined)); + webviewPanel.onDidDispose(() => { + this.webviewPanel = undefined; + }); webviewPanel.onDidChangeViewState(() => { void this.setContextValues(); }); @@ -1038,7 +1118,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { return; } if (!rendererId) { - const renderers = this.api.getRenderers(); + const renderers = this.api.getRenderers(id); const qpItems = renderers.map((renderer) => ({ label: renderer.name, detail: renderer.descr, @@ -1054,7 +1134,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } if (!outFile) { const options: vscode.SaveDialogOptions = {}; - const renderer = this.api.getRenderers().find((r) => r.id === rendererId); + const renderer = this.api.getRenderers(id).find((r) => r.id === rendererId); const ext = renderer?.ext.replace(/^\./, ''); if (this.lastExportUri) { const noExtPath = this.lastExportUri.fsPath.replace(/\.[^.]*$/, ''); @@ -1089,17 +1169,32 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } } - public dispose(): void { + public dispose(keepPanel = false): void { if (this.disposed) { return; } this.disposed = true; - this.sessionExitSubscription?.dispose(); this.sessionConnectionSubscription?.dispose(); this.stopRequests(); + if (!keepPanel) { + this.webviewPanel?.dispose(); + } + this.webviewPanel = undefined; + this.releasePlots(); + } + + private releasePlots(): void { + this.plots = []; + this.hiddenPlots = []; + this.activePlot = undefined; + this.api.releasePlots(); } private stopRequests(): void { + this.requestGeneration++; + this.pendingRefresh = undefined; + this.changedPlots.clear(); + this.pendingReveal = false; clearTimeout(this.resizeTimeout); clearTimeout(this.refreshTimeout); this.resizeTimeout = undefined; diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index 1174f69fb..26868cd7d 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -12,6 +12,8 @@ import { sessionProcessIdentity } from '../sessionIdentity'; import { isViewerSessionSource, ViewerSessionSource } from '../viewerSession'; import { randomUUID } from 'crypto'; import type { PlotHistoryState } from './jgdPlotHistory'; +import { PlotLifecycle } from './plotLifecycle'; +import { PlotViewerRegistry } from './plotViewerRegistry'; export type PlotBackend = 'auto' | 'standard' | 'httpgd' | 'jgd' | 'native'; @@ -55,7 +57,14 @@ const commands = [ export class CommonPlotManager implements PlotManager { public httpgdManager: HttpgdManager; public jgdManager: JgdManager; - private standardViewers = new Map(); + private lifecycle = new PlotLifecycle(); + private standardViewers = new PlotViewerRegistry((source) => { + const key = sessionProcessIdentity(source); + this.restoredStandardSessions = this.restoredStandardSessions.filter( + (saved) => sessionProcessIdentity(saved) !== key, + ); + void this.saveStandardSources(); + }, this.lifecycle); private restoredStandardSessions: ViewerSessionSource[] = []; private disposed = false; private historySaveTimer: NodeJS.Timeout | undefined; @@ -66,12 +75,12 @@ export class CommonPlotManager implements PlotManager { private historyCheckpointId: string | undefined; constructor() { - this.httpgdManager = new HttpgdManager(); - this.jgdManager = new JgdManager(); + this.httpgdManager = new HttpgdManager(this.lifecycle); + this.jgdManager = new JgdManager(this.lifecycle); } get viewers(): PlotViewer[] { - const viewers: PlotViewer[] = [...this.httpgdManager.viewers]; + const viewers: PlotViewer[] = this.httpgdManager.getViewers(); viewers.push(...this.jgdManager.getViewers()); viewers.push(...this.standardViewers.values()); return viewers; @@ -104,11 +113,17 @@ export class CommonPlotManager implements PlotManager { ? selection.positions : undefined, ); - this.historyContentDirty = !this.historyCheckpointId || trimmed; + this.historyContentDirty = + !this.historyCheckpointId || + trimmed || + saved?.sessions.length !== this.jgdManager.history.sessionIds().length; const sources = extensionContext.workspaceState.get('r.plot.standardSessions'); this.restoredStandardSessions = Array.isArray(sources) ? sources.filter(isViewerSessionSource) : []; + for (const source of this.restoredStandardSessions) { + this.lifecycle.observe(source); + } this.httpgdManager.initialize(); this.historySubscription = this.jgdManager.history.onDidChange((_id, selectionOnly) => { this.historyContentDirty ||= !selectionOnly; @@ -173,6 +188,10 @@ export class CommonPlotManager implements PlotManager { if (this.disposed) { return; } + this.lifecycle.observe(target); + if (this.lifecycle.hasExited(target)) { + return; + } const viewer = restoring ? this.standardViewers.get(sessionProcessIdentity(target)) : this.getStandardViewer(target); @@ -180,14 +199,15 @@ export class CommonPlotManager implements PlotManager { } private getStandardViewer(target: Session): StandardPlotViewer { - const key = sessionProcessIdentity(target); - let viewer = this.standardViewers.get(key); - if (!viewer) { - viewer = new StandardPlotViewer(target); - this.standardViewers.set(key, viewer); + const existing = this.standardViewers.forSession(target); + const viewer = this.standardViewers.getOrCreate( + target, + target.sessionId, + () => new StandardPlotViewer(target), + ); + viewer.bindSession(target); + if (!existing) { void this.saveStandardSources(); - } else { - viewer.bindSession(target); } return viewer; } @@ -216,15 +236,14 @@ export class CommonPlotManager implements PlotManager { this.disposed = true; clearTimeout(this.historySaveTimer); this.historySubscription?.dispose(); + this.lifecycle.dispose(); const saved = Promise.all([ this.saveJgdHistory(), this.saveStandardSources(), this.httpgdManager.saveConnections(), ]); this.jgdManager.stop(); - for (const viewer of this.standardViewers.values()) { - viewer.dispose(); - } + this.standardViewers.dispose(); this.httpgdManager.dispose(); await saved; } @@ -271,7 +290,12 @@ export class CommonPlotManager implements PlotManager { this.restoredStandardSessions.map((source) => [sessionProcessIdentity(source), source]), ); for (const viewer of this.standardViewers.values()) { - standardSources.set(sessionProcessIdentity(viewer.sessionSource), viewer.sessionSource); + if (!this.lifecycle.hasExited(viewer.sessionSource)) { + standardSources.set( + sessionProcessIdentity(viewer.sessionSource), + viewer.sessionSource, + ); + } } await extensionContext.workspaceState .update('r.plot.standardSessions', [...standardSources.values()]) @@ -310,7 +334,7 @@ export class CommonPlotManager implements PlotManager { } const key = sessionProcessIdentity(target); if ( - this.standardViewers.has(key) || + this.standardViewers.get(key) || this.restoredStandardSessions.some((saved) => sessionProcessIdentity(saved) === key) ) { this.getStandardViewer(target).show(true); @@ -330,15 +354,23 @@ export class CommonPlotManager implements PlotManager { // Identify the correct viewer let viewer: PlotViewer | undefined; if (typeof hostOrWebviewUri === 'string') { - viewer = this.httpgdManager.viewers.find( - (v: HttpgdViewer) => v.id === hostOrWebviewUri || v.host === hostOrWebviewUri, - ); + const viewers = this.httpgdManager.getViewers(); + viewer = + viewers.find((v: HttpgdViewer) => v.id === hostOrWebviewUri) ?? + viewers.find((v) => + v.connections.some((connection) => { + const url = new URL(connection.url); + return url.host === hostOrWebviewUri || url.origin === hostOrWebviewUri; + }), + ); } else if (hostOrWebviewUri instanceof vscode.Uri) { viewer = this.viewers.find((v) => v.getPanelPath?.() === hostOrWebviewUri.path); } - // Fallback to active viewer - viewer ||= this.activeViewer; + // A command from an ended session's retained tab must not target another session. + if (hostOrWebviewUri === undefined) { + viewer = this.activeViewer; + } if (viewer) { await viewer.handleCommand(command, ...args); diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts index b31956cbe..394cedfdd 100644 --- a/src/plotViewer/jgdPlotHistory.ts +++ b/src/plotViewer/jgdPlotHistory.ts @@ -70,6 +70,30 @@ export class PlotHistory { return this.sessions.get(sessionId)?.source; } + removeSession(source: ViewerSessionSource): void { + const key = sessionProcessIdentity(source); + const timeline = this.owners.get(key); + if (!timeline) { + return; + } + for (const id of timeline.devices) { + this.sessions.delete(id); + this.timelines.delete(id); + } + this.owners.delete(key); + if (timeline.devices.has(this.activeSessionId)) { + this.activeSessionId = ''; + } + this.emitter.emit('change'); + } + + private retainedTimelines(): Timeline[] { + return [...new Set(this.timelines.values())].filter( + (timeline) => + !this.sessionSource(timeline.devices.values().next().value!)?.processExited, + ); + } + restoreDeviceIdentity(previousId: string, id: string, source: ViewerSessionSource): void { const device = this.sessions.get(previousId); if ( @@ -118,25 +142,29 @@ export class PlotHistory { snapshot(): PlotHistoryState { return { - activeSessionId: this.activeSessionId, - sessions: [...this.sessions].map(([id, history]) => ({ - id, - ...history, - source: history.source ? { ...history.source } : undefined, - plots: history.plots.map((plot) => { - let snapshot = this.snapshots.get(plot); - if (!snapshot) { - snapshot = structuredClone(plot); - this.snapshots.set(plot, snapshot); - } - return snapshot; - }), - currentIndex: Math.max( - history.plots.length ? 0 : -1, - history.plots.indexOf(this.currentPlot(id)!), - ), - })), - timelines: [...new Set(this.timelines.values())].map((timeline) => ({ + activeSessionId: this.sessionSource(this.activeSessionId)?.processExited + ? '' + : this.activeSessionId, + sessions: [...this.sessions] + .filter(([, history]) => !history.source?.processExited) + .map(([id, history]) => ({ + id, + ...history, + source: history.source ? { ...history.source } : undefined, + plots: history.plots.map((plot) => { + let snapshot = this.snapshots.get(plot); + if (!snapshot) { + snapshot = structuredClone(plot); + this.snapshots.set(plot, snapshot); + } + return snapshot; + }), + currentIndex: Math.max( + history.plots.length ? 0 : -1, + history.plots.indexOf(this.currentPlot(id)!), + ), + })), + timelines: this.retainedTimelines().map((timeline) => ({ deviceId: timeline.devices.values().next().value!, plots: timeline.plots.map((plot) => ({ deviceId: plot.sessionId, @@ -148,7 +176,7 @@ export class PlotHistory { } snapshotSelection(): Array<{ deviceId: string; currentIndex: number }> { - return [...new Set(this.timelines.values())].map((timeline) => ({ + return this.retainedTimelines().map((timeline) => ({ deviceId: timeline.devices.values().next().value!, currentIndex: timeline.currentIndex, })); @@ -163,7 +191,12 @@ export class PlotHistory { if (!Array.isArray(state.sessions)) { return false; } + let trimmed = false; for (const entry of state.sessions) { + if (isViewerSessionSource(entry?.source) && entry.source.processExited) { + trimmed = true; + continue; + } if ( !entry || typeof entry.id !== 'string' || @@ -254,7 +287,6 @@ export class PlotHistory { } } } - let trimmed = false; for (const timeline of new Set(this.timelines.values())) { for (const plot of timeline.plots) { this.order.set(plot, this.sequence++); diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts index e3ec5b608..9edfefd75 100644 --- a/src/plotViewer/jgdSocketServer.ts +++ b/src/plotViewer/jgdSocketServer.ts @@ -123,6 +123,15 @@ export class JgdSocketServer { return !!session && !session.deviceClosed; } + disconnect(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session) { + this.sessions.delete(sessionId); + session.socket.destroy(); + this.notifyConnectionChange(sessionId); + } + } + private notifyConnectionChange(sessionId: string) { const count = this.sessions.size; for (const l of this.connectionListeners) { diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 19a66922d..e4939ac9f 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -15,6 +15,8 @@ import { ViewerSessionSource, } from '../viewerSession'; import { sessionProcessIdentity } from '../sessionIdentity'; +import { PlotLifecycle } from './plotLifecycle'; +import { PlotViewerRegistry } from './plotViewerRegistry'; interface MetricsRequest { id: number; @@ -104,20 +106,49 @@ type WebviewMessage = export class JgdManager { public server: JgdSocketServer; public history: PlotHistory; - private viewers = new Map(); + private viewers: PlotViewerRegistry; private extensionUri: vscode.Uri | null = null; private historyChangeDisposable: { dispose(): void } | null = null; private sessionConnectionDisposable: vscode.Disposable | undefined; private started = false; - constructor() { + constructor(lifecycle?: PlotLifecycle) { const maxPlots = config().get('plot.jgd.historyLimit', 50); this.history = new PlotHistory(maxPlots); this.server = new JgdSocketServer(this.history); + this.viewers = new PlotViewerRegistry((source) => { + const key = sessionProcessIdentity(source); + for (const id of this.history.sessionIds()) { + const owner = this.history.sessionSource(id); + if (owner && sessionProcessIdentity(owner) === key) { + this.server.disconnect(id); + } + } + this.history.removeSession(source); + }, lifecycle); } initialize(extensionUri: vscode.Uri) { this.extensionUri = extensionUri; + // Observe history owners even when their viewer is closed or only restored + // from storage. A transport disconnect alone never proves process exit. + this.historyChangeDisposable = this.history.onDidChange((id, selectionOnly) => { + if (!selectionOnly) { + for (const device of id ? [id] : this.history.sessionIds()) { + const source = this.history.sessionSource(device); + if (source) { + this.viewers.lifecycle.observe(source); + } + } + } + if (id) { + this.findViewer(id)?.updateToolbar(); + } else { + for (const viewer of this.viewers.values()) { + viewer.updateToolbar(); + } + } + }); this.sessionConnectionDisposable = onDidChangeSessionConnection((event) => { if (event.kind === 'connected') { // Binding an owner can rekey or merge viewers during this pass. @@ -189,16 +220,6 @@ export class JgdManager { }); this.server.start(); - - this.historyChangeDisposable = this.history.onDidChange((sessionId) => { - if (sessionId) { - this.findViewer(sessionId)?.updateToolbar(); - } else { - for (const viewer of this.viewers.values()) { - viewer.updateToolbar(); - } - } - }); } stop() { @@ -206,10 +227,7 @@ export class JgdManager { this.server.stop(); this.historyChangeDisposable?.dispose(); this.sessionConnectionDisposable?.dispose(); - for (const viewer of this.viewers.values()) { - viewer.dispose(); - } - this.viewers.clear(); + this.viewers.dispose(); } getViewers(): JgdViewer[] { @@ -250,10 +268,7 @@ export class JgdManager { private findViewer(id: string): JgdViewer | undefined { const source = this.history.sessionSource(id); - return ( - this.viewers.get(id) ?? - (source ? this.viewers.get(sessionProcessIdentity(source)) : undefined) - ); + return this.viewers.get(id) ?? this.viewers.forSession(source); } getViewer(sessionId = this.history.getActiveSessionId()): JgdViewer | null { @@ -265,27 +280,12 @@ export class JgdManager { } private getOrCreateViewer(sessionId: string): JgdViewer { - const key = this.viewerKey(sessionId); - let viewer = this.viewers.get(key); - // A legacy device may acquire its owner after its first frame. - const previous = this.viewers.get(sessionId); - if (previous && previous !== viewer) { - for (const [oldKey, value] of this.viewers) { - if (value === previous) { - this.viewers.delete(oldKey); - } - } - if (viewer) { - previous.dispose(); - } else { - viewer = previous; - } - } - if (!viewer) { - viewer = new JgdViewer(this.extensionUri!, this.history, this.server, sessionId); - } + const viewer = this.viewers.getOrCreate( + this.sourceForDevice(sessionId), + sessionId, + () => new JgdViewer(this.extensionUri!, this.history, this.server, sessionId), + ); viewer.sessionId = sessionId; - this.viewers.set(key, viewer); return viewer; } } @@ -332,9 +332,12 @@ export class JgdViewer implements PlotViewer { return this.panel?.active ?? false; } - dispose(): void { - this.panel?.dispose(); + dispose(keepPanel = false): void { + if (!keepPanel) { + this.panel?.dispose(); + } this.panel = null; + this.metricsCache.clear(); } async handleCommand(command: string, ...args: unknown[]): Promise { @@ -633,6 +636,9 @@ export class JgdViewer implements PlotViewer { this.panel.iconPath = new UriIcon('graph'); this.panel.webview.onDidReceiveMessage((raw: WebviewMessage) => { + if (!this.panel) { + return; + } switch (raw.type) { case 'ready': { this.updateVisibility(true); diff --git a/src/plotViewer/plotLifecycle.ts b/src/plotViewer/plotLifecycle.ts new file mode 100644 index 000000000..ffc20215a --- /dev/null +++ b/src/plotViewer/plotLifecycle.ts @@ -0,0 +1,73 @@ +import { getViewerSessionContext } from '../session'; +import { sessionProcessIdentity } from '../sessionIdentity'; +import type { ViewerSessionSource } from '../viewerSession'; + +/** Confirmed R exit triggers full cleanup, regardless of panel visibility. + * Disposing the extension or losing its transport does not end an R session. + */ +export class PlotLifecycle { + private subscriptions = new Map(); + private exited = new Set(); + private listeners = new Set<(source: ViewerSessionSource) => void>(); + private disposed = false; + + onExit(listener: (source: ViewerSessionSource) => void): { dispose(): void } { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + hasExited(source?: ViewerSessionSource): boolean { + return ( + !!source && (source.processExited || this.exited.has(sessionProcessIdentity(source))) + ); + } + + observe(source?: ViewerSessionSource): void { + if (!source || this.disposed) { + return; + } + const key = sessionProcessIdentity(source); + if (this.exited.has(key) || this.subscriptions.has(key)) { + return; + } + // Session adapters can clear PID metadata during detach. Keep the + // original process identity for the eventual exit callback. + const captured: ViewerSessionSource = { + sessionId: source.sessionId, + host: source.host, + pid: source.pid, + rVer: source.rVer, + processExited: source.processExited, + }; + const context = getViewerSessionContext(captured.sessionId, captured); + const subscription = context?.observeExit(() => this.markExited(captured)); + if (subscription) { + this.subscriptions.set(key, subscription); + } + if (source.processExited || context?.hasExited) { + this.markExited(captured); + } + } + + markExited(source: ViewerSessionSource): void { + const key = sessionProcessIdentity(source); + if (this.disposed || this.exited.has(key)) { + return; + } + this.exited.add(key); + this.subscriptions.get(key)?.dispose(); + this.subscriptions.delete(key); + for (const listener of this.listeners) { + listener(source); + } + } + + dispose(): void { + this.disposed = true; + for (const subscription of this.subscriptions.values()) { + subscription.dispose(); + } + this.subscriptions.clear(); + this.listeners.clear(); + } +} diff --git a/src/plotViewer/plotViewerRegistry.ts b/src/plotViewer/plotViewerRegistry.ts new file mode 100644 index 000000000..8ecdef2e4 --- /dev/null +++ b/src/plotViewer/plotViewerRegistry.ts @@ -0,0 +1,100 @@ +import { sessionProcessIdentity } from '../sessionIdentity'; +import type { ViewerSessionSource } from '../viewerSession'; +import type { PlotViewer } from './types'; +import { PlotLifecycle } from './plotLifecycle'; + +/** One viewer per R process, including devices whose owner is identified later. */ +export class PlotViewerRegistry { + private viewers = new Map(); + private recent: T[] = []; + readonly lifecycle: PlotLifecycle; + private readonly ownsLifecycle: boolean; + private readonly exitSubscription: { dispose(): void }; + + constructor(onExit: (source: ViewerSessionSource) => void, lifecycle?: PlotLifecycle) { + this.ownsLifecycle = !lifecycle; + this.lifecycle = lifecycle ?? new PlotLifecycle(); + this.exitSubscription = this.lifecycle.onExit((source) => { + const viewer = this.forSession(source); + if (viewer) { + this.remove(viewer); + viewer.dispose(true); + } + // Backing data may exist even when no viewer was opened. + onExit(source); + }); + } + + get(key: string): T | undefined { + return this.viewers.get(key); + } + + forSession(source?: ViewerSessionSource): T | undefined { + return source ? this.get(sessionProcessIdentity(source)) : undefined; + } + + values(): T[] { + return [...this.viewers.values()]; + } + + get recentlyActive(): readonly T[] { + return this.recent; + } + + markActive(viewer: T): void { + this.recent = this.recent.filter((active) => active !== viewer); + this.recent.unshift(viewer); + } + + remove(viewer: T): void { + for (const [key, value] of this.viewers) { + if (value === viewer) { + this.viewers.delete(key); + } + } + this.recent = this.recent.filter((active) => active !== viewer); + } + + register(viewer: T, source?: ViewerSessionSource, deviceId = viewer.id): void { + const key = source ? sessionProcessIdentity(source) : deviceId; + // Preserve recent focus when moving an ownerless viewer to its owner. + if (this.viewers.get(key) !== viewer) { + for (const [oldKey, value] of this.viewers) { + if (value === viewer) { + this.viewers.delete(oldKey); + } + } + } + this.viewers.delete(key); + this.viewers.set(key, viewer); + this.lifecycle.observe(source); + if (this.lifecycle.hasExited(source) && this.viewers.get(key) === viewer) { + this.remove(viewer); + viewer.dispose(true); + } + } + + getOrCreate(source: ViewerSessionSource | undefined, deviceId: string, create: () => T): T { + let viewer = this.forSession(source) ?? this.get(deviceId); + const previous = this.get(deviceId); + if (previous && previous !== viewer) { + this.remove(previous); + previous.dispose(); + } + viewer ??= create(); + this.register(viewer, source, deviceId); + return viewer; + } + + dispose(): void { + this.exitSubscription.dispose(); + if (this.ownsLifecycle) { + this.lifecycle.dispose(); + } + for (const viewer of this.viewers.values()) { + viewer.dispose(); + } + // Shutdown writers still read the owners/endpoints for live R sessions. + // Permanent exit instead removes entries immediately in the handler above. + } +} diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index 6659d067b..edf1f2ffe 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -101,12 +101,15 @@ export class StandardPlotViewer implements PlotViewer { // Contextual plot commands are not supported by the standard viewer. } - public dispose(): void { + public dispose(keepPanel = false): void { const panel = this.panel; if (panel) { this.panel = undefined; this.panelGeneration++; - panel.dispose(); + this.pendingPlotRequest = undefined; + if (!keepPanel) { + panel.dispose(); + } } } diff --git a/src/plotViewer/types.ts b/src/plotViewer/types.ts index fc15fa0e9..5d7eae8ce 100644 --- a/src/plotViewer/types.ts +++ b/src/plotViewer/types.ts @@ -14,7 +14,7 @@ export interface PlotViewer { readonly isActive?: boolean; getPanelPath?(): string | undefined; show(preserveFocus?: boolean): void; - dispose(): void; + dispose(keepPanel?: boolean): void; handleCommand(command: string, ...args: unknown[]): void | Promise; } diff --git a/src/plotViewer/webviewMessages.d.ts b/src/plotViewer/webviewMessages.d.ts index 206d81541..a636b9f11 100644 --- a/src/plotViewer/webviewMessages.d.ts +++ b/src/plotViewer/webviewMessages.d.ts @@ -1,4 +1,4 @@ -// Make sure these files contain the same interfaces as ./html/XXX/webviewMessages.d.ts! +// Shared message types for the plot viewer and its webview. interface VsCode { postMessage: (msg: OutMessage) => void; diff --git a/src/test/node/httpgdDevices.test.ts b/src/test/node/httpgdDevices.test.ts new file mode 100644 index 000000000..900916951 --- /dev/null +++ b/src/test/node/httpgdDevices.test.ts @@ -0,0 +1,137 @@ +import * as assert from 'node:assert/strict'; +import * as sinon from 'sinon'; +import { HttpgdClient, HttpgdPlotsResponse } from '../../plotViewer/httpgdClient'; +import { HttpgdDevices } from '../../plotViewer/httpgdDevices'; + +suite('httpgd session history', () => { + let sandbox: sinon.SinonSandbox; + let session: HttpgdDevices; + let clients: HttpgdClient[]; + let updates: Map void>; + let bytes: sinon.SinonStub; + let remove: sinon.SinonStub; + let disconnect: sinon.SinonStub; + + setup(async () => { + sandbox = sinon.createSandbox(); + clients = []; + updates = new Map(); + sandbox.stub(HttpgdClient.prototype, 'onPlotsChanged').callsFake(function ( + this: HttpgdClient, + callback, + ) { + clients.push(this); + updates.set(this, callback); + }); + sandbox.stub(HttpgdClient.prototype, 'connect').resolves(); + disconnect = sandbox.stub(HttpgdClient.prototype, 'disconnect'); + bytes = sandbox + .stub(HttpgdClient.prototype, 'getPlotBytes') + .resolves(Buffer.from('')); + remove = sandbox.stub(HttpgdClient.prototype, 'removePlot').resolves(); + session = new HttpgdDevices('http://localhost:1234', 'first'); + await session.connect(); + }); + teardown(() => { + session.disconnect(); + sandbox.restore(); + }); + + function publish(index: number, ids: string[]) { + updates.get(clients[index])!({ + state: { upid: 1, hsize: ids.length, active: true }, + plots: ids.map((id) => ({ id })), + }); + } + + test('interleaves devices, disambiguates IDs and routes render/export/remove to their owners', async () => { + publish(0, ['1']); + await session.addDevice('http://localhost:1235', 'second'); + publish(1, ['1']); + publish(0, ['1', '2']); + const ids = session.getPlots().map((plot) => plot.id); + assert.equal(new Set(ids).size, 3); + for (const [index, client, rawId] of [ + [0, 0, '1'], + [1, 1, '1'], + [2, 0, '2'], + ] as const) { + await session.getPlotBytes({ + id: ids[index], + renderer: 'png', + width: 900, + height: 700, + }); + assert.equal(bytes.lastCall.thisValue, clients[client]); + assert.deepEqual(bytes.lastCall.args[0], { + id: rawId, + renderer: 'png', + ...(client === 1 + ? { width: undefined, height: undefined, zoom: undefined } + : { width: 900, height: 700 }), + }); + } + assert.equal(session.plotConnection(ids[1]).token, 'second'); + await session.removePlot({ id: ids[1] }); + assert.equal(remove.lastCall.thisValue, clients[1]); + assert.deepEqual(remove.lastCall.args, [{ id: '1' }]); + publish(1, []); + assert.deepEqual( + session.getPlots().map((plot) => plot.id), + [ids[0], ids[2]], + ); + }); + + test('replacement cannot reuse hidden IDs or deliver late frames into the session', async () => { + publish(0, ['1']); + await session.addDevice('http://localhost:1235', 'second'); + publish(1, ['1']); + const before = session.getPlots(); + await session.addDevice('http://localhost:1235', 'replacement'); + sinon.assert.calledOn(disconnect, clients[1]); + publish(1, ['1', 'stale']); + assert.deepEqual(session.getPlots(), [before[0]]); + publish(2, ['1']); + const after = session.getPlots(); + assert.equal(after.length, 2); + assert.notEqual(after[1].id, before[1].id); + await session.getPlotText({ id: after[1].id }); + assert.equal(bytes.lastCall.thisValue, clients[2]); + assert.equal(session.plotConnection(after[1].id).token, 'replacement'); + }); + + test('reconnect retains stable IDs and order and marks each initial device snapshot as restoring', async () => { + const events: boolean[] = []; + session.onPlotsChanged((_plots, restoring) => events.push(restoring)); + publish(0, ['1']); + await session.addDevice('http://localhost:1235', 'second'); + publish(1, ['1']); + const before = session.getPlots(); + session.disconnect(); + publish(0, ['ignored']); + assert.deepEqual(session.getPlots(), before); + await session.connect(); + publish(1, ['1']); + publish(0, ['1']); + assert.deepEqual(session.getPlots(), before); + assert.deepEqual(events.slice(-2), [true, true]); + publish(0, ['1', '2']); + assert.equal(events.at(-1), false); + }); + + test('device selection notifications do not rerender unchanged plots', () => { + const changes: ReadonlySet[] = []; + session.onPlotsChanged((_plots, _restoring, changed) => changes.push(changed)); + publish(0, ['1']); + updates.get(clients[0])!({ + state: { upid: 1, hsize: 1, active: false }, + plots: [{ id: '1' }], + }); + assert.equal(changes.at(-1)!.size, 0); + updates.get(clients[0])!({ + state: { upid: 2, hsize: 1, active: true }, + plots: [{ id: '1' }], + }); + assert.deepEqual([...changes.at(-1)!], ['1']); + }); +}); diff --git a/src/test/suite/httpgdViewer.test.ts b/src/test/suite/httpgdViewer.test.ts index 610f1c9d4..cfb339644 100644 --- a/src/test/suite/httpgdViewer.test.ts +++ b/src/test/suite/httpgdViewer.test.ts @@ -66,7 +66,7 @@ suite('httpgd viewer requests', () => { test('focusing a correctly sized non-square plot does not request another render', async () => { const viewer = new TestViewer('localhost:1234', manager.viewerOptions); - manager.viewers.push(viewer); + manager.registerViewer(viewer); viewer.plots = [{ id: '1', data: '', width: 800, height: 600, zoom: 1 }]; await viewer.focusPlot('1'); assert.strictEqual(viewer.renderCount, 0); @@ -82,7 +82,7 @@ suite('httpgd viewer requests', () => { refreshTimeoutLength: 10, resizeTimeoutLength: 100, }); - manager.viewers.push(viewer); + manager.registerViewer(viewer); viewer.resize(600, 900); await clock.tickAsync(10); assert.strictEqual(viewer.renderCount, 0); @@ -208,7 +208,7 @@ suite('httpgd viewer requests', () => { refreshTimeoutLength: 10, }); viewer.activePlot = '1'; - manager.viewers.push(viewer); + manager.registerViewer(viewer); const disconnect = sandbox.spy(viewer.api, 'disconnect'); const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); const fire = (kind: RSessionConnectionChange['kind'], sessionId = source.sessionId) => @@ -228,12 +228,13 @@ suite('httpgd viewer requests', () => { }; } - test('owning process exit cancels queued work and leaves the open panel intact', async () => { + test('owning process exit deletes history and cancels queued work while leaving the rendered plot alone', async () => { const clock = sandbox.useFakeTimers(); const state = lifetime(); const request = sandbox.stub(state.viewer.api, 'getPlotText').resolves(''); const closePanel = sandbox.stub(); - state.viewer.webviewPanel = { dispose: closePanel } as unknown as vscode.WebviewPanel; + const panel = { dispose: closePanel, webview: { html: '' } }; + state.viewer.webviewPanel = panel as unknown as vscode.WebviewPanel; manager.registerActiveViewer(state.viewer); state.viewer.queueResize(); await state.viewer.queueRefresh(); @@ -246,6 +247,10 @@ suite('httpgd viewer requests', () => { sinon.assert.calledOnce(state.disconnect); sinon.assert.calledOnce(state.released); sinon.assert.notCalled(closePanel); + assert.strictEqual(panel.webview.html, ''); + assert.strictEqual(state.viewer.webviewPanel, undefined); + assert.deepStrictEqual(state.viewer.plots, []); + assert.strictEqual(state.viewer.activePlot, undefined); sinon.assert.notCalled(state.errors); assert.deepStrictEqual(manager.viewers, []); assert.deepStrictEqual(manager.recentlyActiveViewers, []); diff --git a/src/test/suite/jgdSocketServer.test.ts b/src/test/suite/jgdSocketServer.test.ts index e8448b3d9..3062a37e6 100644 --- a/src/test/suite/jgdSocketServer.test.ts +++ b/src/test/suite/jgdSocketServer.test.ts @@ -163,6 +163,30 @@ suite('JGD SocketServer', () => { return client; } + test('disconnect releases only the requested device and leaves other sessions connected', async () => { + const first = await connect(); + first.send(makePlotMsg('first')); + first.send({ type: 'metrics_request', id: 1 }); + await first.readLine(); + const firstId = history.getActiveSessionId(); + const second = await connect(); + second.send(makePlotMsg('second')); + second.send({ type: 'metrics_request', id: 2 }); + await second.readLine(); + const secondId = history.getActiveSessionId(); + + const closed = new Promise((resolve) => first.socket.once('close', () => resolve())); + server.disconnect(firstId); + await closed; + assert.strictEqual(server.isConnected(firstId), false); + assert.strictEqual(server.isConnected(secondId), true); + second.send(makePlotMsg('still live')); + second.send({ type: 'metrics_request', id: 3 }); + await second.readLine(); + assert.strictEqual(history.count(secondId), 2); + assert.strictEqual(history.currentPlot(secondId)?.device.bg, 'still live'); + }); + suite('frame routing', () => { test('resize replays retain their target when snapshots carry restoration metadata', async () => { const identity = { diff --git a/src/test/suite/plotCommands.test.ts b/src/test/suite/plotCommands.test.ts index ac70bb2da..99a61a7d8 100644 --- a/src/test/suite/plotCommands.test.ts +++ b/src/test/suite/plotCommands.test.ts @@ -96,8 +96,14 @@ suite('Contextual plot commands', () => { sandbox.stub(manager.jgdManager, 'getViewers').returns([jgdViewer]); const viewer = sandbox.createStubInstance(HttpgdViewer); Object.defineProperty(viewer, 'host', { value: 'localhost:1234' }); + Object.defineProperty(viewer, 'connection', { + value: { url: 'http://localhost:1234' }, + }); + Object.defineProperty(viewer, 'connections', { + value: [{ url: 'http://localhost:1234' }], + }); viewer.getPanelPath.returns('/plot-viewer'); - manager.httpgdManager.viewers.push(viewer); + manager.httpgdManager.registerViewer(viewer); sandbox.stub(manager, 'activeViewer').get(() => jgdViewer); const openUrl = sandbox.stub(manager.httpgdManager, 'openUrl').resolves(); manager.initialize(); @@ -125,6 +131,11 @@ suite('Contextual plot commands', () => { assert.strictEqual(viewer.handleCommand.callCount, 1); assert.deepStrictEqual(viewer.handleCommand.firstCall.args, [command, 'plot-id']); } + viewer.handleCommand.resetHistory(); + await invoke('r.plot.nextPlot', 'httpgd:ended-session'); + await invoke('r.plot.prevPlot', vscode.Uri.parse('webview:/ended-session')); + assert.strictEqual(viewer.handleCommand.callCount, 0); + sinon.assert.notCalled(handleJgdCommand); await invoke('r.plot.showViewers'); sinon.assert.calledOnceWithExactly(showForSession, source); assert.strictEqual(viewer.show.callCount, 0); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 36b089162..1f89e6967 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -2,6 +2,9 @@ import * as assert from 'assert'; import * as path from 'path'; import * as os from 'os'; import * as http from 'node:http'; +import * as fs from 'node:fs/promises'; +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import type { AddressInfo } from 'node:net'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; @@ -222,7 +225,208 @@ suite('Standalone plot restoration', () => { }; } - test('httpgd process exit removes only its connections and saved credentials, keeping panels', async () => { + for (const backend of ['jgd', 'httpgd', 'standard'] as const) { + for (const closeFirst of [false, true]) { + test(`${backend} deletes history and saved state without altering the rendered document on R exit with closeFirst=${closeFirst}`, async () => { + const exits = processExits(); + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const value = manager(); + const source = owner('lifecycle', '123'); + if (backend === 'jgd') { + value.jgdManager.history.addPlot('device', frame('device')); + value.jgdManager.history.setSessionSource('device', source); + value.jgdManager.showForSession(source); + } else if (backend === 'httpgd') { + await value.showHttpgdPlot('http://localhost:1234?token=secret', source); + value.httpgdManager.viewers[0].show(); + } else { + await value.showStandardPlot(source); + } + const panel = panels[0].panel; + assert.ok(panel.webview.html.length > 0); + const rendered = panel.webview.html; + const close = sandbox.spy(panel, 'dispose'); + if (closeFirst) { + panel.dispose(); + } + await clock.tickAsync(250); + const captured: ViewerSessionSource = { + sessionId: source.sessionId, + host: source.host, + pid: source.pid, + rVer: source.rVer, + processExited: source.processExited, + }; + // Detaching a Session clears transport metadata before its + // original process can exit. Cleanup must retain that identity. + source.pid = ''; + source.rVer = ''; + exits.exit(captured); + assert.strictEqual(value.viewers.length, 0, 'exit releases viewers immediately'); + await clock.tickAsync(250); + assert.strictEqual( + close.callCount, + closeFirst ? 1 : 0, + "R exit must not close the user's viewer tab", + ); + if (!closeFirst) { + assert.strictEqual( + panel.webview.html, + rendered, + 'the rendered document is unchanged', + ); + } + if (backend === 'jgd') { + assert.deepStrictEqual( + (state.get('r.plot.jgdState') as PlotHistoryState).sessions, + [], + ); + } else if (backend === 'httpgd') { + await value.httpgdManager.saveConnections(); + assert.strictEqual(secrets.size, 0); + } else { + assert.deepStrictEqual(state.get('r.plot.standardSessions'), []); + } + assert.strictEqual(value.viewers.length, 0); + assert.strictEqual(exits.listeners.get(sessionProcessIdentity(captured))!.size, 0); + if (!closeFirst) { + panel.dispose(); + } + await value.dispose(); + const restored = manager(); + assert.strictEqual(restored.viewers.length, 0); + assert.strictEqual(restored.jgdManager.history.sessionIds().length, 0); + }); + } + } + + test('all engines share one process-exit observer and release their backing data together', async () => { + const exits = processExits(); + const value = manager(); + const source = owner('shared-lifetime', '123'); + await value.showStandardPlot(source); + await value.showHttpgdPlot('http://localhost:1234', source); + const httpgd = value.httpgdManager.viewers[0]; + httpgd.plots = [{ id: '1', data: '', width: 800, height: 600, zoom: 1 }]; + httpgd.activePlot = '1'; + value.jgdManager.history.addPlot('device', frame('device')); + value.jgdManager.history.setSessionSource('device', source); + const jgd = value.jgdManager.getViewer('device')!; + const releaseHttpgd = sandbox.spy(httpgd, 'dispose'); + const releaseJgd = sandbox.spy(jgd, 'dispose'); + assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))!.size, 1); + exits.exit(source); + sinon.assert.calledOnce(releaseHttpgd); + sinon.assert.calledOnce(releaseJgd); + assert.deepStrictEqual(httpgd.plots, []); + assert.strictEqual(httpgd.activePlot, undefined); + assert.deepStrictEqual(value.jgdManager.history.sessionIds(), []); + assert.deepStrictEqual(value.viewers, []); + assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))!.size, 0); + }); + + for (const closeFirst of [false, true]) { + test(`JGD clears exited session history with viewer ${closeFirst ? 'closed' : 'open'}`, async () => { + const exits = processExits(); + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const value = manager(); + const disconnect = sandbox.spy(value.jgdManager.server, 'disconnect'); + const source = owner('dead', '123'); + const live = owner('live', '456'); + for (const [id, owner] of [ + ['a', source], + ['b', source], + ['c', live], + ] as const) { + value.jgdManager.history.addPlot(id, frame(id)); + value.jgdManager.history.setSessionSource(id, owner); + } + value.jgdManager.showForSession(source); + const panel = panels[0].panel; + if (closeFirst) { + panel.dispose(); + } + await clock.tickAsync(250); + exits.exit(source); + assert.deepStrictEqual( + disconnect.getCalls().map((call) => call.args[0]), + ['a', 'b'], + ); + assert.strictEqual(value.jgdManager.history.count('a'), 0); + assert.strictEqual(value.jgdManager.history.count('b'), 0); + assert.strictEqual(value.jgdManager.showForSession(source), false); + await clock.tickAsync(250); + const saved = state.get('r.plot.jgdState') as PlotHistoryState; + assert.deepStrictEqual( + saved.sessions.map((entry) => entry.id), + ['c'], + ); + assert.deepStrictEqual(value.jgdManager.history.sessionIds(), ['c']); + assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))!.size, 0); + await value.dispose(); + assert.deepStrictEqual(manager().jgdManager.history.sessionIds(), ['c']); + }); + } + + test('JGD watches restored closed viewers and preserves temporary disconnections', async () => { + const exits = processExits(); + const source = owner('saved', '123'); + const first = manager(); + first.jgdManager.history.addPlot('device', frame('device')); + first.jgdManager.history.setSessionSource('device', source); + await first.dispose(); + const restored = manager(); + assert.strictEqual(restored.jgdManager.history.count('device'), 1); + assert.strictEqual(restored.jgdManager.getViewers().length, 0); + assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))!.size, 1); + // Disconnection is not an exit; only the authoritative exit observer clears history. + assert.strictEqual(restored.jgdManager.server.isConnected('device'), false); + exits.exit(source); + assert.deepStrictEqual(restored.jgdManager.history.sessionIds(), []); + await restored.dispose(); + assert.deepStrictEqual((state.get('r.plot.jgdState') as PlotHistoryState).sessions, []); + }); + + test('JGD corrects a saved checkpoint when exit was already confirmed before restoration', async () => { + const exits = processExits(); + const source = owner('saved', '123'); + const first = manager(); + first.jgdManager.history.addPlot('device', frame('device')); + first.jgdManager.history.setSessionSource('device', source); + await first.dispose(); + exits.exit(source); + const restored = manager(); + assert.deepStrictEqual(restored.jgdManager.history.sessionIds(), []); + await restored.dispose(); + assert.deepStrictEqual((state.get('r.plot.jgdState') as PlotHistoryState).sessions, []); + }); + + test('JGD exit during a checkpoint removes stale saved frames after the write completes', async () => { + const exits = processExits(); + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const gate = deferred(); + let writes = 0; + (extensionContext.workspaceState.update as sinon.SinonStub).callsFake( + async (key: string, data: unknown) => { + if (key === 'r.plot.jgdState' && ++writes === 1) { + await gate.promise; + } + state.set(key, structuredClone(data)); + }, + ); + const value = manager(); + const source = owner('pending-exit', '123'); + value.jgdManager.history.addPlot('device', frame('device')); + value.jgdManager.history.setSessionSource('device', source); + await clock.tickAsync(250); + assert.strictEqual(writes, 1); + exits.exit(source); + gate.resolve(); + await value.dispose(); + assert.deepStrictEqual((state.get('r.plot.jgdState') as PlotHistoryState).sessions, []); + }); + + test('httpgd process exit clears history, connections and saved credentials without affecting other owners', async () => { const exits = processExits(); const source = owner('httpgd-owner', '14001'); const replacement = owner(source.sessionId, '14002'); @@ -232,6 +436,7 @@ suite('Standalone plot restoration', () => { const old = [...value.httpgdManager.viewers]; old[0].show(); const panel = old[0].webviewPanel; + const rendered = panel!.webview.html; const close = sandbox.spy(panel!, 'dispose'); const disconnects = old.map((viewer) => sandbox.spy(viewer.api, 'disconnect')); await value.showHttpgdPlot('http://localhost:1236?token=replacement', replacement); @@ -242,7 +447,10 @@ suite('Standalone plot restoration', () => { sinon.assert.calledOnce(disconnect); } sinon.assert.notCalled(close); - assert.strictEqual(old[0].webviewPanel, panel); + assert.strictEqual(panel!.webview.html, rendered); + assert.strictEqual(old[0].webviewPanel, undefined); + assert.deepStrictEqual(old[0].plots, []); + assert.deepStrictEqual(old[0].api.getPlots(), []); assert.strictEqual(value.httpgdManager.viewers.length, 2); assert.ok( old.every((viewer) => !value.httpgdManager.recentlyActiveViewers.includes(viewer)), @@ -490,7 +698,7 @@ suite('Standalone plot restoration', () => { events.dispose(); }); - test('plot indicators update on owning process exit and release observations on tab close', async () => { + test('owning process exit releases all engines without changing rendered documents or continuing process polling', async () => { const clock = sandbox.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); const kill = sandbox.stub(process, 'kill').returns(true); const source = owner('plot-exit', '12120'); @@ -503,17 +711,18 @@ suite('Standalone plot restoration', () => { value.httpgdManager.viewers[0].show(); value.jgdManager.showForSession(source); assert.strictEqual(panels.length, 3); + const rendered = panels.map(({ panel }) => panel.webview.html); + const closes = panels.map(({ panel }) => sandbox.spy(panel, 'dispose')); kill.callsFake(() => { throw Object.assign(new Error('gone'), { code: 'ESRCH' }); }); await clock.tickAsync(1000); - for (const { panel, post } of panels) { - sinon.assert.calledWith(post, { - message: 'viewer-session/update', - text: 'R: (not attached)', - }); + closes.forEach((close) => sinon.assert.notCalled(close)); + for (const [index, { panel }] of panels.entries()) { + assert.strictEqual(panel.webview.html, rendered[index]); panel.dispose(); } + assert.deepStrictEqual(value.viewers, []); kill.resetHistory(); await clock.tickAsync(2000); sinon.assert.notCalled(kill); @@ -979,7 +1188,7 @@ suite('Standalone plot restoration', () => { source, restoring: true, }); - value.httpgdManager.viewers.push(viewer); + value.httpgdManager.registerViewer(viewer); sandbox.stub(viewer.api, 'getPlotText').resolves(''); await viewer.receivePlots(['1']); assert.strictEqual(panels.length, 0); @@ -995,7 +1204,7 @@ suite('Standalone plot restoration', () => { assert.strictEqual(panels.length, 2, 'a new plot opens one replacement panel'); }); - test('httpgd devices share one visible viewer per session while other sessions remain independent', async () => { + test('httpgd devices reuse one session viewer and preserve its panel and settings', async () => { const a = owner('a', '123'); const b = owner('b', '456'); focus(a); @@ -1004,25 +1213,221 @@ suite('Standalone plot restoration', () => { await value.showHttpgdPlot('http://localhost:1234', a); const first = value.httpgdManager.viewers[0]; first.show(); + const panel = first.webviewPanel; + first.zoom = 2; + first.togglePreviewPlots('hidden'); await value.showHttpgdPlot('http://localhost:1235', b); const other = value.httpgdManager.viewers[0]; other.show(); await value.showHttpgdPlot('http://localhost:1236', a); - const second = value.httpgdManager.viewers[0]; - second.show(); - assert.strictEqual(first.webviewPanel, undefined); - assert.ok(second.webviewPanel); - assert.ok(other.webviewPanel); - first.show(); - assert.strictEqual(second.webviewPanel, undefined); - assert.ok(first.webviewPanel); + assert.strictEqual(value.httpgdManager.viewers.length, 2); + assert.strictEqual(first.connections.length, 2); + assert.strictEqual(first.webviewPanel, panel); + assert.strictEqual(first.zoom, 2); + assert.strictEqual(first.previewPlotLayout, 'hidden'); assert.ok(other.webviewPanel); - panels.at(-1)!.panel.dispose(); + panel!.dispose(); const count = panels.length; await callbacks.get('r.plot.showViewers')!(); assert.strictEqual(panels.length, count + 1); - assert.ok(first.webviewPanel, 'reopens the last used device for the focused session'); - assert.strictEqual(second.webviewPanel, undefined); + assert.ok(first.webviewPanel); + assert.strictEqual(first.connections.length, 2); + }); + + test('httpgd late ownership joins an existing session viewer without replacing its panel', async () => { + const source = owner('late-owner', '123'); + const value = manager(); + await value.showHttpgdPlot('http://localhost:1234', source); + const shared = value.httpgdManager.viewers[0]; + shared.show(); + const panel = shared.webviewPanel; + await value.showHttpgdPlot('http://localhost:1235'); + const unowned = value.httpgdManager.viewers[0]; + unowned.show(); + await value.showHttpgdPlot('http://localhost:1235', source); + assert.deepStrictEqual(value.httpgdManager.viewers, [shared]); + assert.strictEqual(shared.webviewPanel, panel); + assert.strictEqual(shared.connections.length, 2); + assert.strictEqual(unowned.isDisposed, true); + assert.strictEqual(unowned.webviewPanel, undefined); + }); + + test('httpgd command routing survives port reuse by a different session', async () => { + const value = manager(); + const source = owner('original', '123'); + await value.showHttpgdPlot('http://localhost:1234', source); + const first = value.httpgdManager.viewers[0]; + first.show(); + await value.showHttpgdPlot('http://localhost:1235', source); + const panel = first.webviewPanel; + await value.showHttpgdPlot('http://localhost:1234', owner('replacement', '456')); + const replacement = value.httpgdManager.viewers[0]; + assert.notStrictEqual(replacement, first); + assert.strictEqual(first.webviewPanel, panel); + assert.strictEqual(first.connections.length, 1); + const originalCommand = sandbox.stub(first, 'handleCommand'); + const replacementCommand = sandbox.stub(replacement, 'handleCommand'); + await callbacks.get('r.plot.hidePlot')!(first.id, 'plot'); + sinon.assert.calledOnceWithExactly(originalCommand, 'hidePlot', 'plot'); + sinon.assert.notCalled(replacementCommand); + await callbacks.get('r.plot.hidePlot')!('localhost:1234', 'replacement-plot'); + sinon.assert.calledOnceWithExactly(replacementCommand, 'hidePlot', 'replacement-plot'); + }); + + test('httpgd coalesces notifications arriving during a slow render without losing plots', async () => { + const value = manager(); + const viewer = new RefreshViewer( + 'http://localhost:1234', + value.httpgdManager.viewerOptions, + ); + value.httpgdManager.registerViewer(viewer); + const gate = deferred(); + const render = sandbox.stub(viewer.api, 'getPlotText').resolves(''); + render.onFirstCall().returns(gate.promise); + const first = viewer.receivePlots(['1']); + await Promise.resolve(); + const second = viewer.receivePlots(['1', '2']); + const third = viewer.receivePlots(['1', '2', '3']); + sinon.assert.calledOnce(render); + gate.resolve(''); + await Promise.all([first, second, third]); + assert.deepStrictEqual( + viewer.plots.map((plot) => plot.id), + ['1', '2', '3'], + ); + assert.strictEqual(viewer.activePlot, '3'); + assert.strictEqual(render.getCalls().filter((call) => call.args[0].id === '2').length, 1); + assert.strictEqual(render.getCalls().filter((call) => call.args[0].id === '3').length, 1); + }); + + test('live httpgd dev.new shares navigation, resize, export, reload and exit cleanup', async function () { + this.timeout(30000); + try { + const probe = await promisify(execFile)('Rscript', [ + '--vanilla', + '-e', + 'cat(requireNamespace("httpgd", quietly=TRUE) && requireNamespace("jsonlite", quietly=TRUE))', + ]); + if (probe.stdout.trim() !== 'TRUE') { + this.skip(); + } + } catch { + this.skip(); + } + connect.callThrough(); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'httpgd-session-')); + const r = spawn('R', ['--vanilla', '--slave', '--interactive']); + const exited = new Promise((resolve) => r.once('exit', () => resolve())); + let output = ''; + let errors = ''; + r.stdout.on('data', (data: Buffer) => { + output += data.toString(); + }); + r.stderr.on('data', (data: Buffer) => { + errors += data.toString(); + }); + let sequence = 0; + const evaluate = async (code: string): Promise => { + const marker = `HTTPGD_RESULT_${++sequence}:`; + r.stdin.write( + `tryCatch({value <- {${code}}; cat(${JSON.stringify(marker)}, jsonlite::toJSON(value, auto_unbox=TRUE), "\\n", sep="")}, error=function(e) cat(${JSON.stringify(marker)}, jsonlite::toJSON(list(error=conditionMessage(e)), auto_unbox=TRUE), "\\n", sep=""))\n`, + ); + const line = await waitForValue(() => { + if (r.exitCode !== null) { + throw new Error(`R exited: ${errors}`); + } + const start = output.indexOf(marker); + const end = output.indexOf('\n', start); + return start >= 0 && end >= 0 + ? output.slice(start + marker.length, end) + : undefined; + }); + const result: unknown = JSON.parse(line); + assert.strictEqual(typeof result, 'string', line); + return result as string; + }; + try { + const source = owner('live-httpgd', String(r.pid)); + source.host = os.hostname(); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); + const first = manager(); + const firstUrl = await evaluate( + 'options(device=function(...) httpgd::hgd(silent=TRUE)); plot(1:10, main="FIRST DEVICE"); first_device <- dev.cur(); httpgd::hgd_url()', + ); + await first.showHttpgdPlot(firstUrl, source); + const viewer = first.httpgdManager.viewers[0]; + await waitForValue(() => (viewer.plots.length === 1 ? true : undefined)); + const panel = viewer.webviewPanel; + const secondUrl = await evaluate( + 'dev.new(); plot(1:100, main="SECOND DEVICE"); httpgd::hgd_url()', + ); + await first.showHttpgdPlot(secondUrl, source); + await waitForValue(() => (viewer.plots.length === 2 ? true : undefined)); + assert.strictEqual(first.httpgdManager.viewers.length, 1); + assert.strictEqual(viewer.webviewPanel, panel); + assert.ok(viewer.plots[0].data.includes('FIRST DEVICE')); + assert.ok(viewer.plots[1].data.includes('SECOND DEVICE')); + await viewer.prevPlot(); + assert.strictEqual(viewer.activePlot, viewer.plots[0].id); + viewer.viewWidth = 920; + viewer.viewHeight = 680; + await viewer.focusPlot(); + assert.strictEqual(viewer.plots[0].width, 920); + assert.ok(viewer.plots[0].data.includes('FIRST DEVICE')); + const file = path.join(directory, 'first.svg'); + await viewer.exportPlot(viewer.activePlot, 'svgp', file); + assert.ok((await fs.readFile(file, 'utf8')).includes('FIRST DEVICE')); + await evaluate('dev.set(first_device); plot(11:20, main="THIRD PLOT"); "OK"'); + await waitForValue(() => (viewer.plots.length === 3 ? true : undefined)); + assert.ok(viewer.plots[2].data.includes('THIRD PLOT')); + viewer.viewWidth = 960; + await viewer.focusPlot(viewer.plots[0].id); + assert.ok(viewer.plots[0].data.includes('width="960.00"')); + assert.ok(viewer.plots[0].data.includes('FIRST DEVICE')); + panel!.dispose(); + await first.dispose(); + const restored = manager(); + await callbacks.get('r.plot.showViewers')!(); + const live = restored.httpgdManager.viewers[0]; + await waitForValue(() => (live.plots.length === 3 ? true : undefined)); + assert.strictEqual(restored.httpgdManager.viewers.length, 1); + assert.strictEqual(live.connections.length, 2); + const second = live.plots.find((plot) => plot.data.includes('SECOND DEVICE'))!; + await live.closePlot(second.id); + await waitForValue(() => (live.api.getPlots().length === 2 ? true : undefined)); + assert.strictEqual(live.plots.length, 2); + assert.ok(live.plots[0].data.includes('FIRST DEVICE')); + await evaluate('dev.off(first_device); "OK"'); + live.viewWidth = 1010; + await live.focusPlot(live.plots[0].id); + assert.strictEqual(live.plots[0].width, 1010); + assert.ok(live.plots[0].data.includes('FIRST DEVICE')); + const retainedPanel = live.webviewPanel!; + const rendered = retainedPanel.webview.html; + const closed = sandbox.spy(retainedPanel, 'dispose'); + await live.nextPlot(true); + r.kill(); + await exited; + await waitForValue(() => (live.isDisposed ? true : undefined)); + await restored.httpgdManager.saveConnections(); + assert.strictEqual(secrets.size, 0); + sinon.assert.notCalled(closed); + assert.strictEqual(retainedPanel.webview.html, rendered); + assert.strictEqual(live.webviewPanel, undefined); + assert.strictEqual(live.plots.length, 0); + await callbacks.get('r.plot.firstPlot')!(live.id); + assert.strictEqual(live.activePlot, undefined, 'ended sessions have no navigation'); + assert.strictEqual(live.plots.length, 0); + assert.strictEqual(live.api.getPlots().length, 0); + retainedPanel.dispose(); + } finally { + if (r.exitCode === null && r.signalCode === null) { + r.kill(); + } + await exited; + await fs.rm(directory, { recursive: true, force: true }); + } }); test('standard reconnect refreshes only its existing panel without revealing or reopening it', async () => { @@ -1337,7 +1742,7 @@ suite('Standalone plot restoration', () => { resizeTimeoutLength: 0, source: owner('layout', '14002'), }); - value.httpgdManager.viewers.push(viewer); + value.httpgdManager.registerViewer(viewer); const svg = (width: number, height: number) => ``; viewer.plots = [{ id: '1', data: svg(800, 400), width: 800, height: 400, zoom: 1 }]; From 4b61f7f3ca770ded6c42750bd42a21d87e7bc70a Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:55:52 +1100 Subject: [PATCH 24/32] fix(plots): restore toolbar navigation to the focused viewer Remove incorrect viewer identification derived from resource URLs. Route toolbar actions to the focused live viewer without falling back to another session when an ended session's tab remains open. Add regression coverage for consecutive httpgd plots and toolbar command routing. Validation: real toolbar clicks and 152 regression tests passed. --- src/plotViewer/httpgdViewer.ts | 6 +----- src/plotViewer/index.ts | 11 +++++++++-- src/plotViewer/jgdViewer.ts | 6 +----- src/plotViewer/standardViewer.ts | 6 +----- src/plotViewer/types.ts | 11 ----------- src/test/suite/plotCommands.test.ts | 13 ++++++++++--- src/test/suite/plotRestoration.test.ts | 17 ++++++++++++++++- 7 files changed, 38 insertions(+), 32 deletions(-) diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 99f7e13d8..76744e710 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -35,7 +35,7 @@ import { PreviewPlotLayoutMessage, ToggleFullWindowMessage, } from './webviewMessages'; -import { PlotViewer, plotPanelPath } from './types'; +import { PlotViewer } from './types'; interface HttpgdConnection { url: string; @@ -740,10 +740,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } } - public getPanelPath(): string | undefined { - return plotPanelPath(this.webviewPanel); - } - public get isActive(): boolean { return this.webviewPanel?.active ?? false; } diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index 26868cd7d..42302055c 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -363,8 +363,15 @@ export class CommonPlotManager implements PlotManager { return url.host === hostOrWebviewUri || url.origin === hostOrWebviewUri; }), ); - } else if (hostOrWebviewUri instanceof vscode.Uri) { - viewer = this.viewers.find((v) => v.getPanelPath?.() === hostOrWebviewUri.path); + } else if ( + hostOrWebviewUri instanceof vscode.Uri && + hostOrWebviewUri.scheme === 'webview-panel' + ) { + // Editor toolbar actions belong to the focused panel. Resource URLs + // returned by asWebviewUri are shared across panels, not editor IDs. + // Use only a currently focused live viewer, never the recent fallback: + // an ended session can still have its rendered tab open. + viewer = this.viewers.find((candidate) => candidate.isActive); } // A command from an ended session's retained tab must not target another session. diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index e4939ac9f..efc0e9e21 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { PlotViewer, plotPanelPath } from './types'; +import { PlotViewer } from './types'; import { PlotHistory, PlotFrame } from './jgdPlotHistory'; import { JgdSocketServer, JgdMessage } from './jgdSocketServer'; import { config, UriIcon } from '../util'; @@ -324,10 +324,6 @@ export class JgdViewer implements PlotViewer { this.updateToolbar(); } - getPanelPath(): string | undefined { - return plotPanelPath(this.panel); - } - get isActive(): boolean { return this.panel?.active ?? false; } diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index edf1f2ffe..76cd175b8 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { asViewColumn, config, UriIcon } from '../util'; import * as session from '../session'; -import { PlotViewer, plotPanelPath } from './types'; +import { PlotViewer } from './types'; import { getViewerSessionScript, viewerSessionStyle, @@ -59,10 +59,6 @@ export class StandardPlotViewer implements PlotViewer { return this.panel?.active ?? false; } - getPanelPath(): string | undefined { - return plotPanelPath(this.panel); - } - public async update(restoring = false): Promise { if (restoring) { // Refresh retained panels without creating or revealing a viewer. diff --git a/src/plotViewer/types.ts b/src/plotViewer/types.ts index 5d7eae8ce..9c9360b02 100644 --- a/src/plotViewer/types.ts +++ b/src/plotViewer/types.ts @@ -1,18 +1,7 @@ import type { Session } from '../session'; -import { Uri, WebviewPanel } from 'vscode'; - -export function plotPanelPath(panel: WebviewPanel | undefined | null): string | undefined { - if (!panel) { - return undefined; - } - const uri = panel.webview.asWebviewUri(Uri.file('')); - return `webview-panel/webview-${uri.authority.split('.')[0]}`; -} - export interface PlotViewer { readonly id: string; readonly isActive?: boolean; - getPanelPath?(): string | undefined; show(preserveFocus?: boolean): void; dispose(keepPanel?: boolean): void; handleCommand(command: string, ...args: unknown[]): void | Promise; diff --git a/src/test/suite/plotCommands.test.ts b/src/test/suite/plotCommands.test.ts index 99a61a7d8..a6f6991e0 100644 --- a/src/test/suite/plotCommands.test.ts +++ b/src/test/suite/plotCommands.test.ts @@ -102,7 +102,7 @@ suite('Contextual plot commands', () => { Object.defineProperty(viewer, 'connections', { value: [{ url: 'http://localhost:1234' }], }); - viewer.getPanelPath.returns('/plot-viewer'); + const focused = sandbox.stub(viewer, 'isActive').get(() => true); manager.httpgdManager.registerViewer(viewer); sandbox.stub(manager, 'activeViewer').get(() => jgdViewer); const openUrl = sandbox.stub(manager.httpgdManager, 'openUrl').resolves(); @@ -115,7 +115,10 @@ suite('Contextual plot commands', () => { }; for (const command of toolbarCommands) { viewer.handleCommand.resetHistory(); - await invoke(command, vscode.Uri.parse('webview:/plot-viewer')); + await invoke( + command, + vscode.Uri.parse('webview-panel:webview-panel/webview-RPlot-live-panel'), + ); if (command === 'r.plot.openUrl') { sinon.assert.calledOnce(openUrl); } else { @@ -133,7 +136,11 @@ suite('Contextual plot commands', () => { } viewer.handleCommand.resetHistory(); await invoke('r.plot.nextPlot', 'httpgd:ended-session'); - await invoke('r.plot.prevPlot', vscode.Uri.parse('webview:/ended-session')); + focused.get(() => false); + await invoke( + 'r.plot.prevPlot', + vscode.Uri.parse('webview-panel:webview-panel/webview-RPlot-ended-panel'), + ); assert.strictEqual(viewer.handleCommand.callCount, 0); sinon.assert.notCalled(handleJgdCommand); await invoke('r.plot.showViewers'); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 1f89e6967..21a35a781 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -1359,6 +1359,19 @@ suite('Standalone plot restoration', () => { const viewer = first.httpgdManager.viewers[0]; await waitForValue(() => (viewer.plots.length === 1 ? true : undefined)); const panel = viewer.webviewPanel; + await evaluate('plot(1:100, main="SECOND SAME DEVICE"); "OK"'); + await waitForValue(() => (viewer.plots.length === 2 ? true : undefined)); + const toolbarUri = vscode.Uri.parse( + 'webview-panel:webview-panel/webview-RPlot-live-httpgd', + ); + await callbacks.get('r.plot.prevPlot')!(toolbarUri, { groupId: 0 }); + assert.strictEqual(viewer.activePlot, viewer.plots[0].id); + assert.ok(viewer.plots[0].data.includes('FIRST DEVICE')); + await callbacks.get('r.plot.nextPlot')!(toolbarUri, { groupId: 0 }); + assert.strictEqual(viewer.activePlot, viewer.plots[1].id); + assert.ok(viewer.plots[1].data.includes('SECOND SAME DEVICE')); + await viewer.closePlot(viewer.plots[1].id); + await waitForValue(() => (viewer.plots.length === 1 ? true : undefined)); const secondUrl = await evaluate( 'dev.new(); plot(1:100, main="SECOND DEVICE"); httpgd::hgd_url()', ); @@ -1550,8 +1563,10 @@ suite('Standalone plot restoration', () => { assert.notStrictEqual(a.id, b.id); Object.assign(panels[0].panel, { active: false }); assert.strictEqual(value.activeViewer, b); + Object.assign(panels[0].panel, { active: true }); + Object.assign(panels[1].panel, { active: false }); await callbacks.get('r.plot.prevPlot')!( - vscode.Uri.from({ scheme: 'webview', path: a.getPanelPath()! }), + vscode.Uri.parse('webview-panel:webview-panel/webview-jgd.plotPane-a'), ); assert.strictEqual(history.currentIndex('a'), 1); assert.strictEqual(history.currentIndex('b'), 2); From 39a7f4e36fa4adcc93ea51038566d2daa7d752e3 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:25:33 +1100 Subject: [PATCH 25/32] fix(plots): preserve httpgd updates across device failures and restoration - Refresh changed inactive plots while reusing unchanged cached content - Isolate failed plot fetches and avoid redundant retries - Defer httpgd restoration notifications until runtime setup completes - Add regression coverage for stale plots, fetch failures, and cleanup --- sess/R/hooks.R | 18 ++--- sess/inst/tinytest/test-httpgd-devices.R | 39 ++++++++++ src/plotViewer/httpgdViewer.ts | 73 +++++++++++++++---- src/test/suite/plotRestoration.test.ts | 91 +++++++++++++++++++++++- 4 files changed, 197 insertions(+), 24 deletions(-) diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 83c65a6bb..8f1ee2613 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -338,13 +338,6 @@ runtime_start <- function(use_rstudioapi = TRUE, } else if (selected_backend == "httpgd") { # Notify for both explicit and default devices; own only factory devices. .runtime_set_option("device", .install_httpgd_device_hook()) - # Discover live servers on reconnect without adopting their devices. A plain - # unigd device has no HTTP server and must not prevent the others restoring. - devs <- grDevices::dev.list() - for (id in devs[names(devs) %in% c("httpgd", "unigd")]) { - url <- tryCatch(httpgd::hgd_url(which = id), error = function(e) NULL) - if (!is.null(url)) notify_client("httpgd", list(url = url, restoring = TRUE)) - } } else if (selected_backend == "standard") { # If a specific interactive backend was explicitly requested but is # unavailable, warn before silently degrading to the standard viewer. @@ -452,10 +445,19 @@ runtime_start <- function(use_rstudioapi = TRUE, .runtime_add_task_callback(.workspace_update_task_callback, name = "sess.workspace") completed <- TRUE - # Announce the refreshed record only after setup is complete: a failed write + # Announce retained plots only after setup is complete: a failed write # can stop the runtime, so no callbacks should be installed after this point. if (selected_backend == "standard" && !is.null(.sess_env$latest_plot_record)) { notify_client("plot_updated", list(restoring = TRUE)) + } else if (selected_backend == "httpgd") { + # Discover live servers without adopting their devices. A plain unigd + # device has no HTTP server and must not prevent the others restoring. + devs <- grDevices::dev.list() + for (id in devs[names(devs) %in% c("httpgd", "unigd")]) { + url <- tryCatch(httpgd::hgd_url(which = id), error = function(e) NULL) + if (!is.null(url)) notify_client("httpgd", list(url = url, restoring = TRUE)) + if (!isTRUE(state$active)) break + } } invisible(NULL) } diff --git a/sess/inst/tinytest/test-httpgd-devices.R b/sess/inst/tinytest/test-httpgd-devices.R index 210a9e9e4..7e5b2e69e 100644 --- a/sess/inst/tinytest/test-httpgd-devices.R +++ b/sess/inst/tinytest/test-httpgd-devices.R @@ -61,3 +61,42 @@ if (requireNamespace("httpgd", quietly = TRUE) && expect_true(replacement %in% grDevices::dev.list()) expect_true(first %in% grDevices::dev.list()) }) + +if (requireNamespace("httpgd", quietly = TRUE) && + packageVersion("httpgd") >= "2.0.1") local({ + # A restored-device notification can fail after attach succeeds. No runtime + # hooks or bindings may be installed after that failure cleans up the runtime. + has_rstudioapi <- requireNamespace("rstudioapi", quietly = TRUE) + original_available <- if (has_rstudioapi) rstudioapi::isAvailable else NULL + original_hook <- getHook(packageEvent("rstudioapi", "onLoad")) + original_callbacks <- getTaskCallbackNames() + original_device <- getOption("device") + original_hgd <- httpgd::hgd + sess_env <- sess:::.sess_env + previous_connection <- sess_env$con + httpgd::hgd(silent = TRUE) + device <- grDevices::dev.cur() + cons <- processx::conn_create_pipepair() + close(cons[[1L]]) + sess_env$con <- cons[[2L]] + on.exit({ + sess:::runtime_stop() + sess_env$con <- previous_connection + try(close(cons[[2L]]), silent = TRUE) + if (device %in% grDevices::dev.list()) grDevices::dev.off(device) + }, add = TRUE) + + suppressWarnings(sess:::runtime_start(use_rstudioapi = TRUE, plot_backend = "httpgd")) + expect_null(sess_env$con) + state <- sess:::.runtime_state() + expect_false(isTRUE(state$active)) + for (field in c("hooks", "bindings", "task_callbacks", "options", "s3_methods")) { + expect_length(state[[field]], 0L) + } + expect_identical(getTaskCallbackNames(), original_callbacks) + expect_identical(getHook(packageEvent("rstudioapi", "onLoad")), original_hook) + expect_identical(getOption("device"), original_device) + expect_identical(httpgd::hgd, original_hgd) + if (has_rstudioapi) expect_identical(rstudioapi::isAvailable, original_available) + expect_true(device %in% grDevices::dev.list()) +}) diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 76744e710..eecd0672b 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -361,6 +361,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { private refreshTask?: Promise; private requestGeneration = 0; private changedPlots = new Set(); + private stalePlots = new Set(); + private failedPlots = new Set(); private pendingReveal = false; get isDisposed(): boolean { @@ -895,26 +897,66 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { const hidden = new Set(this.hiddenPlots); const existing = new Map(this.plots.map((plot) => [plot.id, plot])); const plotIds = plotsIdResponse.map((x) => x.id).filter((id) => !hidden.has(id)); - const newPlotPromises = plotIds.map(async (id) => { + const incoming = new Set(plotIds); + for (const tracked of [this.stalePlots, this.failedPlots]) { + for (const id of tracked) { + if (!incoming.has(id)) { + tracked.delete(id); + } + } + } + for (const id of changed ?? []) { + if (incoming.has(id)) { + this.stalePlots.add(id); + this.failedPlots.delete(id); + } + } + const refreshIds = plotIds.filter((id) => { + // A closed device must not be retried on every other device's update. + // Retry on content changes, reactivation, reconnect or explicit refresh. + if (this.failedPlots.has(id) && !this.api.isActive(id) && !force) { + return false; + } const plot = existing.get(id); - if (force || !plot || (changed ? changed.has(id) : id === this.activePlot)) { - return await this.getPlotContent( + return ( + force || !plot || this.stalePlots.has(id) || (!changed && id === this.activePlot) + ); + }); + const results = await Promise.allSettled( + refreshIds.map((id) => + this.getPlotContent( id, this.viewWidth, this.viewHeight, this.zoom, - plot ?? null, - ); - } else { - return plot; - } - }); - const newPlots = await Promise.all(newPlotPromises); + existing.get(id) ?? null, + ), + ), + ); if (!this.canRefresh || generation !== this.requestGeneration) { return; } + const refreshed = new Map>(); + results.forEach((result, index) => { + const id = refreshIds[index]; + if (result.status === 'fulfilled') { + refreshed.set(id, result.value); + this.stalePlots.delete(id); + this.failedPlots.delete(id); + } else { + if (!this.failedPlots.has(id)) { + this.reportRefreshError(result.reason); + } + this.failedPlots.add(id); + } + }); + // Preserve cached failures, omit unavailable uncached plots, and commit + // successful renders from other devices without losing their updates. + const newPlots = plotIds + .map((id) => refreshed.get(id) ?? existing.get(id)) + .filter((plot): plot is HttpgdPlot => plot !== undefined); const oldPlotIds = new Set(this.plots.map((plt) => plt.id)); - const newPlotIds = new Set(plotIds); + const newPlotIds = new Set(newPlots.map((plot) => plot.id)); this.plots = newPlots; this.restoring = false; const added = this.plots.filter((plot) => !oldPlotIds.has(plot.id)); @@ -981,9 +1023,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { zoom: number, cached: HttpgdPlot | null = this.plots.find((plot) => plot.id === id) ?? null, ): Promise> { - if (cached && !this.api.isActive(id)) { - // SVG scales with the panel. Do not replay or refetch a retained - // inactive device, which may already have been closed in R. + if (cached && !this.api.isActive(id) && !this.stalePlots.has(id)) { + // Unchanged inactive plots only need scaling. Changed content must + // still be fetched; HttpgdDevices omits resize arguments for it. return { ...cached, width, height, zoom }; } const args = { @@ -1183,6 +1225,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.plots = []; this.hiddenPlots = []; this.activePlot = undefined; + this.stalePlots.clear(); + this.failedPlots.clear(); this.api.releasePlots(); } @@ -1190,6 +1234,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.requestGeneration++; this.pendingRefresh = undefined; this.changedPlots.clear(); + this.failedPlots.clear(); this.pendingReveal = false; clearTimeout(this.resizeTimeout); clearTimeout(this.refreshTimeout); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 21a35a781..c8577dd77 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -26,8 +26,14 @@ const createRealWebviewPanel = vscode.window.createWebviewPanel; const executeRealCommand = vscode.commands.executeCommand; class RefreshViewer extends HttpgdViewer { - receivePlots(ids: string[]): Promise { - return this.refreshPlots(ids.map((id) => ({ id }))); + receivePlots(ids: string[], changed?: ReadonlySet, force = false): Promise { + return this.refreshPlots( + ids.map((id) => ({ id })), + false, + force, + undefined, + changed, + ); } } @@ -1274,6 +1280,87 @@ suite('Standalone plot restoration', () => { sinon.assert.calledOnceWithExactly(replacementCommand, 'hidePlot', 'replacement-plot'); }); + test('httpgd refreshes changed inactive plots but reuses unchanged geometry', async () => { + const value = manager(); + const viewer = new RefreshViewer( + 'http://localhost:1234', + value.httpgdManager.viewerOptions, + ); + value.httpgdManager.registerViewer(viewer); + sandbox.stub(viewer.api, 'isActive').returns(false); + const render = sandbox.stub(viewer.api, 'getPlotText').resolves('original'); + await viewer.receivePlots(['a'], new Set(['a'])); + render.resolves('annotation'); + await viewer.receivePlots(['a'], new Set(['a'])); + assert.strictEqual(viewer.plots[0].data, 'annotation'); + sinon.assert.calledTwice(render); + + viewer.viewWidth = 900; + await viewer.receivePlots(['a']); + assert.strictEqual(viewer.plots[0].width, 900); + sinon.assert.calledTwice(render); + }); + + test('httpgd commits healthy devices when a closed device has an uncached plot', async () => { + const value = manager(); + const viewer = new RefreshViewer( + 'http://localhost:1234', + value.httpgdManager.viewerOptions, + ); + value.httpgdManager.registerViewer(viewer); + sandbox.stub(viewer.api, 'isActive').callsFake((id) => id.startsWith('b')); + const render = sandbox.stub(viewer.api, 'getPlotText').resolves(''); + const error = sandbox.stub(vscode.window, 'showErrorMessage'); + await viewer.receivePlots(['a1', 'b1'], new Set(['a1', 'b1'])); + const retained = viewer.plots[0]; + render.withArgs(sinon.match({ id: 'a2' })).rejects(new Error('device closed')); + await viewer.receivePlots(['a1', 'b1', 'a2', 'b2'], new Set(['a2', 'b2'])); + assert.deepStrictEqual( + viewer.plots.map((plot) => plot.id), + ['a1', 'b1', 'b2'], + ); + assert.strictEqual(viewer.plots[0], retained); + assert.strictEqual(viewer.activePlot, 'b2'); + sinon.assert.calledOnce(error); + + // Another device's notifications must not keep retrying the closed one. + await viewer.receivePlots(['a1', 'b1', 'a2', 'b2', 'b3'], new Set(['b3'])); + assert.strictEqual(viewer.activePlot, 'b3'); + sinon.assert.calledOnce(render.withArgs(sinon.match({ id: 'a2' }))); + sinon.assert.calledOnce(error); + + // An explicit refresh can recover a temporarily unavailable device. + render.withArgs(sinon.match({ id: 'a2' })).resolves('recovered'); + await viewer.receivePlots(['a1', 'b1', 'a2', 'b2', 'b3'], new Set(), true); + assert.ok(viewer.plots.some((plot) => plot.id === 'a2')); + }); + + test('httpgd keeps failed content invalidated until its device becomes active again', async () => { + const value = manager(); + const viewer = new RefreshViewer( + 'http://localhost:1234', + value.httpgdManager.viewerOptions, + ); + value.httpgdManager.registerViewer(viewer); + const active = sandbox.stub(viewer.api, 'isActive').returns(false); + const render = sandbox.stub(viewer.api, 'getPlotText').resolves('original'); + sandbox.stub(vscode.window, 'showErrorMessage'); + await viewer.receivePlots(['a'], new Set(['a'])); + render.rejects(new Error('temporarily unavailable')); + await viewer.receivePlots(['a'], new Set(['a'])); + assert.strictEqual(viewer.plots[0].data, 'original'); + await viewer.receivePlots(['a'], new Set()); + sinon.assert.calledTwice(render); + + active.returns(true); + render.resolves('annotation'); + await viewer.receivePlots(['a'], new Set()); + assert.strictEqual(viewer.plots[0].data, 'annotation'); + sinon.assert.calledThrice(render); + await viewer.receivePlots(['a'], new Set()); + sinon.assert.calledThrice(render); + }); + test('httpgd coalesces notifications arriving during a slow render without losing plots', async () => { const value = manager(); const viewer = new RefreshViewer( From 48bb4f0321e48d2befb613d818e5dcf02a8f15b4 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:28:43 +1100 Subject: [PATCH 26/32] fix: preserve httpgd plot order and handle closed devices - Restore interleaved plot history using saved ordering metadata - Switch to the next available server when the selected device closes - Avoid resize errors after the last device closes - Fix divider jumps caused by the session header - Add live R and VS Code regression coverage --- src/plotViewer/httpgdClient.ts | 34 +++++- src/plotViewer/httpgdDevices.ts | 141 ++++++++++++++++++++++--- src/plotViewer/httpgdTypes.d.ts | 3 +- src/plotViewer/httpgdViewer.ts | 107 +++++++++++++++++-- src/plotViewer/webview/index.ts | 14 +-- src/test/node/httpgdClient.test.ts | 12 +++ src/test/node/httpgdDevices.test.ts | 87 +++++++++++++++ src/test/suite/plotRestoration.test.ts | 134 ++++++++++++++++++++--- 8 files changed, 488 insertions(+), 44 deletions(-) diff --git a/src/plotViewer/httpgdClient.ts b/src/plotViewer/httpgdClient.ts index 5fef5e2de..f1536ec0b 100644 --- a/src/plotViewer/httpgdClient.ts +++ b/src/plotViewer/httpgdClient.ts @@ -70,7 +70,9 @@ export class HttpgdClient { private renderersLoaded = false; private refreshTask?: Promise; private refreshPending = false; + private unavailable = false; private readonly listeners = new Set<(value: HttpgdPlotsResponse) => void>(); + private readonly unavailableListeners = new Set<() => void>(); constructor(host: string, token?: string, options: ClientOptions = {}) { this.base = new URL(host.includes('://') ? host : `http://${host}`); @@ -107,6 +109,10 @@ export class HttpgdClient { this.listeners.add(listener); } + public onUnavailable(listener: () => void): void { + this.unavailableListeners.add(listener); + } + public getPlots(): HttpgdIdResponse[] { return this.snapshot?.plots ?? []; } @@ -114,6 +120,10 @@ export class HttpgdClient { return this.renderers; } + public async refreshState(): Promise { + await this.updateState(await this.json(this.url('/state'))); + } + public async getPlotText(request: HttpgdPlotRequest): Promise { return (await this.getPlotBytes(request)).toString('utf8'); } @@ -151,7 +161,25 @@ export class HttpgdClient { } // Rendering and exports may be slow; preserve httpgd-js's lack of a // request timeout while still cancelling requests on disconnect. - const response = await getHttpResponse(url, { headers: this.headers, signal }); + let response; + try { + response = await getHttpResponse(url, { headers: this.headers, signal }); + } catch (error) { + if ( + !signal.aborted && + ['ECONNREFUSED', 'ECONNRESET'].includes( + (error as NodeJS.ErrnoException)?.code ?? '', + ) + ) { + this.unavailable = true; + this.closeWebSocket(); + this.schedulePoll(this.retryInterval); + for (const listener of this.unavailableListeners) { + listener(); + } + } + throw error; + } if (response.status !== 200) { throw new Error(`httpgd request failed (${response.status})`); } @@ -211,6 +239,7 @@ export class HttpgdClient { return { id: plot.id }; }); this.snapshot = { state: state(value.state), plots }; + this.unavailable = false; // TODO: Revisit this notification boundary if httpgd and JGD viewer // updates are unified. Their transports remain separate for now. for (const listener of this.listeners) { @@ -223,6 +252,7 @@ export class HttpgdClient { const next = state(value); const previous = this.snapshot?.state; if ( + this.unavailable || !previous || previous.upid !== next.upid || previous.hsize !== next.hsize || @@ -251,7 +281,7 @@ export class HttpgdClient { if (!this.renderersLoaded) { await this.loadRenderers(); } - await this.updateState(await this.json(this.url('/state'))); + await this.refreshState(); if (!this.socket && Date.now() >= this.retryAt) { this.openWebSocket(); } diff --git a/src/plotViewer/httpgdDevices.ts b/src/plotViewer/httpgdDevices.ts index 9cb73d28c..49fa271b0 100644 --- a/src/plotViewer/httpgdDevices.ts +++ b/src/plotViewer/httpgdDevices.ts @@ -13,12 +13,19 @@ interface Device { plots: Map; restoring: boolean; active: boolean; + available: boolean; upid?: number; } interface PlotTarget { device: Device; id: string; + position: number; +} + +export interface HttpgdPlotReference { + origin: string; + id: string; } /** One session timeline; HTTP requests and credentials remain device-specific. */ @@ -28,15 +35,45 @@ export class HttpgdDevices { private targets = new Map(); private nextId = 0; private usedIds = new Set(); + private order = new Map(); + private nextPosition = 0; private connected = false; + private unavailableListeners = new Set<() => void>(); private listeners = new Set< - (plots: HttpgdIdResponse[], restoring: boolean, changed: ReadonlySet) => void + ( + plots: HttpgdIdResponse[], + restoring: boolean, + changed: ReadonlySet, + orderChanged: boolean, + ) => void >(); - constructor(origin: string, token?: string, restoring = false) { + constructor( + origin: string, + token?: string, + restoring = false, + order: HttpgdPlotReference[] = [], + ) { + for (const plot of order) { + this.rememberPlot(plot); + } void this.addDevice(origin, token, restoring); } + get plotOrder(): HttpgdPlotReference[] { + return [...this.order.values()].map(({ plot }) => ({ ...plot })); + } + + private rememberPlot(plot: HttpgdPlotReference): number { + const key = JSON.stringify([plot.origin, plot.id]); + let entry = this.order.get(key); + if (!entry) { + entry = { plot, position: this.nextPosition++ }; + this.order.set(key, entry); + } + return entry.position; + } + get connections(): Array<{ origin: string; token?: string }> { return [...this.devices.values()].map(({ origin, token }) => ({ origin, token })); } @@ -60,9 +97,20 @@ export class HttpgdDevices { plots: new Map(), restoring, active: false, + available: true, }; this.devices.set(origin, device); device.client.onPlotsChanged((snapshot) => this.update(device, snapshot)); + device.client.onUnavailable(() => { + if (!this.connected || this.devices.get(origin) !== device || !device.available) { + return; + } + device.available = false; + device.active = false; + for (const listener of this.unavailableListeners) { + listener(); + } + }); if (this.connected) { await device.client.connect(); } @@ -78,7 +126,12 @@ export class HttpgdDevices { for (const id of device.plots.values()) { this.targets.delete(id); } - this.notify(true); + for (const [key, { plot }] of this.order) { + if (plot.origin === origin) { + this.order.delete(key); + } + } + this.notify(true, new Set(), true); } private update(device: Device, snapshot: HttpgdPlotsResponse): void { @@ -86,22 +139,36 @@ export class HttpgdDevices { return; } device.active = snapshot.state.active; + device.available = true; const contentChanged = device.upid !== snapshot.state.upid; device.upid = snapshot.state.upid; if (device.active) { for (const other of this.devices.values()) { - if (other !== device) { + if (other !== device && other.active) { other.active = false; + // dev.off() can leave httpgd's WebSocket open. Check the + // previous device when R activates its successor. + void other.client.refreshState().catch(() => undefined); } } } const incoming = new Set(snapshot.plots.map((plot) => plot.id)); + let orderChanged = false; + // Reconcile only this device: other restored devices may not have + // delivered their first snapshot yet. + for (const [key, { plot }] of this.order) { + if (plot.origin === device.origin && !incoming.has(plot.id)) { + this.order.delete(key); + orderChanged = true; + } + } for (const [rawId, id] of device.plots) { if (!incoming.has(rawId)) { device.plots.delete(rawId); this.targets.delete(id); } } + let restoreOrder = false; for (const rawId of incoming) { if (device.plots.has(rawId)) { continue; @@ -114,7 +181,17 @@ export class HttpgdDevices { } this.usedIds.add(id); device.plots.set(rawId, id); - this.targets.set(id, { device, id: rawId }); + const saved = this.order.has(JSON.stringify([device.origin, rawId])); + restoreOrder ||= saved; + orderChanged ||= !saved; + this.targets.set(id, { + device, + id: rawId, + position: this.rememberPlot({ origin: device.origin, id: rawId }), + }); + } + if (restoreOrder) { + this.targets = new Map([...this.targets].sort((a, b) => a[1].position - b[1].position)); } const restoring = device.restoring; device.restoring = false; @@ -124,13 +201,18 @@ export class HttpgdDevices { this.notify( restoring, new Set(!contentChanged || latest === undefined ? [] : [device.plots.get(latest)!]), + orderChanged, ); } - private notify(restoring: boolean, changed: ReadonlySet = new Set()): void { + private notify( + restoring: boolean, + changed: ReadonlySet = new Set(), + orderChanged = false, + ): void { const plots = this.getPlots(); for (const listener of this.listeners) { - listener(plots, restoring, changed); + listener(plots, restoring, changed, orderChanged); } } @@ -139,6 +221,7 @@ export class HttpgdDevices { plots: HttpgdIdResponse[], restoring: boolean, changed: ReadonlySet, + orderChanged: boolean, ) => void, ): void { this.listeners.add(listener); @@ -165,6 +248,7 @@ export class HttpgdDevices { releasePlots(): void { this.targets.clear(); this.usedIds.clear(); + this.order.clear(); for (const device of this.devices.values()) { device.plots.clear(); } @@ -182,7 +266,7 @@ export class HttpgdDevices { if (!device) { throw new Error('No httpgd device is available'); } - return { device, id: '' }; + return { device, id: '', position: -1 }; } plotConnection(id?: string): { origin: string; token?: string } { @@ -193,6 +277,42 @@ export class HttpgdDevices { return this.targets.get(id)?.device.active !== false; } + onDeviceUnavailable(listener: () => void): void { + this.unavailableListeners.add(listener); + } + + async nextAvailablePlot( + id?: string, + visible?: ReadonlySet, + ): Promise { + const current = id === undefined ? undefined : this.targets.get(id); + if (!current || current.device.available) { + return undefined; + } + const latest = new Map(); + for (const [plotId, target] of this.targets) { + if (!visible || visible.has(plotId)) { + latest.set(target.device, plotId); + } + } + const devices = [...this.devices.values()]; + const start = devices.indexOf(current.device); + for (let offset = 1; offset < devices.length; offset++) { + const device = devices[(start + offset) % devices.length]; + if (device.available && latest.has(device)) { + // An inactive device may have been closed without a socket + // notification. Verify the successor before selecting it. + try { + await device.client.refreshState(); + return latest.get(device); + } catch { + continue; + } + } + } + return undefined; + } + getRenderers(id?: string): HttpgdRendererResponse[] { return this.target(id).device.client.getRenderers(); } @@ -210,10 +330,7 @@ export class HttpgdDevices { const dimensions = target.device.active ? {} : { width: undefined, height: undefined, zoom: undefined }; - return target.device.client.getPlotBytes({ - ...routed, - ...dimensions, - }); + return target.device.client.getPlotBytes({ ...routed, ...dimensions }); } async removePlot(request: { id: string }): Promise { diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts index 3a3835579..3f06d01c1 100644 --- a/src/plotViewer/httpgdTypes.d.ts +++ b/src/plotViewer/httpgdTypes.d.ts @@ -1,5 +1,5 @@ import { HttpgdPlotId } from './httpgdClient'; -import { HttpgdDevices } from './httpgdDevices'; +import { HttpgdDevices, HttpgdPlotReference } from './httpgdDevices'; import * as vscode from 'vscode'; import { HttpgdManager } from './httpgdViewer'; import type { ViewerSessionSource } from '../viewerSession'; @@ -20,6 +20,7 @@ export interface HttpgdPlot { // Example for possible viewer creation options: export interface HttpgdViewerOptions { + plotOrder?: HttpgdPlotReference[]; restoring?: boolean; parent: HttpgdManager; token?: string; diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index eecd0672b..fe39c2b95 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from './httpgdClient'; -import { HttpgdDevices } from './httpgdDevices'; +import { HttpgdDevices, HttpgdPlotReference } from './httpgdDevices'; import { PlotLifecycle } from './plotLifecycle'; import { PlotViewerRegistry } from './plotViewerRegistry'; import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; @@ -40,6 +40,16 @@ import { PlotViewer } from './types'; interface HttpgdConnection { url: string; source?: ViewerSessionSource; + plotOrder?: HttpgdPlotReference[]; +} + +function isPlotReference(value: unknown): value is HttpgdPlotReference { + return ( + !!value && + typeof value === 'object' && + typeof (value as HttpgdPlotReference).origin === 'string' && + typeof (value as HttpgdPlotReference).id === 'string' + ); } function isHttpgdConnection(value: unknown): value is HttpgdConnection { @@ -72,6 +82,7 @@ export class HttpgdManager { private secretKey: string | undefined; private disposed = false; private connectionWrites: Promise = Promise.resolve(); + private saveTimer?: NodeJS.Timeout; getViewers(): HttpgdViewer[] { return this.viewers; @@ -79,6 +90,7 @@ export class HttpgdManager { dispose(): void { this.disposed = true; + clearTimeout(this.saveTimer); this.viewerStore.dispose(); } @@ -92,7 +104,12 @@ export class HttpgdManager { if (!Array.isArray(connections)) { throw new Error('Invalid saved httpgd connections'); } - const saved = connections.filter(isHttpgdConnection); + const saved = connections.filter(isHttpgdConnection).map((connection) => ({ + ...connection, + plotOrder: Array.isArray(connection.plotOrder) + ? connection.plotOrder.filter(isPlotReference) + : undefined, + })); if (!this.disposed) { for (const connection of saved) { this.viewerStore.lifecycle.observe(connection.source); @@ -144,6 +161,8 @@ export class HttpgdManager { } saveConnections(): Promise { + clearTimeout(this.saveTimer); + this.saveTimer = undefined; this.connectionWrites = this.connectionWrites .then(() => this.writeConnections()) .catch((error) => @@ -152,6 +171,12 @@ export class HttpgdManager { return this.connectionWrites; } + scheduleSaveConnections(): void { + if (!this.disposed && !this.saveTimer) { + this.saveTimer = setTimeout(() => void this.saveConnections(), 250); + } + } + private async writeConnections(): Promise { const connections = new Map( (await this.savedConnections) @@ -205,7 +230,7 @@ export class HttpgdManager { onlyIfMissing = false, restoring = false, ): Promise { - await Promise.resolve(); + const saved = await this.savedConnections.catch(() => []); if (this.disposed || this.hasExited(source)) { return; } @@ -277,6 +302,22 @@ export class HttpgdManager { this.viewerOptions.token = token; this.viewerOptions.source = source; this.viewerOptions.restoring = restoring; + const matching = saved.filter((connection) => + source + ? connection.source && + sessionProcessIdentity(connection.source) === sessionProcessIdentity(source) + : !connection.source && new URL(connection.url).origin === url.origin, + ); + const sameDevice = matching.some((connection) => { + const previous = new URL(connection.url); + return ( + previous.origin === url.origin && + (previous.searchParams.get('token') || undefined) === token + ); + }); + this.viewerOptions.plotOrder = matching + .flatMap((connection) => connection.plotOrder ?? []) + .filter((plot) => plot.origin !== url.origin || sameDevice); const viewer = new HttpgdViewer(url.origin, this.viewerOptions); this.registerViewer(viewer); if (viewer.isDisposed) { @@ -364,6 +405,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { private stalePlots = new Set(); private failedPlots = new Set(); private pendingReveal = false; + private retainedPlotOrder?: HttpgdPlotReference[]; + private switchingDevice = false; get isDisposed(): boolean { return this.disposed; @@ -374,12 +417,16 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } get connections(): HttpgdConnection[] { - return this.api.connections.map(({ origin, token }) => { + return this.api.connections.map(({ origin, token }, index) => { const url = new URL(origin); if (token) { url.searchParams.set('token', token); } - return { url: url.toString(), source: this.source }; + return { + url: url.toString(), + source: this.source, + ...(index === 0 ? { plotOrder: this.retainedPlotOrder ?? this.api.plotOrder } : {}), + }; }); } @@ -440,8 +487,14 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.restoring = options.restoring ?? false; this.parent = options.parent; - this.api = new HttpgdDevices(this.origin, this.token, this.restoring); - this.api.onPlotsChanged((plots, restoring, changed) => { + this.api = new HttpgdDevices(this.origin, this.token, this.restoring, options.plotOrder); + this.api.onDeviceUnavailable(() => { + void this.focusNextAvailableDevice().catch((error) => this.reportRefreshError(error)); + }); + this.api.onPlotsChanged((plots, restoring, changed, orderChanged) => { + if (orderChanged) { + this.parent.scheduleSaveConnections(); + } void this.refreshPlotsDelayed(plots, false, false, restoring, changed).catch((err) => { this.reportRefreshError(err); }); @@ -622,6 +675,25 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this._focusPlot(); } } + + private async focusNextAvailableDevice(): Promise { + if (!this.canRefresh || this.switchingDevice) { + return; + } + this.switchingDevice = true; + const selected = this.activePlot; + try { + const next = await this.api.nextAvailablePlot( + selected, + new Set(this.plots.map((plot) => plot.id)), + ); + if (this.canRefresh && this.activePlot === selected && next) { + await this.focusPlot(next); + } + } finally { + this.switchingDevice = false; + } + } protected _focusPlot(plotId?: HttpgdPlotId): void { plotId ??= this.activePlot; if (!plotId) { @@ -1035,7 +1107,21 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { zoom: zoom, renderer: 'svgp', }; - const svg = await this.api.getPlotText(args); + let svg: string; + try { + svg = await this.api.getPlotText(args); + } catch (error) { + if ( + cached && + !this.stalePlots.has(id) && + ['ECONNREFUSED', 'ECONNRESET'].includes( + (error as NodeJS.ErrnoException)?.code ?? '', + ) + ) { + return { ...cached, width, height, zoom }; + } + throw error; + } const plt: HttpgdPlot = { id: id, data: svg, @@ -1212,6 +1298,11 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { return; } this.disposed = true; + if (!keepPanel) { + // The shutdown checkpoint may run after clients and SVG caches are + // disposed. Retain only the small ordering metadata for that write. + this.retainedPlotOrder = this.api.plotOrder; + } this.sessionConnectionSubscription?.dispose(); this.stopRequests(); if (!keepPanel) { diff --git a/src/plotViewer/webview/index.ts b/src/plotViewer/webview/index.ts index a314346b9..a78cc0a87 100644 --- a/src/plotViewer/webview/index.ts +++ b/src/plotViewer/webview/index.ts @@ -32,6 +32,8 @@ function getSmallPlots(): HTMLAnchorElement[] { } let isHandlerDragging = false; +let dragStartY = 0; +let dragStartHeight = 0; let isFullWindow = false; @@ -178,6 +180,8 @@ document.addEventListener('mousedown', (e) => { // If mousedown event is fired from .handler, toggle flag to true if (!isFullWindow && e.target === handler) { isHandlerDragging = true; + dragStartY = e.clientY; + dragStartHeight = largePlotDiv.clientHeight; handler.classList.add('dragging'); document.body.style.cursor = 'ns-resize'; } @@ -189,19 +193,11 @@ document.addEventListener('mousemove', (e) => { return false; } - // postLogMessage('mousemove'); - - // Get offset - const containerOffsetTop = document.body.offsetTop; - - // Get x-coordinate of pointer relative to container - const pointerRelativeYpos = e.clientY - containerOffsetTop + window.scrollY; - // Arbitrary minimum width set on box A, otherwise its inner content will collapse to width of 0 const largePlotMinHeight = 60; // Resize large plot - const newHeight = Math.max(largePlotMinHeight, pointerRelativeYpos - 5); // <- why 5? + const newHeight = Math.max(largePlotMinHeight, dragStartHeight + e.clientY - dragStartY); const newHeightString = `${newHeight}px`; if (largePlotDiv.style.height !== newHeightString) { diff --git a/src/test/node/httpgdClient.test.ts b/src/test/node/httpgdClient.test.ts index 9511fc3b3..7745149eb 100644 --- a/src/test/node/httpgdClient.test.ts +++ b/src/test/node/httpgdClient.test.ts @@ -183,6 +183,18 @@ suite('Internal httpgd client', () => { await until(() => client.getPlots().length === 2); assert.deepStrictEqual(notifications, [['1'], ['1', '2']]); }); + test('reports a refused server and refreshes unchanged history when it becomes available again', async () => { + let unavailable = 0; + client.onUnavailable(() => unavailable++); + await client.connect(); + const port = (server.address() as AddressInfo).port; + await new Promise((resolve) => server.close(() => resolve())); + await assert.rejects(client.getPlotText({ id: '1' }), { code: 'ECONNREFUSED' }); + assert.ok(unavailable > 0); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + await until(() => notifications.length > 1); + assert.deepStrictEqual(notifications.at(-1), ['1']); + }); test('uses native WebSocket notifications and falls back after a disconnect', async () => { await client.connect(); const socket = TestWebSocket.instances[0]; diff --git a/src/test/node/httpgdDevices.test.ts b/src/test/node/httpgdDevices.test.ts index 900916951..793565a37 100644 --- a/src/test/node/httpgdDevices.test.ts +++ b/src/test/node/httpgdDevices.test.ts @@ -8,6 +8,7 @@ suite('httpgd session history', () => { let session: HttpgdDevices; let clients: HttpgdClient[]; let updates: Map void>; + let unavailable: Map void>; let bytes: sinon.SinonStub; let remove: sinon.SinonStub; let disconnect: sinon.SinonStub; @@ -16,6 +17,13 @@ suite('httpgd session history', () => { sandbox = sinon.createSandbox(); clients = []; updates = new Map(); + unavailable = new Map(); + sandbox.stub(HttpgdClient.prototype, 'onUnavailable').callsFake(function ( + this: HttpgdClient, + callback, + ) { + unavailable.set(this, callback); + }); sandbox.stub(HttpgdClient.prototype, 'onPlotsChanged').callsFake(function ( this: HttpgdClient, callback, @@ -24,6 +32,7 @@ suite('httpgd session history', () => { updates.set(this, callback); }); sandbox.stub(HttpgdClient.prototype, 'connect').resolves(); + sandbox.stub(HttpgdClient.prototype, 'refreshState').resolves(); disconnect = sandbox.stub(HttpgdClient.prototype, 'disconnect'); bytes = sandbox .stub(HttpgdClient.prototype, 'getPlotBytes') @@ -134,4 +143,82 @@ suite('httpgd session history', () => { }); assert.deepEqual([...changes.at(-1)!], ['1']); }); + + test('restores interleaving independently of snapshot arrival and prunes only the reporting device', async () => { + publish(0, ['1']); + await session.addDevice('http://localhost:1235', 'second'); + publish(1, ['1']); + publish(0, ['1', '2']); + const order = session.plotOrder; + session.disconnect(); + session = new HttpgdDevices('http://localhost:1234', 'first', true, order); + await session.addDevice('http://localhost:1235', 'second', true); + await session.connect(); + publish(3, ['1']); + assert.deepEqual(session.plotOrder, order, 'retain pending device references'); + publish(2, ['1', '2']); + assert.deepEqual( + session.getPlots().map(({ id }) => session.plotConnection(id).origin), + ['http://localhost:1234', 'http://localhost:1235', 'http://localhost:1234'], + ); + assert.deepEqual(session.plotOrder, order); + publish(2, ['2']); + assert.deepEqual(session.plotOrder, order.slice(1)); + session.removeDevice('http://localhost:1235'); + assert.deepEqual(session.plotOrder, order.slice(2)); + }); + + test('checkpoints only order changes, not drawing or device-selection updates', () => { + const changes: boolean[] = []; + session.onPlotsChanged((_plots, _restoring, _changed, orderChanged) => + changes.push(orderChanged), + ); + publish(0, ['1']); + updates.get(clients[0])!({ + state: { upid: 2, hsize: 1, active: false }, + plots: [{ id: '1' }], + }); + publish(0, ['1']); + publish(0, []); + assert.deepEqual(changes, [true, false, false, true]); + }); + + test('unavailable devices yield to the next visible live plot and recover on a fresh snapshot', async () => { + publish(0, ['1']); + await session.addDevice('http://localhost:1235', 'second'); + publish(1, ['1']); + publish(0, ['1', '2']); + const ids = session.getPlots().map(({ id }) => id); + const changed = sandbox.spy(); + session.onDeviceUnavailable(changed); + unavailable.get(clients[0])!(); + assert.equal(session.isActive(ids[2]), false); + assert.equal(await session.nextAvailablePlot(ids[2]), ids[1]); + assert.equal(await session.nextAvailablePlot(ids[0], new Set([ids[0], ids[2]])), undefined); + unavailable.get(clients[0])!(); + sinon.assert.calledOnce(changed); + publish(0, ['1', '2']); + assert.equal(session.isActive(ids[2]), true); + assert.equal(await session.nextAvailablePlot(ids[2]), undefined); + unavailable.get(clients[0])!(); + unavailable.get(clients[1])!(); + assert.equal(await session.nextAvailablePlot(ids[2]), undefined); + assert.equal(session.getPlots().length, 3, 'retain rendered history'); + }); + + test('chooses the next server in device order despite interleaved plots', async () => { + publish(0, ['1']); + await session.addDevice('http://localhost:1235', 'second'); + publish(1, ['1']); + publish(0, ['1', '2']); + await session.addDevice('http://localhost:1236', 'third'); + publish(2, ['1']); + publish(1, ['1', '2']); + const ids = session.getPlots().map(({ id }) => id); + unavailable.get(clients[0])!(); + assert.equal(await session.nextAvailablePlot(ids[2]), ids[4]); + assert.equal(await session.nextAvailablePlot(ids[2], new Set(ids.slice(0, 4))), ids[1]); + unavailable.get(clients[1])!(); + assert.equal(await session.nextAvailablePlot(ids[2]), ids[3]); + }); }); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index c8577dd77..4c3d70296 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -48,9 +48,12 @@ class LayoutViewer extends HttpgdViewer { if (data.message !== 'inspect-layout') return; if (data.dragHeight !== undefined) { const handler = document.getElementById('handler'); - handler.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + const plot = document.getElementById('largePlot'); + const bounds = handler.getBoundingClientRect(); + const startY = bounds.top + bounds.height / 2; + handler.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientY: startY })); document.dispatchEvent(new MouseEvent('mousemove', { - clientY: document.body.offsetTop + data.dragHeight + 5 - window.scrollY, + clientY: startY + data.dragHeight - plot.clientHeight, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); @@ -1387,8 +1390,7 @@ suite('Standalone plot restoration', () => { assert.strictEqual(render.getCalls().filter((call) => call.args[0].id === '3').length, 1); }); - test('live httpgd dev.new shares navigation, resize, export, reload and exit cleanup', async function () { - this.timeout(30000); + async function startHttpgdR(context: Mocha.Context) { try { const probe = await promisify(execFile)('Rscript', [ '--vanilla', @@ -1396,13 +1398,11 @@ suite('Standalone plot restoration', () => { 'cat(requireNamespace("httpgd", quietly=TRUE) && requireNamespace("jsonlite", quietly=TRUE))', ]); if (probe.stdout.trim() !== 'TRUE') { - this.skip(); + context.skip(); } } catch { - this.skip(); + context.skip(); } - connect.callThrough(); - const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'httpgd-session-')); const r = spawn('R', ['--vanilla', '--slave', '--interactive']); const exited = new Promise((resolve) => r.once('exit', () => resolve())); let output = ''; @@ -1433,6 +1433,25 @@ suite('Standalone plot restoration', () => { assert.strictEqual(typeof result, 'string', line); return result as string; }; + return { + r, + evaluate, + exited, + async dispose() { + if (r.exitCode === null && r.signalCode === null) { + r.kill(); + } + await exited; + }, + }; + } + + test('live httpgd dev.new shares navigation, resize, export, reload and exit cleanup', async function () { + this.timeout(30000); + connect.callThrough(); + const process = await startHttpgdR(this); + const { r, evaluate, exited } = process; + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'httpgd-session-')); try { const source = owner('live-httpgd', String(r.pid)); source.host = os.hostname(); @@ -1486,11 +1505,23 @@ suite('Standalone plot restoration', () => { assert.ok(viewer.plots[0].data.includes('width="960.00"')); assert.ok(viewer.plots[0].data.includes('FIRST DEVICE')); panel!.dispose(); + const titles = (plots: HttpgdViewer['plots']) => + plots.map((plot) => plot.data.match(/FIRST DEVICE|SECOND DEVICE|THIRD PLOT/)?.[0]); + const originalOrder = titles(viewer.plots); + await waitForValue(() => { + const key = state.get('r.plot.httpgdConnectionsKey') as string; + const saved = JSON.parse(secrets.get(key) ?? '[]') as typeof viewer.connections; + const order = saved.flatMap((connection) => connection.plotOrder ?? []); + return JSON.stringify(order) === JSON.stringify(viewer.api.plotOrder) + ? true + : undefined; + }); await first.dispose(); const restored = manager(); await callbacks.get('r.plot.showViewers')!(); const live = restored.httpgdManager.viewers[0]; await waitForValue(() => (live.plots.length === 3 ? true : undefined)); + assert.deepStrictEqual(titles(live.plots), originalOrder); assert.strictEqual(restored.httpgdManager.viewers.length, 1); assert.strictEqual(live.connections.length, 2); const second = live.plots.find((plot) => plot.data.includes('SECOND DEVICE'))!; @@ -1522,14 +1553,93 @@ suite('Standalone plot restoration', () => { assert.strictEqual(live.api.getPlots().length, 0); retainedPanel.dispose(); } finally { - if (r.exitCode === null && r.signalCode === null) { - r.kill(); - } - await exited; + await process.dispose(); await fs.rm(directory, { recursive: true, force: true }); } }); + test('live httpgd switches to the next available device and resizes quietly after the last dev.off()', async function () { + this.timeout(30000); + connect.callThrough(); + (vscode.window.createWebviewPanel as sinon.SinonStub).callsFake( + (...args: Parameters) => createRealWebviewPanel(...args), + ); + const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + const process = await startHttpgdR(this); + const originalLayout = await executeRealCommand('vscode.getEditorLayout'); + let viewer: HttpgdViewer | undefined; + const document = await vscode.workspace.openTextDocument({ language: 'plaintext' }); + try { + await vscode.window.showTextDocument(document, { viewColumn: vscode.ViewColumn.One }); + const layout = (width: number) => + executeRealCommand('vscode.setEditorLayout', { + orientation: 0, + groups: [{ size: 1 - width }, { size: width }], + }); + await layout(0.6); + const source = owner('live-httpgd-close', String(process.r.pid)); + source.host = os.hostname(); + const value = manager(); + const first = await process.evaluate( + 'options(device=function(...) httpgd::hgd(silent=TRUE)); plot(1:5, main="FIRST DEVICE"); first_device <- dev.cur(); httpgd::hgd_url()', + ); + await value.showHttpgdPlot(first, source); + viewer = value.httpgdManager.viewers[0]; + await waitForValue(() => (viewer!.plots.length === 1 ? true : undefined)); + viewer.show(false); + await waitForValue(() => (viewer!.plots[0].width !== 800 ? true : undefined)); + const firstId = viewer.plots[0].id; + const second = await process.evaluate( + 'dev.new(); plot(5:1, main="SECOND DEVICE"); httpgd::hgd_url()', + ); + await value.showHttpgdPlot(second, source); + await waitForValue(() => (viewer!.plots.length === 2 ? true : undefined)); + assert.notStrictEqual(viewer.activePlot, firstId); + const secondId = viewer.activePlot; + const third = await process.evaluate( + 'dev.new(); plot(1:10, main="THIRD DEVICE"); httpgd::hgd_url()', + ); + await value.showHttpgdPlot(third, source); + await waitForValue(() => (viewer!.plots.length === 3 ? true : undefined)); + const thirdId = viewer.activePlot; + await process.evaluate('dev.off(first_device); "OK"'); + assert.strictEqual(viewer.activePlot, thirdId); + await process.evaluate('dev.off(); "OK"'); + await waitForValue(() => (viewer!.activePlot === secondId ? true : undefined)); + await process.evaluate('dev.off(); "OK"'); + const render = sandbox.spy(viewer.api, 'getPlotText'); + for (const width of [0.3, 0.5]) { + const before = viewer.viewWidth; + await layout(width); + await waitForValue(() => (viewer!.viewWidth !== before ? true : undefined)); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + assert.ok( + render.callCount <= 1, + 'Only the first resize may discover the closed server', + ); + sinon.assert.notCalled(errors); + assert.strictEqual(viewer.activePlot, secondId); + assert.ok( + viewer.plots.find((plot) => plot.id === secondId)!.data.includes('SECOND DEVICE'), + ); + } finally { + viewer?.webviewPanel?.dispose(); + await process.dispose(); + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if ( + tab.input instanceof vscode.TabInputText && + tab.input.uri.toString() === document.uri.toString() + ) { + await vscode.window.tabGroups.close(tab); + } + } + } + await executeRealCommand('vscode.setEditorLayout', originalLayout); + } + }); + test('standard reconnect refreshes only its existing panel without revealing or reopening it', async () => { const a = owner('a', '123'); const b = owner('b', '456'); From d33b9dfb06c9af6095eae5b5791074959ab3e43f Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:18:14 +1100 Subject: [PATCH 27/32] fix(plots): preserve history across device closure and reload - Persist httpgd SVGs, plot order, selection, and hidden plots - Preserve navigation and SVG export after devices close - Refresh annotations on previous and hidden plots - Keep restored thumbnails in session timeline order - Align JGD and httpgd viewer reopening behavior - Consolidate received SVGs into one authoritative collection - Clean up retained content and credentials on confirmed R exit - Add lifecycle regression tests and dedicated cross-platform CI - Require live R test dependencies and enable CI for master Validated with 198 extension tests, 38 Node tests, compilation, lint, formatting, and manual httpgd/JGD lifecycle checks. --- .github/workflows/main.yml | 22 +- .vscode-test.mjs | 14 +- src/plotViewer/httpgdDevices.ts | 96 ++++---- src/plotViewer/httpgdTypes.d.ts | 17 +- src/plotViewer/httpgdViewer.ts | 303 ++++++++++++++++++++----- src/plotViewer/jgdViewer.ts | 8 +- src/test/node/httpgdDevices.test.ts | 72 ++++-- src/test/node/jgdResize.test.ts | 98 +++++++- src/test/node/plotReconnect.test.ts | 9 +- src/test/suite/httpgdViewer.test.ts | 7 +- src/test/suite/plotRestoration.test.ts | 258 ++++++++++++++++++++- 11 files changed, 764 insertions(+), 140 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3caae277e..9856e6967 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,9 +3,11 @@ on: push: branches: - main + - master pull_request: branches: - main + - master workflow_call: inputs: prerelease-date: @@ -20,6 +22,8 @@ concurrency: jobs: test: + env: + VSCR_REQUIRE_PLOT_TESTS: '1' strategy: fail-fast: false matrix: @@ -46,6 +50,12 @@ jobs: run: install.packages(c("remotes", "pkgbuild", "languageserver", "lintr", "styler", "renv")) shell: Rscript {0} - run: pnpm run build + - name: Verify live plot lifecycle dependencies + run: | + packages <- c("processx", "later", "jsonlite", "jgd", "httpgd", "svglite") + stopifnot(all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) + stopifnot(packageVersion("httpgd") >= "2.0.1") + shell: Rscript {0} - name: Verify managed jgd preparation run: pnpm run test:prepare-jgd - name: Verify managed rstudioapi preparation @@ -88,14 +98,20 @@ jobs: sudo apt-get update sudo apt-get install -y tmux VSCR_TEST_TMUX=1 pnpm exec mocha out/test/node/interactiveRuntime.test.js --ui tdd --timeout 60000 --grep "^Interactive real R runtime standalone agent survives its launcher process exiting$" - - name: Run Node tests + - name: Run Node tests including live plot lifecycle tests run: pnpm run test:node + - name: Run plot lifecycle extension tests (Linux) + if: runner.os == 'Linux' + run: xvfb-run -a pnpm run test:extension --label plot-lifecycle + - name: Run plot lifecycle extension tests + if: runner.os != 'Linux' + run: pnpm run test:extension --label plot-lifecycle - name: Run extension tests (Linux) if: runner.os == 'Linux' - run: xvfb-run -a pnpm run test:extension + run: xvfb-run -a pnpm run test:extension --label provider-tests --label editor-command-tests --label interactive-integration - name: Run extension tests if: runner.os != 'Linux' - run: pnpm run test:extension + run: pnpm run test:extension --label provider-tests --label editor-command-tests --label interactive-integration - name: Preserve extension test failure logs if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.vscode-test.mjs b/.vscode-test.mjs index d138f5b57..aab9a9ce4 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -11,6 +11,11 @@ const common = { } }; +const plotSuites = [ + 'jgdPlotHistory', 'jgdSocketServer', 'standardPlotViewer', 'plotCommands', + 'plotBackend', 'plotRestoration', 'httpgdViewer', 'viewerSession', 'sessionConnectionLifecycle' +]; + // Keep mock/provider tests independent of R language-server startup. Editor // command tests also need a fresh window after fixtures create terminals and // webviews. The real Interactive suite has its own window with the LSP enabled. @@ -32,10 +37,17 @@ export default defineConfig([ { ...common, label: 'provider-tests', - files: 'out/test/suite/**/!(interactiveEditor|interactiveInputFeatures|completionMiddleware).test.js', + files: `out/test/suite/**/!(${['interactiveEditor', 'interactiveInputFeatures', 'completionMiddleware', ...plotSuites].join('|')}).test.js`, mocha: { ...common.mocha, require: './out/test/common/extensionSetup.js' }, launchArgs: testProfile('provider-tests', false) }, + { + ...common, + label: 'plot-lifecycle', + files: plotSuites.map(name => `out/test/suite/${name}.test.js`), + mocha: { ...common.mocha, require: './out/test/common/extensionSetup.js' }, + launchArgs: testProfile('plots', false) + }, { ...common, label: 'editor-command-tests', diff --git a/src/plotViewer/httpgdDevices.ts b/src/plotViewer/httpgdDevices.ts index 49fa271b0..6b278dac9 100644 --- a/src/plotViewer/httpgdDevices.ts +++ b/src/plotViewer/httpgdDevices.ts @@ -38,7 +38,6 @@ export class HttpgdDevices { private order = new Map(); private nextPosition = 0; private connected = false; - private unavailableListeners = new Set<() => void>(); private listeners = new Set< ( plots: HttpgdIdResponse[], @@ -107,9 +106,6 @@ export class HttpgdDevices { } device.available = false; device.active = false; - for (const listener of this.unavailableListeners) { - listener(); - } }); if (this.connected) { await device.client.connect(); @@ -139,8 +135,8 @@ export class HttpgdDevices { return; } device.active = snapshot.state.active; + const contentChanged = !device.available || device.upid !== snapshot.state.upid; device.available = true; - const contentChanged = device.upid !== snapshot.state.upid; device.upid = snapshot.state.upid; if (device.active) { for (const other of this.devices.values()) { @@ -152,7 +148,9 @@ export class HttpgdDevices { } } } + const previousLatest = [...device.plots.keys()].at(-1); const incoming = new Set(snapshot.plots.map((plot) => plot.id)); + let added = false; let orderChanged = false; // Reconcile only this device: other restored devices may not have // delivered their first snapshot yet. @@ -180,6 +178,7 @@ export class HttpgdDevices { id = `httpgd-${++this.nextId}`; } this.usedIds.add(id); + added = true; device.plots.set(rawId, id); const saved = this.order.has(JSON.stringify([device.origin, rawId])); restoreOrder ||= saved; @@ -193,18 +192,50 @@ export class HttpgdDevices { if (restoreOrder) { this.targets = new Map([...this.targets].sort((a, b) => a[1].position - b[1].position)); } - const restoring = device.restoring; + // The WebSocket repeats the initial HTTP snapshot. An unchanged snapshot + // or an annotation must not turn a restoration into a new-plot reveal. + const restoring = device.restoring || !added; device.restoring = false; - // Drawing updates the newest plot on this device. Historical plots are - // immutable unless explicitly rendered at a new size by the viewer. + // The previous latest plot can receive its final annotations before a + // new page appears in the same snapshot. Invalidate both boundaries. const latest = snapshot.plots.at(-1)?.id; this.notify( restoring, - new Set(!contentChanged || latest === undefined ? [] : [device.plots.get(latest)!]), + new Set( + contentChanged + ? [previousLatest, latest] + .flatMap((id) => (id === undefined ? [] : [device.plots.get(id)])) + .filter((id): id is string => id !== undefined) + : [], + ), orderChanged, ); } + /** Restore routing before connecting; retained images remain usable when a + * device no longer has a server. Aliases stay stable across reloads. */ + restorePlots(plots: Array<{ id: string; reference: HttpgdPlotReference }>): void { + for (const { id, reference } of plots) { + const device = this.devices.get(reference.origin); + if (!device || device.plots.has(reference.id) || this.usedIds.has(id)) { + continue; + } + this.usedIds.add(id); + device.plots.set(reference.id, id); + this.targets.set(id, { + device, + id: reference.id, + position: this.rememberPlot(reference), + }); + } + this.targets = new Map([...this.targets].sort((a, b) => a[1].position - b[1].position)); + } + + plotReference(id: string): HttpgdPlotReference | undefined { + const target = this.targets.get(id); + return target && { origin: target.device.origin, id: target.id }; + } + private notify( restoring: boolean, changed: ReadonlySet = new Set(), @@ -277,40 +308,8 @@ export class HttpgdDevices { return this.targets.get(id)?.device.active !== false; } - onDeviceUnavailable(listener: () => void): void { - this.unavailableListeners.add(listener); - } - - async nextAvailablePlot( - id?: string, - visible?: ReadonlySet, - ): Promise { - const current = id === undefined ? undefined : this.targets.get(id); - if (!current || current.device.available) { - return undefined; - } - const latest = new Map(); - for (const [plotId, target] of this.targets) { - if (!visible || visible.has(plotId)) { - latest.set(target.device, plotId); - } - } - const devices = [...this.devices.values()]; - const start = devices.indexOf(current.device); - for (let offset = 1; offset < devices.length; offset++) { - const device = devices[(start + offset) % devices.length]; - if (device.available && latest.has(device)) { - // An inactive device may have been closed without a socket - // notification. Verify the successor before selecting it. - try { - await device.client.refreshState(); - return latest.get(device); - } catch { - continue; - } - } - } - return undefined; + isAvailable(id: string): boolean { + return this.targets.get(id)?.device.available !== false; } getRenderers(id?: string): HttpgdRendererResponse[] { @@ -337,4 +336,15 @@ export class HttpgdDevices { const target = this.target(request.id); await target.device.client.removePlot({ id: target.id }); } + + forgetPlot(id: string): void { + const target = this.targets.get(id); + if (!target) { + return; + } + this.targets.delete(id); + target.device.plots.delete(target.id); + this.order.delete(JSON.stringify([target.device.origin, target.id])); + this.notify(true, new Set(), true); + } } diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts index 3f06d01c1..328c6392f 100644 --- a/src/plotViewer/httpgdTypes.d.ts +++ b/src/plotViewer/httpgdTypes.d.ts @@ -18,8 +18,23 @@ export interface HttpgdPlot { zoom: number; } +/** Received images persist independently of device servers; credentials remain in SecretStorage. */ +export interface HttpgdHistoryState { + source?: ViewerSessionSource; + plots: Array & { reference: HttpgdPlotReference }>; +} + +export interface HttpgdSelectionState { + activePlot?: string; + hiddenPlots: string[]; +} + // Example for possible viewer creation options: export interface HttpgdViewerOptions { + historyId?: string; + history?: HttpgdHistoryState; + selection?: HttpgdSelectionState; + connections?: Array<{ origin: string; token?: string }>; plotOrder?: HttpgdPlotReference[]; restoring?: boolean; parent: HttpgdManager; @@ -45,7 +60,7 @@ export class IHttpgdViewer { api: HttpgdDevices; // active plots - plots: HttpgdPlot[]; + readonly plots: readonly HttpgdPlot[]; // Id of the currently viewed plot activePlot?: HttpgdPlotId; diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index fe39c2b95..b08bccc77 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -3,7 +3,13 @@ import { HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from './httpgdClient import { HttpgdDevices, HttpgdPlotReference } from './httpgdDevices'; import { PlotLifecycle } from './plotLifecycle'; import { PlotViewerRegistry } from './plotViewerRegistry'; -import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; +import { + HttpgdPlot, + IHttpgdViewer, + HttpgdViewerOptions, + HttpgdHistoryState, + HttpgdSelectionState, +} from './httpgdTypes'; import * as path from 'path'; import * as fs from 'fs'; import ejs from 'ejs'; @@ -41,6 +47,7 @@ interface HttpgdConnection { url: string; source?: ViewerSessionSource; plotOrder?: HttpgdPlotReference[]; + historyId?: string; } function isPlotReference(value: unknown): value is HttpgdPlotReference { @@ -52,6 +59,42 @@ function isPlotReference(value: unknown): value is HttpgdPlotReference { ); } +function isHttpgdHistoryState(value: unknown): value is HttpgdHistoryState { + if (!value || typeof value !== 'object') { + return false; + } + const state = value as Partial; + return ( + (state.source === undefined || isViewerSessionSource(state.source)) && + Array.isArray(state.plots) && + state.plots.every( + (plot) => + plot && + typeof plot.id === 'string' && + typeof plot.data === 'string' && + Number.isFinite(plot.width) && + plot.width > 0 && + Number.isFinite(plot.height) && + plot.height > 0 && + Number.isFinite(plot.zoom) && + plot.zoom > 0 && + isPlotReference(plot.reference), + ) + ); +} + +function isHttpgdSelectionState(value: unknown): value is HttpgdSelectionState { + if (!value || typeof value !== 'object') { + return false; + } + const state = value as Partial; + return ( + (state.activePlot === undefined || typeof state.activePlot === 'string') && + Array.isArray(state.hiddenPlots) && + state.hiddenPlots.every((id) => typeof id === 'string') + ); +} + function isHttpgdConnection(value: unknown): value is HttpgdConnection { if (!value || typeof value !== 'object') { return false; @@ -83,6 +126,10 @@ export class HttpgdManager { private disposed = false; private connectionWrites: Promise = Promise.resolve(); private saveTimer?: NodeJS.Timeout; + private histories = new Map(); + private selections = new Map(); + private historyDirty = true; + private selectionDirty = true; getViewers(): HttpgdViewer[] { return this.viewers; @@ -95,6 +142,22 @@ export class HttpgdManager { } initialize(): void { + const histories = extensionContext.workspaceState.get('r.plot.httpgdHistory'); + const selections = extensionContext.workspaceState.get('r.plot.httpgdSelection'); + if (histories && typeof histories === 'object') { + for (const [id, value] of Object.entries(histories)) { + if (isHttpgdHistoryState(value)) { + this.histories.set(id, value); + } + } + } + if (selections && typeof selections === 'object') { + for (const [id, value] of Object.entries(selections)) { + if (isHttpgdSelectionState(value)) { + this.selections.set(id, value); + } + } + } this.secretKey = extensionContext.workspaceState.get('r.plot.httpgdConnectionsKey'); if (this.secretKey) { // URLs contain device tokens, so keep them in SecretStorage. @@ -163,15 +226,26 @@ export class HttpgdManager { saveConnections(): Promise { clearTimeout(this.saveTimer); this.saveTimer = undefined; + // Capture before shutdown releases the SVGs. Queued writes still filter + // confirmed process exits, including exits during a SecretStorage write. + for (const viewer of this.viewers) { + if (!viewer.isDisposed) { + this.histories.set(viewer.historyId, viewer.snapshotHistory()); + this.selections.set(viewer.historyId, viewer.snapshotSelection()); + } + } this.connectionWrites = this.connectionWrites .then(() => this.writeConnections()) - .catch((error) => - console.warn('[Plot Viewer] Could not save httpgd connections', error), - ); + .catch((error) => { + this.historyDirty = this.selectionDirty = true; + console.warn('[Plot Viewer] Could not save httpgd connections', error); + }); return this.connectionWrites; } - scheduleSaveConnections(): void { + scheduleSaveConnections(contentChanged = true): void { + this.historyDirty ||= contentChanged; + this.selectionDirty = true; if (!this.disposed && !this.saveTimer) { this.saveTimer = setTimeout(() => void this.saveConnections(), 250); } @@ -193,6 +267,30 @@ export class HttpgdManager { } } this.savedConnections = Promise.resolve([...connections.values()]); + const retained = new Set( + [...connections.values()].map((connection) => connection.historyId), + ); + for (const id of this.histories.keys()) { + if (!retained.has(id)) { + this.histories.delete(id); + this.selections.delete(id); + this.historyDirty = this.selectionDirty = true; + } + } + if (this.historyDirty) { + this.historyDirty = false; + await extensionContext.workspaceState.update( + 'r.plot.httpgdHistory', + Object.fromEntries(this.histories), + ); + } + if (this.selectionDirty) { + this.selectionDirty = false; + await extensionContext.workspaceState.update( + 'r.plot.httpgdSelection', + Object.fromEntries(this.selections), + ); + } if (!connections.size) { if (this.secretKey) { await extensionContext.secrets.delete(this.secretKey); @@ -318,6 +416,29 @@ export class HttpgdManager { this.viewerOptions.plotOrder = matching .flatMap((connection) => connection.plotOrder ?? []) .filter((plot) => plot.origin !== url.origin || sameDevice); + const historyId = matching.find( + (connection) => typeof connection.historyId === 'string', + )?.historyId; + this.viewerOptions.historyId = historyId; + const history = historyId ? this.histories.get(historyId) : undefined; + this.viewerOptions.history = history && { + ...history, + plots: history.plots.filter( + (plot) => plot.reference.origin !== url.origin || sameDevice, + ), + }; + this.viewerOptions.selection = historyId ? this.selections.get(historyId) : undefined; + this.viewerOptions.connections = matching + .map((connection) => new URL(connection.url)) + .filter((connection) => connection.origin !== url.origin) + .filter( + (connection) => + !this.viewers.some((viewer) => viewer.api.connection(connection.origin)), + ) + .map((connection) => ({ + origin: connection.origin, + token: connection.searchParams.get('token') || undefined, + })); const viewer = new HttpgdViewer(url.origin, this.viewerOptions); this.registerViewer(viewer); if (viewer.isDisposed) { @@ -361,7 +482,7 @@ interface EjsData { overwriteStyles: boolean; previewPlotLayout: PreviewPlotLayout; activePlot?: HttpgdPlotId; - plots: HttpgdPlot[]; + plots: readonly HttpgdPlot[]; largePlot: HttpgdPlot; host: string; asLocalPath: (relPath: string) => string; @@ -386,6 +507,7 @@ interface PlotRefresh { export class HttpgdViewer implements IHttpgdViewer, PlotViewer { readonly id: string; + readonly historyId: string; readonly parent: HttpgdManager; readonly host: string; readonly origin: string; @@ -406,7 +528,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { private failedPlots = new Set(); private pendingReveal = false; private retainedPlotOrder?: HttpgdPlotReference[]; - private switchingDevice = false; + private retainedPlots: HttpgdPlot[] = []; + private visiblePlots: HttpgdPlot[] = []; get isDisposed(): boolean { return this.disposed; @@ -425,6 +548,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { return { url: url.toString(), source: this.source, + historyId: this.historyId, ...(index === 0 ? { plotOrder: this.retainedPlotOrder ?? this.api.plotOrder } : {}), }; }); @@ -435,7 +559,29 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { .addDevice(origin, token, restoring) .catch((err) => this.reportRefreshError(err)); } - plots: HttpgdPlot[] = []; + + snapshotHistory(): HttpgdHistoryState { + return { + source: this.source && { ...this.source }, + plots: this.retainedPlots.flatMap((plot) => { + const reference = this.api.plotReference(plot.id); + return reference ? [{ ...plot, reference }] : []; + }), + }; + } + + snapshotSelection(): HttpgdSelectionState { + return { activePlot: this.activePlot, hiddenPlots: [...this.hiddenPlots] }; + } + get plots(): readonly HttpgdPlot[] { + return this.visiblePlots; + } + + protected setPlots(plots: HttpgdPlot[]): void { + this.retainedPlots = plots; + const hidden = new Set(this.hiddenPlots); + this.visiblePlots = plots.filter((plot) => !hidden.has(plot.id)); + } activePlot?: HttpgdPlotId; hiddenPlots: HttpgdPlotId[] = []; readonly defaultStripStyles: boolean = true; @@ -483,14 +629,32 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { // A device's port can be reused while this session still owns other // devices. Panel command identity must outlive any individual origin. this.id = `httpgd:${randomUUID()}`; + this.historyId = options.historyId ?? randomUUID(); this.token = options.token; this.restoring = options.restoring ?? false; this.parent = options.parent; this.api = new HttpgdDevices(this.origin, this.token, this.restoring, options.plotOrder); - this.api.onDeviceUnavailable(() => { - void this.focusNextAvailableDevice().catch((error) => this.reportRefreshError(error)); - }); + for (const connection of options.connections ?? []) { + void this.api.addDevice(connection.origin, connection.token, true); + } + if (options.history) { + this.api.restorePlots(options.history.plots); + const restored = new Set(this.api.getPlots().map(({ id }) => id)); + this.hiddenPlots = (options.selection?.hiddenPlots ?? []).filter((id) => + restored.has(id), + ); + const saved = new Map(options.history.plots.map((plot) => [plot.id, plot])); + this.setPlots( + this.api.getPlots().flatMap(({ id }) => { + const plot = saved.get(id); + return plot ? [{ ...plot }] : []; + }), + ); + this.activePlot = + this.plots.find((plot) => plot.id === options.selection?.activePlot)?.id ?? + this.plots.at(-1)?.id; + } this.api.onPlotsChanged((plots, restoring, changed, orderChanged) => { if (orderChanged) { this.parent.scheduleSaveConnections(); @@ -546,7 +710,14 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { return; } void this.api.connect().catch((err) => { - if (this.canRefresh && !(err instanceof Error && err.name === 'AbortError')) { + const retainedUnavailable = + this.retainedPlots.length > 0 && + ['ECONNREFUSED', 'ECONNRESET'].includes((err as NodeJS.ErrnoException)?.code ?? ''); + if ( + this.canRefresh && + !retainedUnavailable && + !(err instanceof Error && err.name === 'AbortError') + ) { void vscode.window.showErrorMessage(`httpgd connection failed: ${String(err)}`); } }); @@ -674,25 +845,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } else { this._focusPlot(); } - } - - private async focusNextAvailableDevice(): Promise { - if (!this.canRefresh || this.switchingDevice) { - return; - } - this.switchingDevice = true; - const selected = this.activePlot; - try { - const next = await this.api.nextAvailablePlot( - selected, - new Set(this.plots.map((plot) => plot.id)), - ); - if (this.canRefresh && this.activePlot === selected && next) { - await this.focusPlot(next); - } - } finally { - this.switchingDevice = false; - } + this.parent.scheduleSaveConnections(false); } protected _focusPlot(plotId?: HttpgdPlotId): void { plotId ??= this.activePlot; @@ -731,12 +884,13 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } const tmpIndex = this.activeIndex; this.hiddenPlots.push(id); - this.plots = this.plots.filter((plt) => !this.hiddenPlots.includes(plt.id)); + this.setPlots(this.retainedPlots); if (id === this.activePlot) { this.activeIndex = tmpIndex; this._focusPlot(); } this._hidePlot(id); + this.parent.scheduleSaveConnections(false); } protected _hidePlot(id: HttpgdPlotId): void { const msg: HidePlotMessage = { @@ -751,7 +905,18 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { if (id) { this.hidePlot(id); if (this.canRefresh) { - await this.api.removePlot({ id: id }); + if (!this.api.isAvailable(id)) { + this.api.forgetPlot(id); + } else { + try { + await this.api.removePlot({ id }); + } catch (error) { + if (this.api.isAvailable(id)) { + throw error; + } + this.api.forgetPlot(id); + } + } } } } @@ -863,10 +1028,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { if (!this.canRefresh || generation !== this.requestGeneration) { return; } - const index = this.getIndex(id); - if (index >= 0) { - this.plots[index] = plt; - } + this.setPlots(this.retainedPlots.map((plot) => (plot.id === id ? plt : plot))); + this.parent.scheduleSaveConnections(); this.updatePlot(plt); } @@ -967,9 +1130,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } const generation = this.requestGeneration; const hidden = new Set(this.hiddenPlots); - const existing = new Map(this.plots.map((plot) => [plot.id, plot])); - const plotIds = plotsIdResponse.map((x) => x.id).filter((id) => !hidden.has(id)); - const incoming = new Set(plotIds); + const existing = new Map(this.retainedPlots.map((plot) => [plot.id, plot])); + const incoming = new Set(plotsIdResponse.map(({ id }) => id)); + this.hiddenPlots = this.hiddenPlots.filter((id) => incoming.has(id)); for (const tracked of [this.stalePlots, this.failedPlots]) { for (const id of tracked) { if (!incoming.has(id)) { @@ -983,7 +1146,12 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.failedPlots.delete(id); } } - const refreshIds = plotIds.filter((id) => { + const refreshIds = [...incoming].filter((id) => { + // Hiding a received plot only changes visibility. Keep its drawing + // current while R can still render it, even if the device later closes. + if (hidden.has(id) && !existing.has(id)) { + return false; + } // A closed device must not be retried on every other device's update. // Retry on content changes, reactivation, reconnect or explicit refresh. if (this.failedPlots.has(id) && !this.api.isActive(id) && !force) { @@ -1024,14 +1192,16 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { }); // Preserve cached failures, omit unavailable uncached plots, and commit // successful renders from other devices without losing their updates. - const newPlots = plotIds - .map((id) => refreshed.get(id) ?? existing.get(id)) - .filter((plot): plot is HttpgdPlot => plot !== undefined); const oldPlotIds = new Set(this.plots.map((plt) => plt.id)); - const newPlotIds = new Set(newPlots.map((plot) => plot.id)); - this.plots = newPlots; + this.setPlots( + [...incoming] + .map((id) => refreshed.get(id) ?? existing.get(id)) + .filter((plot): plot is HttpgdPlot => plot !== undefined), + ); + const newPlotIds = new Set(this.plots.map((plot) => plot.id)); this.restoring = false; const added = this.plots.filter((plot) => !oldPlotIds.has(plot.id)); + this.parent.scheduleSaveConnections(); if ( (!restoring && added.length) || !this.plots.some((plot) => plot.id === this.activePlot) @@ -1047,7 +1217,11 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { ) { return; } - if (redraw || !this.webviewPanel) { + const appendedOrder = [...oldPlotIds] + .filter((id) => newPlotIds.has(id)) + .concat(added.map((plot) => plot.id)); + const reordered = appendedOrder.some((id, index) => this.plots[index]?.id !== id); + if (redraw || !this.webviewPanel || reordered) { this.refreshHtml(); } else { for (const id of oldPlotIds) { @@ -1095,7 +1269,10 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { zoom: number, cached: HttpgdPlot | null = this.plots.find((plot) => plot.id === id) ?? null, ): Promise> { - if (cached && !this.api.isActive(id) && !this.stalePlots.has(id)) { + if ( + cached && + (!this.api.isAvailable(id) || (!this.api.isActive(id) && !this.stalePlots.has(id))) + ) { // Unchanged inactive plots only need scaling. Changed content must // still be fetched; HttpgdDevices omits resize arguments for it. return { ...cached, width, height, zoom }; @@ -1113,7 +1290,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } catch (error) { if ( cached && - !this.stalePlots.has(id) && ['ECONNREFUSED', 'ECONNRESET'].includes( (error as NodeJS.ErrnoException)?.code ?? '', ) @@ -1241,8 +1417,11 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { void vscode.window.showWarningMessage('No plot available for export.'); return; } + const renderers = this.api.isAvailable(plot.id) ? [...this.api.getRenderers(id)] : []; + if (!renderers.some((renderer) => renderer.id === 'svgp')) { + renderers.push({ id: 'svgp', name: 'SVG', ext: 'svg', descr: 'Retained plot' }); + } if (!rendererId) { - const renderers = this.api.getRenderers(id); const qpItems = renderers.map((renderer) => ({ label: renderer.name, detail: renderer.descr, @@ -1258,7 +1437,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } if (!outFile) { const options: vscode.SaveDialogOptions = {}; - const renderer = this.api.getRenderers(id).find((r) => r.id === rendererId); + const renderer = renderers.find((r) => r.id === rendererId); const ext = renderer?.ext.replace(/^\./, ''); if (this.lastExportUri) { const noExtPath = this.lastExportUri.fsPath.replace(/\.[^.]*$/, ''); @@ -1283,7 +1462,25 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } } try { - const bytes = await this.api.getPlotBytes({ id, renderer: rendererId }); + const svg = rendererId === 'svgp' || rendererId === 'svg'; + let bytes: Buffer; + if (svg && !this.api.isAvailable(plot.id)) { + bytes = Buffer.from(plot.data, 'utf8'); + } else { + try { + bytes = await this.api.getPlotBytes({ id, renderer: rendererId }); + } catch (error) { + if ( + !svg || + !['ECONNREFUSED', 'ECONNRESET'].includes( + (error as NodeJS.ErrnoException)?.code ?? '', + ) + ) { + throw error; + } + bytes = Buffer.from(plot.data, 'utf8'); + } + } await fs.promises.writeFile(outFile, bytes); void vscode.window.showInformationMessage(`Export done: ${outFile}`); } catch (err) { @@ -1313,7 +1510,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } private releasePlots(): void { - this.plots = []; + this.setPlots([]); this.hiddenPlots = []; this.activePlot = undefined; this.stalePlots.clear(); diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index efc0e9e21..8fa8c93e1 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -111,6 +111,7 @@ export class JgdManager { private historyChangeDisposable: { dispose(): void } | null = null; private sessionConnectionDisposable: vscode.Disposable | undefined; private started = false; + private receivedPlots = new WeakSet(); constructor(lifecycle?: PlotLifecycle) { const maxPlots = config().get('plot.jgd.historyLimit', 50); @@ -182,9 +183,14 @@ export class JgdManager { 'restoring' in metadata && metadata.restoring === true, ); + const latest = this.history.latestPlot(sessionId)!; + const newPlot = !this.receivedPlots.has(latest); + this.receivedPlots.add(latest); + // Receiving more drawing for an existing plot updates retained + // content without reopening a panel the user closed. this.getOrCreateViewer(sessionId).showPlot( current, - !restoring && !message.resizeReplay, + newPlot && !restoring && !message.resizeReplay, ); } }); diff --git a/src/test/node/httpgdDevices.test.ts b/src/test/node/httpgdDevices.test.ts index 793565a37..377a7454f 100644 --- a/src/test/node/httpgdDevices.test.ts +++ b/src/test/node/httpgdDevices.test.ts @@ -183,42 +183,68 @@ suite('httpgd session history', () => { assert.deepEqual(changes, [true, false, false, true]); }); - test('unavailable devices yield to the next visible live plot and recover on a fresh snapshot', async () => { + test('unavailable devices retain routing and history until explicit removal', async () => { publish(0, ['1']); await session.addDevice('http://localhost:1235', 'second'); publish(1, ['1']); publish(0, ['1', '2']); const ids = session.getPlots().map(({ id }) => id); - const changed = sandbox.spy(); - session.onDeviceUnavailable(changed); unavailable.get(clients[0])!(); assert.equal(session.isActive(ids[2]), false); - assert.equal(await session.nextAvailablePlot(ids[2]), ids[1]); - assert.equal(await session.nextAvailablePlot(ids[0], new Set([ids[0], ids[2]])), undefined); - unavailable.get(clients[0])!(); - sinon.assert.calledOnce(changed); + assert.equal(session.isAvailable(ids[2]), false); + assert.deepEqual( + session.getPlots().map(({ id }) => id), + ids, + ); publish(0, ['1', '2']); - assert.equal(session.isActive(ids[2]), true); - assert.equal(await session.nextAvailablePlot(ids[2]), undefined); - unavailable.get(clients[0])!(); + assert.equal(session.isAvailable(ids[2]), true); + session.forgetPlot(ids[0]); + assert.deepEqual( + session.getPlots().map(({ id }) => id), + ids.slice(1), + ); + }); + + test('restores aliases for unavailable devices without confusing colliding raw IDs', async () => { + session.disconnect(); + session = new HttpgdDevices('http://localhost:1234', 'first', true); + await session.addDevice('http://localhost:1235', 'second', true); + const saved = [ + { id: 'first-plot', reference: { origin: 'http://localhost:1234', id: '1' } }, + { id: 'second-plot', reference: { origin: 'http://localhost:1235', id: '1' } }, + ]; + session.restorePlots(saved); + await session.connect(); unavailable.get(clients[1])!(); - assert.equal(await session.nextAvailablePlot(ids[2]), undefined); - assert.equal(session.getPlots().length, 3, 'retain rendered history'); + publish(2, ['1']); + assert.deepEqual( + session.getPlots(), + saved.map(({ id }) => ({ id })), + ); + assert.deepEqual(session.plotReference('first-plot'), saved[0].reference); + assert.equal(session.isAvailable('first-plot'), false); }); - test('chooses the next server in device order despite interleaved plots', async () => { + test('invalidates the previously latest plot when drawing and a new page share a snapshot', () => { + const changes: ReadonlySet[] = []; + session.onPlotsChanged((_plots, _restoring, changed) => changes.push(changed)); publish(0, ['1']); - await session.addDevice('http://localhost:1235', 'second'); + updates.get(clients[0])!({ + state: { upid: 2, hsize: 2, active: true }, + plots: [{ id: '1' }, { id: '2' }], + }); + assert.deepEqual([...changes.at(-1)!], ['1', '2']); + }); + + test('duplicate startup snapshots and annotations do not request a new-plot reveal', async () => { + session.disconnect(); + session = new HttpgdDevices('http://localhost:1234', 'first', true); + const restoring: boolean[] = []; + session.onPlotsChanged((_plots, value) => restoring.push(value)); + await session.connect(); + publish(1, ['1']); publish(1, ['1']); - publish(0, ['1', '2']); - await session.addDevice('http://localhost:1236', 'third'); - publish(2, ['1']); publish(1, ['1', '2']); - const ids = session.getPlots().map(({ id }) => id); - unavailable.get(clients[0])!(); - assert.equal(await session.nextAvailablePlot(ids[2]), ids[4]); - assert.equal(await session.nextAvailablePlot(ids[2], new Set(ids.slice(0, 4))), ids[1]); - unavailable.get(clients[1])!(); - assert.equal(await session.nextAvailablePlot(ids[2]), ids[3]); + assert.deepEqual(restoring, [true, true, false]); }); }); diff --git a/src/test/node/jgdResize.test.ts b/src/test/node/jgdResize.test.ts index 0533ea137..1493250c8 100644 --- a/src/test/node/jgdResize.test.ts +++ b/src/test/node/jgdResize.test.ts @@ -25,9 +25,12 @@ suite('Live JGD resize', () => { 'cat(requireNamespace("jgd", quietly=TRUE))', ]); if (probe.stdout.trim() !== 'TRUE') { - this.skip(); + throw new Error('Live JGD tests require the jgd R package'); + } + } catch (error) { + if (process.env.VSCR_REQUIRE_PLOT_TESTS === '1') { + throw error; } - } catch { this.skip(); } const history = new PlotHistory(); @@ -97,4 +100,95 @@ suite('Live JGD resize', () => { await exited; } }); + test('closed devices retain interleaved history and selection across reload', async function () { + try { + const probe = await run('Rscript', [ + '--vanilla', + '-e', + 'cat(requireNamespace("jgd", quietly=TRUE))', + ]); + if (probe.stdout.trim() !== 'TRUE') { + throw new Error('The jgd R package is required for live plot lifecycle tests'); + } + } catch (error) { + if (process.env.VSCR_REQUIRE_PLOT_TESTS === '1') { + throw error; + } + this.skip(); + } + const history = new PlotHistory(); + const server = new JgdSocketServer(history); + server.setMeasureText((request) => + Promise.resolve({ + type: 'metrics_response', + id: request.id, + width: 8, + ascent: 8, + descent: 2, + }), + ); + await new Promise((resolve) => { + server.onReady(resolve); + server.start(); + }); + const r = spawn('R', ['--vanilla', '--slave', '--interactive'], { + env: { ...process.env, JGD_SOCKET: server.getSocketPath() }, + }); + let output = ''; + r.stdout.on('data', (data: Buffer) => { + output += data.toString(); + }); + r.stderr.resume(); + const exited = new Promise((resolve) => r.once('exit', () => resolve())); + const source = { + sessionId: 'retained-jgd', + host: 'localhost', + pid: String(r.pid), + rVer: '4', + processExited: false, + }; + server.setOnFrame((id) => history.setSessionSource(id, source)); + try { + r.stdin.write('jgd::jgd(); plot(1:3, main="FIRST"); a<-dev.cur()\n'); + await until( + () => + history.count() === 1 && + JSON.stringify(history.currentPlot()?.ops).includes('FIRST'), + ); + r.stdin.write( + 'text(2,2,"ANNOTATION"); plot(4:6, main="SECOND"); jgd::jgd(); plot(7:9, main="OTHER DEVICE")\n', + ); + await until( + () => + history.count() === 3 && + JSON.stringify(history.currentPlot()?.ops).includes('OTHER DEVICE'), + ); + const id = history.getActiveSessionId(); + history.navigateFirst(id); + assert.ok(JSON.stringify(history.currentPlot(id)?.ops).includes('ANNOTATION')); + r.stdin.write('graphics.off(); cat("DEVICES_CLOSED\\n")\n'); + await until( + () => + output.includes('DEVICES_CLOSED') && + history.sessionIds().every((device) => !server.isConnected(device)), + ); + assert.equal(history.count(id), 3); + const state = history.snapshot(); + server.stop(); + const restored = new PlotHistory(); + restored.restore(state); + assert.equal(restored.count(id), 3); + assert.equal(restored.currentIndex(id), 1); + assert.ok(JSON.stringify(restored.currentPlot(id)?.ops).includes('ANNOTATION')); + assert.ok(JSON.stringify(restored.navigateNext(id)?.ops).includes('SECOND')); + assert.ok(JSON.stringify(restored.navigateNext(id)?.ops).includes('OTHER DEVICE')); + assert.ok(JSON.stringify(restored.navigateFirst(id)?.ops).includes('FIRST')); + restored.removeSession(source); + assert.deepEqual(restored.sessionIds(), []); + } finally { + r.kill(); + server.stop(); + await exited; + } + }); }); diff --git a/src/test/node/plotReconnect.test.ts b/src/test/node/plotReconnect.test.ts index fd9a3dced..3ba56190e 100644 --- a/src/test/node/plotReconnect.test.ts +++ b/src/test/node/plotReconnect.test.ts @@ -34,9 +34,14 @@ suite('Live R plot reconnect', function () { 'cat(all(vapply(c("processx", "later", "jsonlite", "jgd", "httpgd", "svglite"), requireNamespace, logical(1), quietly=TRUE)))', ]); if (probe.stdout.trim() !== 'TRUE') { - this.skip(); + throw new Error( + 'Live plot tests require processx, later, jsonlite, jgd, httpgd and svglite', + ); + } + } catch (error) { + if (process.env.VSCR_REQUIRE_PLOT_TESTS === '1') { + throw error; } - } catch { this.skip(); } directory = await fs.mkdtemp(path.join(os.tmpdir(), 'plot-reconnect-')); diff --git a/src/test/suite/httpgdViewer.test.ts b/src/test/suite/httpgdViewer.test.ts index cfb339644..edd02fb61 100644 --- a/src/test/suite/httpgdViewer.test.ts +++ b/src/test/suite/httpgdViewer.test.ts @@ -4,6 +4,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { HttpgdClient } from '../../plotViewer/httpgdClient'; import { HttpgdManager, HttpgdViewer } from '../../plotViewer/httpgdViewer'; +import type { HttpgdPlot } from '../../plotViewer/httpgdTypes'; import { mockExtensionContext } from '../common/mockvscode'; import * as util from '../../util'; import * as session from '../../session'; @@ -26,6 +27,10 @@ class LifecycleViewer extends HttpgdViewer { } class TestViewer extends HttpgdViewer { + seedPlots(plots: HttpgdPlot[]): void { + this.setPlots(plots); + } + renderCount = 0; resize(height: number, width: number): void { this.handleResize(height, width); @@ -67,7 +72,7 @@ suite('httpgd viewer requests', () => { test('focusing a correctly sized non-square plot does not request another render', async () => { const viewer = new TestViewer('localhost:1234', manager.viewerOptions); manager.registerViewer(viewer); - viewer.plots = [{ id: '1', data: '', width: 800, height: 600, zoom: 1 }]; + viewer.seedPlots([{ id: '1', data: '', width: 800, height: 600, zoom: 1 }]); await viewer.focusPlot('1'); assert.strictEqual(viewer.renderCount, 0); viewer.viewWidth = 900; diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 4c3d70296..559eb9d3c 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -14,6 +14,7 @@ import type { JgdMessage } from '../../plotViewer/jgdSocketServer'; import type { PlotFrame, PlotHistoryState } from '../../plotViewer/jgdPlotHistory'; import { HttpgdClient } from '../../plotViewer/httpgdClient'; import { HttpgdViewer } from '../../plotViewer/httpgdViewer'; +import type { HttpgdPlot } from '../../plotViewer/httpgdTypes'; import { extensionContext } from '../../extension'; import * as session from '../../session'; import * as util from '../../util'; @@ -26,6 +27,10 @@ const createRealWebviewPanel = vscode.window.createWebviewPanel; const executeRealCommand = vscode.commands.executeCommand; class RefreshViewer extends HttpgdViewer { + seedPlots(plots: HttpgdPlot[]): void { + this.setPlots(plots); + } + receivePlots(ids: string[], changed?: ReadonlySet, force = false): Promise { return this.refreshPlots( ids.map((id) => ({ id })), @@ -316,7 +321,9 @@ suite('Standalone plot restoration', () => { await value.showStandardPlot(source); await value.showHttpgdPlot('http://localhost:1234', source); const httpgd = value.httpgdManager.viewers[0]; - httpgd.plots = [{ id: '1', data: '', width: 800, height: 600, zoom: 1 }]; + RefreshViewer.prototype.seedPlots.call(httpgd, [ + { id: '1', data: '', width: 800, height: 600, zoom: 1 }, + ]); httpgd.activePlot = '1'; value.jgdManager.history.addPlot('device', frame('device')); value.jgdManager.history.setSessionSource('device', source); @@ -1398,9 +1405,12 @@ suite('Standalone plot restoration', () => { 'cat(requireNamespace("httpgd", quietly=TRUE) && requireNamespace("jsonlite", quietly=TRUE))', ]); if (probe.stdout.trim() !== 'TRUE') { - context.skip(); + throw new Error('Live httpgd tests require httpgd and jsonlite'); + } + } catch (error) { + if (process.env.VSCR_REQUIRE_PLOT_TESTS === '1') { + throw error; } - } catch { context.skip(); } const r = spawn('R', ['--vanilla', '--slave', '--interactive']); @@ -1558,7 +1568,7 @@ suite('Standalone plot restoration', () => { } }); - test('live httpgd switches to the next available device and resizes quietly after the last dev.off()', async function () { + test('live httpgd retains selection and navigation after every device closes', async function () { this.timeout(30000); connect.callThrough(); (vscode.window.createWebviewPanel as sinon.SinonStub).callsFake( @@ -1605,7 +1615,11 @@ suite('Standalone plot restoration', () => { await process.evaluate('dev.off(first_device); "OK"'); assert.strictEqual(viewer.activePlot, thirdId); await process.evaluate('dev.off(); "OK"'); - await waitForValue(() => (viewer!.activePlot === secondId ? true : undefined)); + assert.strictEqual( + viewer.activePlot, + thirdId, + 'closing a device preserves its rendered plot', + ); await process.evaluate('dev.off(); "OK"'); const render = sandbox.spy(viewer.api, 'getPlotText'); for (const width of [0.3, 0.5]) { @@ -1619,10 +1633,16 @@ suite('Standalone plot restoration', () => { 'Only the first resize may discover the closed server', ); sinon.assert.notCalled(errors); - assert.strictEqual(viewer.activePlot, secondId); + assert.strictEqual(viewer.activePlot, thirdId); + await viewer.focusPlot(firstId); + assert.ok( + viewer.plots.find((plot) => plot.id === firstId)!.data.includes('FIRST DEVICE'), + ); + await viewer.focusPlot(secondId); assert.ok( viewer.plots.find((plot) => plot.id === secondId)!.data.includes('SECOND DEVICE'), ); + assert.strictEqual(viewer.plots.length, 3); } finally { viewer?.webviewPanel?.dispose(); await process.dispose(); @@ -1891,9 +1911,9 @@ suite('Standalone plot restoration', () => { sandbox.stub(session, 'resolvePlotSession').returns(source); const first = manager(); await first.showHttpgdPlot('http://localhost:1234/live?token=secret', source); - first.httpgdManager.viewers[0].plots = [ + RefreshViewer.prototype.seedPlots.call(first.httpgdManager.viewers[0], [ { id: '1', data: 'not persisted', width: 400, height: 300, zoom: 1 }, - ]; + ]); await first.dispose(); assert.ok(!JSON.stringify([...state.values()]).includes('secret')); assert.ok(!JSON.stringify([...secrets.values()]).includes('')); @@ -1906,7 +1926,9 @@ suite('Standalone plot restoration', () => { const viewer = restored.httpgdManager.viewers[0]; assert.strictEqual(viewer.token, 'secret'); assert.deepStrictEqual(viewer.plots, []); - viewer.plots = [{ id: 'live', data: '', width: 1, height: 1, zoom: 1 }]; + RefreshViewer.prototype.seedPlots.call(viewer, [ + { id: 'live', data: '', width: 1, height: 1, zoom: 1 }, + ]); const render = sandbox.stub(viewer.api, 'getPlotText').resolves('fresh'); sandbox.stub(viewer.api, 'getPlots').returns([{ id: 'live' }]); await viewer.focusPlot('live'); @@ -1957,7 +1979,9 @@ suite('Standalone plot restoration', () => { value.httpgdManager.registerViewer(viewer); const svg = (width: number, height: number) => ``; - viewer.plots = [{ id: '1', data: svg(800, 400), width: 800, height: 400, zoom: 1 }]; + RefreshViewer.prototype.seedPlots.call(viewer, [ + { id: '1', data: svg(800, 400), width: 800, height: 400, zoom: 1 }, + ]); viewer.activePlot = '1'; const render = sandbox .stub(viewer.api, 'getPlotText') @@ -2392,4 +2416,218 @@ suite('Standalone plot restoration', () => { sinon.assert.notCalled(connect); assert.strictEqual(panels.length, 0); }); + for (const backend of ['jgd', 'httpgd'] as const) { + test(`${backend} updates a closed viewer's retained plot without reopening until a new plot`, async () => { + const source = owner('retained', '321'); + const value = manager(); + if (backend === 'jgd') { + const deliver = startJgd(value); + const history = value.jgdManager.history; + history.addPlot('device', frame('device')); + history.setSessionSource('device', source); + deliver('device', { type: 'frame', newPage: true }); + panels[0].panel.dispose(); + history.appendOps('device', { + ...frame('device'), + ops: [{ op: 'text', str: 'annotation' }], + }); + deliver('device', { type: 'frame', incremental: true }); + assert.strictEqual(panels.length, 1); + assert.ok(JSON.stringify(history.latestPlot('device')?.ops).includes('annotation')); + history.addPlot('device', frame('device', 'new')); + deliver('device', { type: 'frame', newPage: true }); + } else { + const viewer = new RefreshViewer('http://localhost:1234', { + ...value.httpgdManager.viewerOptions, + source, + }); + value.httpgdManager.registerViewer(viewer); + const render = sandbox.stub(viewer.api, 'getPlotText').resolves('first'); + await viewer.receivePlots(['a'], new Set(['a'])); + panels[0].panel.dispose(); + render.resolves('annotation'); + await viewer.receivePlots(['a'], new Set(['a'])); + assert.strictEqual(panels.length, 1); + assert.ok(viewer.plots[0].data.includes('annotation')); + await viewer.receivePlots(['a', 'b'], new Set(['a', 'b'])); + } + assert.strictEqual(panels.length, 2, 'a new plot opens exactly one panel'); + }); + } + + test('live httpgd retains closed-device content, selection and hidden plots across reload', async function () { + this.timeout(30000); + connect.callThrough(); + const process = await startHttpgdR(this); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'retained-httpgd-')); + const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + try { + const source = owner('retained-httpgd', String(process.r.pid)); + source.host = os.hostname(); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); + const first = manager(); + const url = await process.evaluate( + 'httpgd::hgd(silent=TRUE); plot(1:3, main="FIRST"); httpgd::hgd_url()', + ); + await first.showHttpgdPlot(url, source); + const viewer = first.httpgdManager.viewers[0]; + await waitForValue(() => (viewer.plots.length === 1 ? true : undefined)); + await process.evaluate( + 'text(2, 2, "FINAL ANNOTATION"); plot(4:6, main="SECOND"); "OK"', + ); + await waitForValue(() => (viewer.plots.length === 2 ? true : undefined)); + assert.ok(viewer.plots[0].data.includes('FINAL ANNOTATION')); + const other = await process.evaluate( + 'httpgd::hgd(silent=TRUE); plot(7:9, main="OTHER DEVICE"); httpgd::hgd_url()', + ); + await first.showHttpgdPlot(other, source); + await waitForValue(() => (viewer.plots.length === 3 ? true : undefined)); + const [firstId, secondId, otherId] = viewer.plots.map((plot) => plot.id); + await viewer.focusPlot(firstId); + viewer.hidePlot(secondId); + await process.evaluate('graphics.off(); "OK"'); + assert.strictEqual(viewer.activePlot, firstId); + await first.dispose(); + assert.ok(!JSON.stringify([...secrets.values()]).includes(' plot.id), + [firstId, otherId], + ); + const fetch = sandbox.spy(live.api, 'getPlotText'); + live.viewWidth = 900; + await live.focusPlot(otherId); + await live.focusPlot(firstId); + const output = path.join(directory, 'retained.svg'); + await live.exportPlot(firstId, 'svgp', output); + assert.ok((await fs.readFile(output, 'utf8')).includes('FINAL ANNOTATION')); + live.resetPlots(); + await waitForValue(() => (live.plots.length === 3 ? true : undefined)); + assert.deepStrictEqual( + live.plots.map((plot) => plot.id), + [firstId, secondId, otherId], + ); + sinon.assert.notCalled(fetch); + sinon.assert.notCalled(errors); + // Confirmed R exit still removes all persisted content and credentials. + await process.dispose(); + await waitForValue(() => (live.isDisposed ? true : undefined)); + await restored.httpgdManager.saveConnections(); + assert.deepStrictEqual(state.get('r.plot.httpgdHistory'), {}); + assert.deepStrictEqual(state.get('r.plot.httpgdSelection'), {}); + assert.strictEqual(secrets.size, 0); + } finally { + await process.dispose(); + await fs.rm(directory, { recursive: true, force: true }); + } + }); + + test('httpgd navigation checkpoints cursors without rewriting retained SVGs', async () => { + const clock = sandbox.useFakeTimers(); + const source = owner('retained', '321'); + const value = manager(); + await value.showHttpgdPlot('http://localhost:1234', source); + const viewer = value.httpgdManager.viewers[0]; + viewer.api.restorePlots( + ['a', 'b'].map((id) => ({ id, reference: { origin: viewer.origin, id } })), + ); + sandbox.stub(viewer.api, 'getPlotText').resolves('retained'); + await RefreshViewer.prototype.receivePlots.call(viewer, ['a', 'b'], new Set(['a', 'b'])); + await value.httpgdManager.saveConnections(); + const update = ( + extensionContext.workspaceState as unknown as { + update: sinon.SinonStub<[string, unknown], Promise>; + } + ).update; + update.resetHistory(); + await viewer.prevPlot(); + await clock.tickAsync(250); + const keys = update.getCalls().map((call) => call.args[0]); + assert.ok(keys.includes('r.plot.httpgdSelection')); + assert.ok(!keys.includes('r.plot.httpgdHistory')); + }); + + test('httpgd exit during a retained-content write removes its images and selection', async () => { + const exits = processExits(); + const source = owner('writing-history', '321'); + const value = manager(); + await value.showHttpgdPlot('http://localhost:1234', source); + const viewer = value.httpgdManager.viewers[0]; + viewer.api.restorePlots([{ id: 'a', reference: { origin: viewer.origin, id: 'a' } }]); + sandbox.stub(viewer.api, 'getPlotText').resolves('retained'); + await RefreshViewer.prototype.receivePlots.call(viewer, ['a'], new Set(['a'])); + const started = deferred(); + const finish = deferred(); + let writes = 0; + (extensionContext.workspaceState.update as sinon.SinonStub).callsFake( + async (key: string, data: unknown) => { + if (key === 'r.plot.httpgdHistory' && ++writes === 1) { + started.resolve(); + await finish.promise; + } + state.set(key, structuredClone(data)); + }, + ); + const saving = value.httpgdManager.saveConnections(); + await started.promise; + exits.exit(source); + finish.resolve(); + await saving; + await value.httpgdManager.saveConnections(); + assert.deepStrictEqual(state.get('r.plot.httpgdHistory'), {}); + assert.deepStrictEqual(state.get('r.plot.httpgdSelection'), {}); + assert.strictEqual(secrets.size, 0); + }); + + test('httpgd redraws thumbnails in timeline order when restored devices arrive out of order', async () => { + const value = manager(); + const viewer = new RefreshViewer( + 'http://localhost:1234', + value.httpgdManager.viewerOptions, + ); + value.httpgdManager.registerViewer(viewer); + sandbox + .stub(viewer.api, 'getPlotText') + .callsFake(({ id }) => Promise.resolve(`${id}`)); + await viewer.receivePlots(['a', 'c']); + await viewer.receivePlots(['a', 'b', 'c']); + assert.deepStrictEqual( + viewer.plots.map(({ id }) => id), + ['a', 'b', 'c'], + ); + const html = viewer.webviewPanel!.webview.html; + assert.ok(html.indexOf('a') < html.indexOf('b')); + assert.ok(html.indexOf('b') < html.lastIndexOf('c')); + }); + + test('httpgd retains annotations received while a plot is hidden before its device closes', async () => { + const value = manager(); + const viewer = new RefreshViewer( + 'http://localhost:1234', + value.httpgdManager.viewerOptions, + ); + value.httpgdManager.registerViewer(viewer); + viewer.api.restorePlots([{ id: 'a', reference: { origin: viewer.origin, id: 'a' } }]); + const render = sandbox.stub(viewer.api, 'getPlotText').resolves('first'); + await viewer.receivePlots(['a'], new Set(['a'])); + viewer.hidePlot('a'); + render.resolves('annotation'); + await viewer.receivePlots(['a'], new Set(['a'])); + assert.strictEqual(viewer.plots.length, 0); + assert.strictEqual(viewer.snapshotHistory().plots[0].data, 'annotation'); + sandbox.stub(viewer.api, 'isAvailable').returns(false); + render.resetHistory(); + viewer.resetPlots(); + await waitForValue(() => (viewer.plots.length === 1 ? true : undefined)); + assert.strictEqual(viewer.plots[0].data, 'annotation'); + sinon.assert.notCalled(render); + }); }); From 7c15ebcc4c9663b87dddea6bf23545f07fdca825 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:01:42 +1100 Subject: [PATCH 28/32] fix(plots): apply deferred httpgd resizes when devices reactivate --- src/plotViewer/httpgdTypes.d.ts | 3 ++ src/plotViewer/httpgdViewer.ts | 35 ++++++++++---- src/test/suite/plotRestoration.test.ts | 64 +++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts index 328c6392f..427fabb72 100644 --- a/src/plotViewer/httpgdTypes.d.ts +++ b/src/plotViewer/httpgdTypes.d.ts @@ -16,6 +16,9 @@ export interface HttpgdPlot { height: number; width: number; zoom: number; + + // The viewport size above was applied by scaling; replay it when the device is active. + resizeDeferred?: boolean; } /** Received images persist independently of device servers; credentials remain in SecretStorage. */ diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index b08bccc77..e9b315303 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -839,7 +839,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.canRefresh && (plt.height !== this.viewHeight || plt.width !== this.viewWidth || - plt.zoom !== this.zoom) + plt.zoom !== this.zoom || + (plt.resizeDeferred && this.api.isActive(plt.id) && this.api.isAvailable(plt.id))) ) { await this.refreshPlots(this.api.getPlots()); } else { @@ -1159,7 +1160,12 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } const plot = existing.get(id); return ( - force || !plot || this.stalePlots.has(id) || (!changed && id === this.activePlot) + force || + !plot || + this.stalePlots.has(id) || + (id === this.activePlot && + (!changed || + (plot.resizeDeferred && this.api.isActive(id) && this.api.isAvailable(id)))) ); }); const results = await Promise.allSettled( @@ -1269,13 +1275,22 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { zoom: number, cached: HttpgdPlot | null = this.plots.find((plot) => plot.id === id) ?? null, ): Promise> { - if ( - cached && - (!this.api.isAvailable(id) || (!this.api.isActive(id) && !this.stalePlots.has(id))) - ) { + const active = this.api.isActive(id); + const scaled = cached && { + ...cached, + width, + height, + zoom, + resizeDeferred: + cached.resizeDeferred || + cached.width !== width || + cached.height !== height || + cached.zoom !== zoom, + }; + if (scaled && (!this.api.isAvailable(id) || (!active && !this.stalePlots.has(id)))) { // Unchanged inactive plots only need scaling. Changed content must // still be fetched; HttpgdDevices omits resize arguments for it. - return { ...cached, width, height, zoom }; + return scaled; } const args = { id: id, @@ -1289,12 +1304,12 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { svg = await this.api.getPlotText(args); } catch (error) { if ( - cached && + scaled && ['ECONNREFUSED', 'ECONNRESET'].includes( (error as NodeJS.ErrnoException)?.code ?? '', ) ) { - return { ...cached, width, height, zoom }; + return scaled; } throw error; } @@ -1304,6 +1319,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { height: height, width: width, zoom: zoom, + // Inactive-device requests omit geometry even when fetching changed content. + resizeDeferred: !active, }; return plt; } diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 559eb9d3c..fbde91925 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -1290,14 +1290,14 @@ suite('Standalone plot restoration', () => { sinon.assert.calledOnceWithExactly(replacementCommand, 'hidePlot', 'replacement-plot'); }); - test('httpgd refreshes changed inactive plots but reuses unchanged geometry', async () => { + test('httpgd reuses inactive geometry and applies its latest size once reactivated', async () => { const value = manager(); const viewer = new RefreshViewer( 'http://localhost:1234', value.httpgdManager.viewerOptions, ); value.httpgdManager.registerViewer(viewer); - sandbox.stub(viewer.api, 'isActive').returns(false); + const active = sandbox.stub(viewer.api, 'isActive').returns(false); const render = sandbox.stub(viewer.api, 'getPlotText').resolves('original'); await viewer.receivePlots(['a'], new Set(['a'])); render.resolves('annotation'); @@ -1309,6 +1309,57 @@ suite('Standalone plot restoration', () => { await viewer.receivePlots(['a']); assert.strictEqual(viewer.plots[0].width, 900); sinon.assert.calledTwice(render); + + viewer.viewWidth = 1000; + viewer.viewHeight = 700; + viewer.zoom = 1.2; + await viewer.receivePlots(['a']); + sinon.assert.calledTwice(render); + + // dev.set() changes activity without changing the drawing's update ID. + active.returns(true); + await viewer.receivePlots(['a'], new Set()); + sinon.assert.calledThrice(render); + assert.deepStrictEqual(render.lastCall.args, [ + { id: 'a', width: 1000, height: 700, zoom: 1.2, renderer: 'svgp' }, + ]); + await viewer.receivePlots(['a'], new Set()); + sinon.assert.calledThrice(render); + active.returns(false); + await viewer.receivePlots(['a']); + active.returns(true); + await viewer.receivePlots(['a'], new Set()); + sinon.assert.calledThrice(render); + }); + + test('httpgd defers background plot resizing until selected on its active device', async () => { + const value = manager(); + const viewer = new RefreshViewer( + 'http://localhost:1234', + value.httpgdManager.viewerOptions, + ); + value.httpgdManager.registerViewer(viewer); + const active = sandbox.stub(viewer.api, 'isActive').returns(false); + sandbox.stub(viewer.api, 'getPlots').returns([{ id: 'a' }, { id: 'b' }]); + const render = sandbox.stub(viewer.api, 'getPlotText').resolves('retained'); + await viewer.receivePlots(['a', 'b'], new Set(['a', 'b'])); + viewer.viewWidth = 1000; + await viewer.focusPlot('a'); + await viewer.focusPlot('b'); + render.resetHistory(); + + active.returns(true); + await viewer.receivePlots(['a', 'b'], new Set()); + sinon.assert.calledOnce(render); + assert.strictEqual(render.firstCall.args[0].id, 'b'); + // Both cache entries already have the current viewport dimensions. + // Focusing the other plot must still replay its deferred geometry. + await viewer.focusPlot('a'); + sinon.assert.calledTwice(render); + assert.strictEqual(render.lastCall.args[0].id, 'a'); + await viewer.focusPlot('a'); + await viewer.receivePlots(['a', 'b'], new Set()); + sinon.assert.calledTwice(render); }); test('httpgd commits healthy devices when a closed device has an uncached plot', async () => { @@ -1504,6 +1555,15 @@ suite('Standalone plot restoration', () => { await viewer.focusPlot(); assert.strictEqual(viewer.plots[0].width, 920); assert.ok(viewer.plots[0].data.includes('FIRST DEVICE')); + assert.ok(!viewer.plots[0].data.includes('width="920.00"')); + // Reactivation alone must apply the deferred size, without another + // resize event, new plot, or annotation to invalidate the drawing. + await evaluate('dev.set(first_device); "OK"'); + await waitForValue(() => + viewer.plots[0].data.includes('width="920.00"') ? true : undefined, + ); + assert.ok(viewer.plots[0].data.includes('height="680.00"')); + assert.ok(viewer.plots[0].data.includes('FIRST DEVICE')); const file = path.join(directory, 'first.svg'); await viewer.exportPlot(viewer.activePlot, 'svgp', file); assert.ok((await fs.readFile(file, 'utf8')).includes('FIRST DEVICE')); From 997a4bcaabf8f011f6460ed257ce65bca68ba672 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:27:12 +1100 Subject: [PATCH 29/32] fix(test): resolve R lint and session attachment test failures --- R/tests/attach_sess.R | 2 +- sess/R/hooks.R | 3 ++- sess/R/runtime.R | 8 ++++++-- sess/inst/tinytest/test-httpgd-devices.R | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index 61f33bd6b..ea04e4e37 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -238,7 +238,7 @@ writeLines(c( " sys.source(file.path(getwd(), 'sess', 'R', 'server.R'), scheduler)", " scheduler$.read_discovery <- function(path) list(endpoint='endpoint-b')", " scheduler$.configure_discovery_jgd <- function(discovery, enabled) NULL", - " scheduler$runtime_stop <- function() invisible(NULL)", + " scheduler$runtime_stop <- function(preserve_plots=FALSE) invisible(NULL)", " scheduler$connect <- function(endpoint, ...) {", " connect_count <<- connect_count + 1L", " observed[[connect_count]] <<- readLines(reconnect_file, warn=FALSE)", diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 8f1ee2613..89bea4d29 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -295,7 +295,8 @@ runtime_start <- function(use_rstudioapi = TRUE, grDevices::dev.set(id) info <- tryCatch(jgd::jgd_server_info(), error = function(e) NULL) previous_device_id <- unname(info$server_info["deviceId"]) - if (length(previous_device_id) != 1L || is.na(previous_device_id) || !nzchar(previous_device_id)) { + if (length(previous_device_id) != 1L || is.na(previous_device_id) || + !nzchar(previous_device_id)) { previous_device_id <- NULL } # An explicit jgd() can reuse a managed device's number without calling diff --git a/sess/R/runtime.R b/sess/R/runtime.R index 1038506d4..9d739d8c1 100644 --- a/sess/R/runtime.R +++ b/sess/R/runtime.R @@ -283,7 +283,9 @@ } state$fields <- if (preserve_plots) { Filter(function(entry) identical(entry$name, "latest_plot_record"), state$fields) - } else list() + } else { + list() + } for (name in names(state$options)) { if (preserve_plots && identical(name, "sess.null_dev")) next @@ -294,7 +296,9 @@ } state$options <- if (preserve_plots && "sess.null_dev" %in% names(state$options)) { state$options["sess.null_dev"] - } else list() + } else { + list() + } state$preserved_plots <- preserve_plots .runtime_clear_viewer_state() state$active <- FALSE diff --git a/sess/inst/tinytest/test-httpgd-devices.R b/sess/inst/tinytest/test-httpgd-devices.R index 7e5b2e69e..21cc1f4c2 100644 --- a/sess/inst/tinytest/test-httpgd-devices.R +++ b/sess/inst/tinytest/test-httpgd-devices.R @@ -1,5 +1,5 @@ if (requireNamespace("httpgd", quietly = TRUE) && - packageVersion("httpgd") >= "2.0.1") local({ + packageVersion("httpgd") >= "2.0.1") local({ original_device <- grDevices::dev.cur() original_devices <- grDevices::dev.list() original_hgd <- httpgd::hgd @@ -63,7 +63,7 @@ if (requireNamespace("httpgd", quietly = TRUE) && }) if (requireNamespace("httpgd", quietly = TRUE) && - packageVersion("httpgd") >= "2.0.1") local({ + packageVersion("httpgd") >= "2.0.1") local({ # A restored-device notification can fail after attach succeeds. No runtime # hooks or bindings may be installed after that failure cleans up the runtime. has_rstudioapi <- requireNamespace("rstudioapi", quietly = TRUE) From 550a08e57445d6f8a4f1531c658b63919b982a4a Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Thu, 12 Nov 2026 12:33:54 +1100 Subject: [PATCH 30/32] fix(tests): align plot fixtures and CI with session-aware viewers - Update JGD fixtures for device tracking and session metadata - Use Windows-compatible interactive R flags in plot tests - Handle connection resets and cleanup in HTTP recovery tests - Install libtiffxx6 on Linux and expose dependency load errors --- .github/workflows/main.yml | 11 ++++++++++- R/tests/prepare-jgd.R | 22 +++++++++++++++++++++- src/test/node/httpgdClient.test.ts | 17 ++++++++++++----- src/test/node/jgdResize.test.ts | 20 ++++++++++++++------ src/test/suite/plotRestoration.test.ts | 6 +++++- 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9856e6967..e7ed58612 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -49,11 +49,20 @@ jobs: - name: Install R test dependencies run: install.packages(c("remotes", "pkgbuild", "languageserver", "lintr", "styler", "renv")) shell: Rscript {0} + - name: Install Linux plot system dependencies + if: runner.os == 'Linux' + # The unigd binary links libtiffxx.so.6, which the hosted image lacks. + run: | + sudo apt-get update + sudo apt-get install -y libtiffxx6 - run: pnpm run build - name: Verify live plot lifecycle dependencies run: | packages <- c("processx", "later", "jsonlite", "jgd", "httpgd", "svglite") - stopifnot(all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) + for (package in packages) { + message("Loading plot test dependency: ", package) + loadNamespace(package) + } stopifnot(packageVersion("httpgd") >= "2.0.1") shell: Rscript {0} - name: Verify managed jgd preparation diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R index 5381c366b..78bc055b7 100644 --- a/R/tests/prepare-jgd.R +++ b/R/tests/prepare-jgd.R @@ -46,10 +46,20 @@ local({ "jgd <- function() {", " path <- Sys.getenv('JGD_TEST_DEVICE_LOG', '')", " if (nzchar(path)) cat('jgd\\n', file=path, append=TRUE)", + # sess now tracks the opened device and attaches its session identity. + # A null PDF supplies a real device without requiring a JGD renderer. + " grDevices::pdf(NULL)", + " invisible(NULL)", + "}", + "jgd_server_info <- function() list(server_info=c(deviceId=as.character(grDevices::dev.cur())))", + "jgd_frame_ext <- function(value) {", + " path <- Sys.getenv('JGD_TEST_DEVICE_LOG', '')", + " if (nzchar(path)) cat(value, '\\n', file=paste0(path, '-frames'), append=TRUE)", " invisible(NULL)", "}" ), file.path(package, "R", "api.R")) - writeLines(c("export(fixture_device)", "export(jgd)"), file.path(package, "NAMESPACE")) + writeLines(c("export(fixture_device)", "export(jgd)", + "export(jgd_server_info)", "export(jgd_frame_ext)"), file.path(package, "NAMESPACE")) previous <- setwd(root) on.exit(setwd(previous), add = TRUE) output <- system2_with_child_environment( @@ -336,6 +346,16 @@ local({ "fixture-jgd-socket-next", "TRUE"))) stopifnot(identical(readLines(file.path(root, "real-approved-real-device"), warn = FALSE), c("jgd", "jgd"))) + frames <- lapply(readLines(file.path(root, "real-approved-real-device-frames"), warn = FALSE), + jsonlite::fromJSON) + stopifnot(length(frames) == 2L) + stopifnot(all(vapply(frames, function(frame) { + identity <- frame$vscodeR + nzchar(identity$sessionId) && nzchar(identity$pid) && + nzchar(identity$deviceId) && identical(identity$restoring, FALSE) + }, logical(1)))) + stopifnot(identical(frames[[1L]]$vscodeR$sessionId, frames[[2L]]$vscodeR$sessionId)) + stopifnot(!identical(frames[[1L]]$vscodeR$deviceId, frames[[2L]]$vscodeR$deviceId)) approved_root <- file.path(root, "approved") dir.create(approved_root) diff --git a/src/test/node/httpgdClient.test.ts b/src/test/node/httpgdClient.test.ts index 7745149eb..c74077838 100644 --- a/src/test/node/httpgdClient.test.ts +++ b/src/test/node/httpgdClient.test.ts @@ -120,9 +120,11 @@ suite('Internal httpgd client', () => { for (const response of held) { response.destroy(); } - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); + if (server.listening) { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } }); test('loads the initial snapshot, SVG, binary exports and renderers with authentication', async () => { @@ -183,13 +185,18 @@ suite('Internal httpgd client', () => { await until(() => client.getPlots().length === 2); assert.deepStrictEqual(notifications, [['1'], ['1', '2']]); }); - test('reports a refused server and refreshes unchanged history when it becomes available again', async () => { + test('reports an unavailable server and refreshes unchanged history when it becomes available again', async () => { let unavailable = 0; client.onUnavailable(() => unavailable++); await client.connect(); const port = (server.address() as AddressInfo).port; await new Promise((resolve) => server.close(() => resolve())); - await assert.rejects(client.getPlotText({ id: '1' }), { code: 'ECONNREFUSED' }); + // A pooled connection can be reset as the server closes; a fresh one is refused. + await assert.rejects( + client.getPlotText({ id: '1' }), + (error: NodeJS.ErrnoException) => + error.code === 'ECONNREFUSED' || error.code === 'ECONNRESET', + ); assert.ok(unavailable > 0); await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); await until(() => notifications.length > 1); diff --git a/src/test/node/jgdResize.test.ts b/src/test/node/jgdResize.test.ts index 1493250c8..dc72d6a90 100644 --- a/src/test/node/jgdResize.test.ts +++ b/src/test/node/jgdResize.test.ts @@ -61,9 +61,13 @@ suite('Live JGD resize', () => { }); // An interactive process exercises JGD's native idle input handler. // No C_jgd_poll_resize call or subsequent R command drives the redraw. - const r = spawn('R', ['--vanilla', '--slave', '--interactive'], { - env: { ...process.env, JGD_SOCKET: server.getSocketPath() }, - }); + const r = spawn( + 'R', + ['--vanilla', '--slave', process.platform === 'win32' ? '--ess' : '--interactive'], + { + env: { ...process.env, JGD_SOCKET: server.getSocketPath() }, + }, + ); let errors = ''; r.stderr.on('data', (data: Buffer) => { errors += data.toString(); @@ -131,9 +135,13 @@ suite('Live JGD resize', () => { server.onReady(resolve); server.start(); }); - const r = spawn('R', ['--vanilla', '--slave', '--interactive'], { - env: { ...process.env, JGD_SOCKET: server.getSocketPath() }, - }); + const r = spawn( + 'R', + ['--vanilla', '--slave', process.platform === 'win32' ? '--ess' : '--interactive'], + { + env: { ...process.env, JGD_SOCKET: server.getSocketPath() }, + }, + ); let output = ''; r.stdout.on('data', (data: Buffer) => { output += data.toString(); diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index fbde91925..19a08209b 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -1464,7 +1464,11 @@ suite('Standalone plot restoration', () => { } context.skip(); } - const r = spawn('R', ['--vanilla', '--slave', '--interactive']); + const r = spawn('R', [ + '--vanilla', + '--slave', + process.platform === 'win32' ? '--ess' : '--interactive', + ]); const exited = new Promise((resolve) => r.once('exit', () => resolve())); let output = ''; let errors = ''; From 09099775d7e6cd82984c03c3b7938f28145ee2ca Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Thu, 12 Nov 2026 13:10:12 +1100 Subject: [PATCH 31/32] fix: repair JGD reconnect and stabilize CI test fixtures --- R/tests/attach_sess.R | 8 +++++--- sess/R/hooks.R | 8 ++++++++ src/test/common/mockvscode.ts | 2 +- src/test/node/plotReconnect.test.ts | 8 +++++++- .../suite/sessionTerminalIntegration.test.ts | 18 +++++++++--------- .../suite/sessionTerminalLifecycle.test.ts | 14 +++++++++++--- 6 files changed, 41 insertions(+), 17 deletions(-) diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index ea04e4e37..af78b6e54 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -410,14 +410,16 @@ writeLines(c( " inherited <- vscode_r_startup_existing('endpoint')", " if (!is.null(inherited)) stop('child reused the parent startup context')", "}", - "ids <- character(); identities <- character(); deadline <- Sys.time() + 30", + "ids <- character(); identities <- character(); deadline <- Sys.time() + 30; read_error <- NULL", "while (length(ids) < count && Sys.time() < deadline) {", " requests <- list.files(directory, pattern='\\\\.request$', full.names=TRUE)", " for (request in requests) {", " id <- sub('\\\\.request$', '', basename(request))", " if (id %in% ids) next", + # Requests can disappear or be briefly locked during cancellation on Windows. + # Retry on the next scan; the existing deadline still bounds persistent errors. " lines <- suppressWarnings(tryCatch(readLines(request, warn=FALSE),", - " error=function(e) { if (!file.exists(request)) NULL else stop(e) }))", + " error=function(e) { read_error <<- conditionMessage(e); NULL }))", " if (is.null(lines)) next", " if (length(lines) != 5L || lines[[1L]] != 'vscode-r-sess-consent-v1' ||", " lines[[2L]] != id) stop('bad request')", @@ -431,7 +433,7 @@ writeLines(c( " }", " Sys.sleep(0.025)", "}", - "if (length(ids) != count) stop('timed out waiting for consent requests')", + "if (length(ids) != count) stop('timed out waiting for consent requests: ', read_error)", "writeLines(ids, log)", "writeLines(identities, identity_log)", "writeLines('ok', status_log)", diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 89bea4d29..373fcc7f3 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -252,6 +252,14 @@ runtime_start <- function(use_rstudioapi = TRUE, package_version(getNamespaceVersion("httpgd")) >= "2.0.1" selected_backend <- .select_plot_backend(plot_backend, has_httpgd, has_jgd) if (selected_backend == "jgd") { + open_device <- jgd::jgd + .runtime_rebind("jgd", function(...) { + result <- open_device(...) + # Windows defers the renderer welcome until server_info is requested. + # Cache it while connected so explicit devices can be identified on reload. + tryCatch(jgd::jgd_server_info(), error = function(e) NULL) + invisible(result) + }, "jgd") open_jgd_device <- function(key = NULL, recorded = NULL, previous_device_id = NULL, ...) { jgd::jgd(...) .runtime_track_device() diff --git a/src/test/common/mockvscode.ts b/src/test/common/mockvscode.ts index ee5a1f2ad..977c71b56 100644 --- a/src/test/common/mockvscode.ts +++ b/src/test/common/mockvscode.ts @@ -38,7 +38,7 @@ export function mockExtensionContext(extension_root: string, sandbox: sinon.Sino subscriptions: [], workspaceState: { get: sinon.stub().callsFake((key: string, defaultValue?: unknown) => defaultValue), - update: sinon.stub(), + update: sinon.stub().resolves(), }, asAbsolutePath: (relativePath: string) => { return path.join(extension_root, relativePath); diff --git a/src/test/node/plotReconnect.test.ts b/src/test/node/plotReconnect.test.ts index 3ba56190e..99c335da2 100644 --- a/src/test/node/plotReconnect.test.ts +++ b/src/test/node/plotReconnect.test.ts @@ -143,7 +143,13 @@ suite('Live R plot reconnect', function () { try { await startServers(); r = spawn('R', ['--vanilla', '--slave'], { - env: { ...process.env, R_LIBS_USER: library, JGD_SOCKET: jgd!.getSocketPath() }, + env: { + ...process.env, + // Prefer the fixture sess without hiding dependencies installed + // in R_LIBS_USER by setup-r on CI. + R_LIBS: [library, process.env.R_LIBS].filter(Boolean).join(path.delimiter), + JGD_SOCKET: jgd!.getSocketPath(), + }, }); let output = ''; let errors = ''; diff --git a/src/test/suite/sessionTerminalIntegration.test.ts b/src/test/suite/sessionTerminalIntegration.test.ts index be387891e..028bf12a8 100644 --- a/src/test/suite/sessionTerminalIntegration.test.ts +++ b/src/test/suite/sessionTerminalIntegration.test.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import * as os from 'os'; import * as path from 'path'; -import * as fsp from 'node:fs/promises'; +import fsp from 'node:fs/promises'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as extension from '../../extension'; @@ -230,16 +230,16 @@ suite('External Terminal Integration', () => { creationOptions: { env: { SESS_DISCOVERY_FILE: discoveryFile } }, } as vscode.Terminal; setTerminals([external, native], native); + const publish = sandbox.spy(fsp, 'rename'); const refresh = session.refreshTerminalDiscoveryFiles('new-sess', [external, native]); try { - let endpoint: string | undefined; - for (let attempt = 0; attempt < 100; attempt++) { - endpoint = ((await readJson(discoveryFile)) as { endpoint?: string }).endpoint; - if (endpoint === 'new-sess') { - break; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } + // Wait for the native terminal's publication while the unrelated PID + // stays unresolved. Reading during replacement can lock the file on Windows. + await waitForValue( + () => + publish.getCalls().find((call) => call.args[1] === discoveryFile)?.returnValue, + ); + const { endpoint } = (await readJson(discoveryFile)) as { endpoint?: string }; assert.strictEqual(endpoint, 'new-sess'); } finally { pendingPid.resolve(undefined); diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index 18a642db0..fadddb8e4 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -21,6 +21,7 @@ import { async function settleWithFakeTime( promise: Promise, clock: sinon.SinonFakeTimers, + startupFile?: string, ): Promise { let settled = false; void promise.then( @@ -33,6 +34,10 @@ async function settleWithFakeTime( ); for (let attempt = 0; attempt < 100 && !settled; attempt++) { await clock.tickAsync(10); + if (startupFile) { + // Advancing fake timers alone does not complete the real startup-file reads. + await fsp.readFile(startupFile); + } } assert.strictEqual( settled, @@ -782,7 +787,10 @@ suite('Session Terminal Lifecycle', () => { return waitForReady(current, timeout, startupPath, startupToken); }); const timerSandbox = sinon.createSandbox(); - const clock = timerSandbox.useFakeTimers(); + // Keep IPC and filesystem completion callbacks on the real event loop. + const clock = timerSandbox.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'Date'], + }); const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); let commandsSettled: Promise[]> | undefined; @@ -831,8 +839,8 @@ suite('Session Terminal Lifecycle', () => { const target = connections.attach('startup-target', rPid); await settleWithFakeTime(target, clock); - assert.strictEqual(await firstResult, true); - assert.strictEqual(await second, true); + assert.strictEqual(await settleWithFakeTime(firstResult, clock, startupFile), true); + assert.strictEqual(await settleWithFakeTime(second, clock, startupFile), true); sinon.assert.calledTwice(sendText); assert.deepStrictEqual(sendText.args, [ ['\x1b[200~first\x1b[201~', true], From 9eb31681e877f271a5ddd7f9592882afae270349 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Thu, 12 Nov 2026 13:29:08 +1100 Subject: [PATCH 32/32] fix(tests): ensure editor space for plot resize checks --- src/test/suite/plotRestoration.test.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/test/suite/plotRestoration.test.ts b/src/test/suite/plotRestoration.test.ts index 19a08209b..6e57b27b0 100644 --- a/src/test/suite/plotRestoration.test.ts +++ b/src/test/suite/plotRestoration.test.ts @@ -26,6 +26,14 @@ import type { ViewerSessionSource } from '../../viewerSession'; const createRealWebviewPanel = vscode.window.createWebviewPanel; const executeRealCommand = vscode.commands.executeCommand; +async function prepareEditorLayout(): Promise { + // Sidebars and the panel can leave too little space on CI desktops: VS Code + // clamps different requested group sizes to the same minimum dimensions. + await executeRealCommand('workbench.action.closeSidebar'); + await executeRealCommand('workbench.action.closeAuxiliaryBar'); + await executeRealCommand('workbench.action.closePanel'); +} + class RefreshViewer extends HttpgdViewer { seedPlots(plots: HttpgdPlot[]): void { this.setPlots(plots); @@ -1640,6 +1648,7 @@ suite('Standalone plot restoration', () => { ); const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); const process = await startHttpgdR(this); + await prepareEditorLayout(); const originalLayout = await executeRealCommand('vscode.getEditorLayout'); let viewer: HttpgdViewer | undefined; const document = await vscode.workspace.openTextDocument({ language: 'plaintext' }); @@ -2058,6 +2067,7 @@ suite('Standalone plot restoration', () => { }); return waitForValue(() => inspection); }; + await prepareEditorLayout(); const originalLayout = await executeRealCommand('vscode.getEditorLayout'); const documents: vscode.TextDocument[] = []; try { @@ -2087,8 +2097,14 @@ suite('Standalone plot restoration', () => { viewer.show(); await waitForValue(() => (render.called ? true : undefined)); const reopened = await inspect(); - assert.ok(reopened.windowWidth < enlarged.windowWidth); - assert.ok(reopened.windowHeight < enlarged.windowHeight); + assert.ok( + reopened.windowWidth < enlarged.windowWidth, + `width should shrink: ${enlarged.windowWidth} -> ${reopened.windowWidth}`, + ); + assert.ok( + reopened.windowHeight < enlarged.windowHeight, + `height should shrink: ${enlarged.windowHeight} -> ${reopened.windowHeight}`, + ); assert.strictEqual(reopened.fullWindow, fullWindow); assert.strictEqual( viewer.viewWidth,