@@ -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: '', 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