From ce001896fd73adb55da482266a2083ebc4722e19 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Tue, 5 May 2026 05:52:10 +0000 Subject: [PATCH 1/7] feat(ipc): replace WebSocket/TCP transport with Unix socket / named pipe Replace the WebSocket-over-TCP IPC between vscode and R sessions with a Unix domain socket (Linux/Mac) or Windows named pipe transport. Changes: - session.ts: create net.Server pipe instead of WebSocket.Server; use NDJSON framing (\n-delimited JSON) for message boundaries; remove token-based authentication (filesystem permissions suffice) - rTerminal.ts: pass SESS_PIPE env var instead of SESS_PORT + SESS_TOKEN - sess/R/server.R: connect via processx::conn_connect_unix_socket(); drive message dispatch through a later::later() polling loop - sess/R/dispatch.R: send via processx::conn_write() + NDJSON framing - sess/DESCRIPTION: swap websocket dep for processx (>= 3.5.0); add testthat to Suggests - sess/tests/testthat/test-ipc.R: unit tests for socket round-trip, dispatch_message routing, and ipc_write guard --- sess/DESCRIPTION | 6 +- sess/R/dispatch.R | 72 ++++---- sess/R/server.R | 278 ++++++++++++++++--------------- sess/tests/testthat.R | 4 + sess/tests/testthat/test-ipc.R | 84 ++++++++++ src/completions.ts | 8 +- src/plotViewer/standardViewer.ts | 6 +- src/rTerminal.ts | 13 +- src/session.ts | 275 +++++++++++++++--------------- src/test/suite/session.test.ts | 6 +- src/test/suite/terminal.test.ts | 4 +- 11 files changed, 431 insertions(+), 325 deletions(-) create mode 100644 sess/tests/testthat.R create mode 100644 sess/tests/testthat/test-ipc.R diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index 46e0e0f1..c260a06e 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -8,12 +8,14 @@ Description: Implements a high-performance HTTP and WebSocket server for R Inter License: MIT Encoding: UTF-8 LazyData: true -Imports: - websocket, +Imports: + processx (>= 3.5.0), later, jsonlite, utils, methods, rstudioapi, svglite +Suggests: + testthat (>= 3.0.0) Config/roxygen2/version: 8.0.0 diff --git a/sess/R/dispatch.R b/sess/R/dispatch.R index 207cb3bb..ec5a4bbc 100644 --- a/sess/R/dispatch.R +++ b/sess/R/dispatch.R @@ -1,6 +1,23 @@ -#' Send a message to the client via WebSocket (JSON-RPC 2.0) -#' -#' This is the internal workhorse for both Notifications and Requests. +#' Write a JSON object to the IPC pipe as a NDJSON line (internal) +#' @keywords internal +ipc_write <- function(data) { + con <- .sess_env$con + if (is.null(con)) return(invisible(FALSE)) + + line <- paste0(jsonlite::toJSON(data, auto_unbox = TRUE, null = "null", force = TRUE), "\n") + tryCatch( + { + processx::conn_write(con, line) + invisible(TRUE) + }, + error = function(e) { + warning("[sess] Failed to send IPC message: ", e$message) + invisible(FALSE) + } + ) +} + +#' Send a message to the client via IPC pipe (JSON-RPC 2.0) #' #' @param method String. The JSON-RPC method. #' @param params List. The parameters for the method. @@ -8,7 +25,7 @@ #' @return The result of the request if request=TRUE, otherwise TRUE if sent. #' @keywords internal rpc_send <- function(method, params = list(), request = FALSE) { - if (is.null(.sess_env$ws)) { + if (is.null(.sess_env$con)) { return(invisible(FALSE)) } @@ -24,45 +41,30 @@ rpc_send <- function(method, params = list(), request = FALSE) { msg$id <- req_id } - # Push over the websocket - payload <- jsonlite::toJSON(msg, auto_unbox = TRUE, null = "null", force = TRUE) - tryCatch( - { - .sess_env$ws$send(payload) - }, - error = function(e) { - warning("Failed to send IPC message: ", e$message) - invisible(FALSE) - } - ) + ipc_write(msg) if (!request) { - return(invisible(TRUE)) - } + invisible(TRUE) + } else { + # NON-BLOCKING WAIT: + # Run later callbacks (which include poll_connection) while waiting for a response. + while (is.null(.sess_env$pending_responses[[req_id]])) { + later::run_now() + Sys.sleep(0.01) + } - # NON-BLOCKING WAIT: - # Process HTTP/WS events in the background while blocking the R console execution - # This prevents the R event loop from locking up. - while (is.null(.sess_env$pending_responses[[req_id]])) { - later::run_now() - Sys.sleep(0.01) - } + response <- .sess_env$pending_responses[[req_id]] + .sess_env$pending_responses[[req_id]] <- NULL - # Retrieve and clean up response - response <- .sess_env$pending_responses[[req_id]] - .sess_env$pending_responses[[req_id]] <- NULL + if (inherits(response, "json_rpc_error")) { + stop(sprintf("JSON-RPC Error [%d]: %s", response$code, response$message)) + } - # Handle JSON-RPC Errors if any - if (inherits(response, "json_rpc_error")) { - stop(sprintf("JSON-RPC Error [%d]: %s", response$code, response$message)) + response } - - response } -#' Notify the client via WebSocket (JSON-RPC 2.0 Notification) -#' -#' Pushes an event instantly to the client extension via the active WebSocket connection. +#' Notify the client via IPC pipe (JSON-RPC 2.0 Notification) #' #' @param method A string representing the action (e.g., "dataview", "plot_updated") #' @param params A list containing the arguments for the command diff --git a/sess/R/server.R b/sess/R/server.R index 8e189b2b..7d92a0b9 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -1,51 +1,42 @@ -#' Start the client R IPC connection +#' Connect to the VS Code IPC server #' -#' @param port Integer. The port of the VS Code WebSocket server. -#' If NULL, it will use SESS_PORT env var. -#' @param token String. The authentication token. If NULL, it will use SESS_TOKEN env var. -#' @param use_rstudioapi Logical. Should the rstudioapi emulation layer -#' be enabled? Defaults to TRUE. -#' @param use_httpgd Logical. Should httpgd be used for plotting if available? Defaults to TRUE +#' @param pipe_path Character. Path to the named pipe / Unix domain socket. +#' If NULL, uses the SESS_PIPE environment variable, then falls back to the +#' session JSON file written by the extension. +#' @param use_rstudioapi Logical. Enable rstudioapi emulation. Defaults to TRUE. +#' @param use_httpgd Logical. Use httpgd for plotting if available. Defaults to TRUE. #' @export -connect <- function(port = NULL, token = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE) { - # Initialize state - .sess_env$server <- NULL - .sess_env$ws <- NULL +connect <- function(pipe_path = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE) { + .sess_env$con <- NULL .sess_env$pending_responses <- list() + .sess_env$read_buffer <- "" - # Specific tempdir for vscode-R .sess_env$tempdir <- file.path(tempdir(), "sess") dir.create(.sess_env$tempdir, showWarnings = FALSE, recursive = TRUE) - # Temporary file for static plot serving .sess_env$latest_plot_path <- file.path(.sess_env$tempdir, "sess_plot.png") - is_manual <- (!is.null(port) && !is.na(port) && nzchar(port)) || - (!is.null(token) && !is.na(token) && nzchar(token)) + is_manual <- !is.null(pipe_path) && !is.na(pipe_path) && nzchar(pipe_path) - if (is.null(port) || is.na(port)) { - port <- Sys.getenv("SESS_PORT") - } - if (is.null(token) || is.na(token) || !nzchar(token)) { - token <- Sys.getenv("SESS_TOKEN") + if (is.null(pipe_path) || is.na(pipe_path)) { + pipe_path <- Sys.getenv("SESS_PIPE") } - # Derive file path for fallback/reconnection + # Fallback: read from session JSON file written by the extension pid <- Sys.getpid() home <- path.expand("~") file_path <- file.path(home, ".vscode-R", "sessions", sprintf("%d.json", pid)) - if (!nzchar(port) || !nzchar(token)) { + if (!nzchar(pipe_path)) { if (file.exists(file_path)) { tryCatch({ - config <- jsonlite::fromJSON(readLines(file_path, warn = FALSE)) - port <- config$port - token <- config$token + cfg <- jsonlite::fromJSON(readLines(file_path, warn = FALSE)) + pipe_path <- cfg$pipe }, error = function(e) NULL) } } - if (!nzchar(port) || !nzchar(token)) { + if (!nzchar(pipe_path)) { warning("[sess] Connection info not available. Cannot connect to VS Code.") return(invisible(NULL)) } @@ -56,120 +47,145 @@ connect <- function(port = NULL, token = NULL, use_rstudioapi = TRUE, use_httpgd } do_connect <- function() { - url <- sprintf("ws://127.0.0.1:%s/?token=%s", port, token) - ws <- websocket::WebSocket$new( - url, - autoConnect = FALSE, - accessLogChannels = "none", - errorLogChannels = "none" + con <- tryCatch( + processx::conn_connect_unix_socket(pipe_path, encoding = ""), + error = function(e) { + print_async_msg(sprintf("[sess] Failed to connect to IPC pipe: %s", e$message)) + NULL + } ) + if (is.null(con)) return() + + .sess_env$con <- con + + # Send attach handshake + notify_client("attach", list( + version = sprintf("%s.%s", R.version$major, R.version$minor), + pid = Sys.getpid(), + tempdir = .sess_env$tempdir, + wd = getwd(), + info = list( + command = commandArgs()[[1L]], + version = R.version.string, + start_time = format(Sys.time()) + ) + )) + + print_async_msg("[sess] Connected to VS Code") + + # Start the polling loop + poll_connection() + } - ws$onOpen(function(event) { - .sess_env$ws <- ws - print_async_msg("[sess] Connected to VS Code") - - # Send the attach handshake immediately upon connection - notify_client("attach", list( - version = sprintf("%s.%s", R.version$major, R.version$minor), - pid = Sys.getpid(), - tempdir = .sess_env$tempdir, - wd = getwd(), - info = list( - command = commandArgs()[[1L]], - version = R.version.string, - start_time = format(Sys.time()) - ) - )) - }) - - ws$onMessage(function(event) { - # Handle JSON-RPC 2.0 messages COMING FROM the client - payload <- tryCatch(jsonlite::fromJSON(event$data), error = function(e) NULL) - - if (!is.null(payload) && !is.null(payload$id)) { - if (!is.null(payload$method)) { - # It's a Request from the Client (e.g., 'workspace', 'plot_latest') - handlers <- list( - "workspace" = function(p) get_workspace_data(), - "hover" = function(p) handle_hover(p$expr), - "completion" = function(p) handle_complete(p$expr, p$trigger), - "plot_latest" = function(p) handle_plot_latest(p) - ) - - if (payload$method %in% names(handlers)) { - res <- tryCatch( - { - handlers[[payload$method]](payload$params) - }, - error = function(e) { - warning(sprintf( - "[sess] Error in handler for '%s': %s", - payload$method, e$message - )) - NULL - } - ) - - succ_resp <- list( - jsonrpc = "2.0", - id = payload$id, - result = res - ) - ws$send(jsonlite::toJSON(succ_resp, auto_unbox = TRUE, null = "null", force = TRUE)) - } else { - err_resp <- list( - jsonrpc = "2.0", - id = payload$id, - error = list(code = -32601, message = "Method not found") - ) - ws$send(jsonlite::toJSON(err_resp, auto_unbox = TRUE, null = "null", force = TRUE)) - } - } else { - # It's a Response (to our RStudio API request) - if (!is.null(payload$result)) { - .sess_env$pending_responses[[as.character(payload$id)]] <- - payload$result - } else if (!is.null(payload$error)) { - .sess_env$pending_responses[[as.character(payload$id)]] <- - structure(payload$error, class = "json_rpc_error") - } - } - } - }) + do_connect() - ws$onClose(function(event) { - .sess_env$ws <- NULL - if (is_manual) { - print_async_msg("[sess] Disconnected from VS Code.") - return() + if (is.na(use_rstudioapi)) use_rstudioapi <- TRUE + if (is.na(use_httpgd)) use_httpgd <- TRUE + register_hooks(use_rstudioapi = use_rstudioapi, use_httpgd = use_httpgd) + + invisible(NULL) +} + +#' Poll the IPC connection for incoming messages (internal) +#' +#' Runs as a recurring later callback; dispatches NDJSON messages from vscode. +#' @keywords internal +poll_connection <- function() { + con <- .sess_env$con + if (is.null(con)) return() + + # Non-blocking poll: 0 ms timeout + ready <- tryCatch( + processx::poll(list(con), 0L), + error = function(e) NULL + ) + + if (!is.null(ready) && length(ready) > 0 && identical(ready[[1]], "ready")) { + chunk <- tryCatch( + processx::conn_read_chars(con), + error = function(e) { + .sess_env$con <- NULL + NULL } - print_async_msg("[sess] Disconnected from VS Code. Retrying in 5 seconds...") - later::later(function() { - if (file.exists(file_path)) { - tryCatch({ - config <- jsonlite::fromJSON(readLines(file_path, warn = FALSE)) - port <<- config$port - token <<- config$token - }, error = function(e) NULL) - } - do_connect() - }, 5) - }) + ) - ws$onError(function(event) { - print_async_msg(sprintf("[sess] WebSocket error: %s", event$message)) - }) + if (!is.null(chunk) && nzchar(chunk)) { + .sess_env$read_buffer <- paste0(.sess_env$read_buffer, chunk) + parts <- strsplit(.sess_env$read_buffer, "\n", fixed = TRUE)[[1]] + + n <- length(parts) + # Keep any trailing partial line in the buffer + if (endsWith(.sess_env$read_buffer, "\n")) { + .sess_env$read_buffer <- "" + } else { + .sess_env$read_buffer <- parts[n] + parts <- parts[-n] + } - ws$connect() + for (line in parts) { + line <- trimws(line) + if (!nzchar(line)) next + tryCatch( + dispatch_message(line), + error = function(e) { + warning("[sess] Error dispatching message: ", e$message) + } + ) + } + } } - # Connect to VS Code - do_connect() + later::later(poll_connection, 0.01) +} - # Register runtime hooks - if (is.na(use_rstudioapi)) use_rstudioapi <- TRUE - if (is.na(use_httpgd)) use_httpgd <- TRUE - register_hooks(use_rstudioapi = use_rstudioapi, use_httpgd = use_httpgd) +#' Dispatch a single NDJSON line as a JSON-RPC message (internal) +#' @keywords internal +dispatch_message <- function(line) { + payload <- tryCatch(jsonlite::fromJSON(line, simplifyVector = FALSE), error = function(e) NULL) + if (is.null(payload)) return(invisible(NULL)) + + has_id <- !is.null(payload$id) + has_method <- !is.null(payload$method) + + if (has_id && !has_method) { + # Response to a request we sent + key <- as.character(payload$id) + if (!is.null(payload$result)) { + .sess_env$pending_responses[[key]] <- payload$result + } else if (!is.null(payload$error)) { + .sess_env$pending_responses[[key]] <- + structure(payload$error, class = "json_rpc_error") + } + } else if (has_method && has_id) { + # Request from vscode → R must reply + handlers <- list( + "workspace" = function(p) get_workspace_data(), + "hover" = function(p) handle_hover(p$expr), + "completion" = function(p) handle_complete(p$expr, p$trigger), + "plot_latest" = function(p) handle_plot_latest(p) + ) + if (payload$method %in% names(handlers)) { + res <- tryCatch( + handlers[[payload$method]](payload$params), + error = function(e) { + warning(sprintf("[sess] Error in handler for '%s': %s", payload$method, e$message)) + NULL + } + ) + rpc_reply(payload$id, result = res) + } else { + rpc_reply(payload$id, error = list(code = -32601L, message = "Method not found")) + } + } + # has_method && !has_id: unsolicited notification from vscode — ignore gracefully invisible(NULL) } + +#' Send a JSON-RPC reply to a request (internal) +#' @keywords internal +rpc_reply <- function(id, result = NULL, error = NULL) { + msg <- list(jsonrpc = "2.0", id = id) + if (!is.null(error)) msg$error <- error else msg$result <- result + ipc_write(msg) +} diff --git a/sess/tests/testthat.R b/sess/tests/testthat.R new file mode 100644 index 00000000..4b5180a8 --- /dev/null +++ b/sess/tests/testthat.R @@ -0,0 +1,4 @@ +library(testthat) +library(sess) + +test_check("sess") diff --git a/sess/tests/testthat/test-ipc.R b/sess/tests/testthat/test-ipc.R new file mode 100644 index 00000000..cccde80c --- /dev/null +++ b/sess/tests/testthat/test-ipc.R @@ -0,0 +1,84 @@ +test_that("NDJSON framing round-trips correctly through a socket pair", { + skip_if_not_installed("processx") + skip_on_os("windows") # Windows named pipe paths are tested separately + + pipe_path <- tempfile(fileext = ".sock") + on.exit(unlink(pipe_path), add = TRUE) + + server_con <- processx::conn_create_unix_socket(pipe_path, encoding = "") + on.exit(close(server_con), add = TRUE) + + client_con <- processx::conn_connect_unix_socket(pipe_path, encoding = "") + on.exit(close(client_con), add = TRUE) + + # Accept the incoming client on the server side + processx::poll(list(server_con), 1000L) + conn_con <- processx::conn_accept_unix_socket(server_con) + on.exit(close(conn_con), add = TRUE) + + # Write a NDJSON line from client to server + msg <- list(jsonrpc = "2.0", method = "ping", params = list(value = 42L)) + line <- paste0(jsonlite::toJSON(msg, auto_unbox = TRUE), "\n") + processx::conn_write(client_con, line, sep = "") + + # Poll and read on server side + ready <- processx::poll(list(conn_con), 1000L) + expect_equal(ready[[1]], "ready") + + received <- processx::conn_read_chars(conn_con) + expect_true(nzchar(received)) + + # Parse and verify + parsed <- jsonlite::fromJSON(trimws(received), simplifyVector = FALSE) + expect_equal(parsed$method, "ping") + expect_equal(parsed$params$value, 42L) +}) + +test_that("dispatch_message routes responses to pending_responses", { + .sess_env <- sess:::.sess_env + orig_pending <- .sess_env$pending_responses + on.exit(.sess_env$pending_responses <- orig_pending, add = TRUE) + + .sess_env$pending_responses <- list() + + response_line <- as.character(jsonlite::toJSON( + list(jsonrpc = "2.0", id = "req_001", result = list(x = 1L)), + auto_unbox = TRUE + )) + + sess:::dispatch_message(response_line) + + expect_false(is.null(.sess_env$pending_responses[["req_001"]])) + expect_equal(.sess_env$pending_responses[["req_001"]]$x, 1L) +}) + +test_that("dispatch_message stores JSON-RPC errors with error class", { + .sess_env <- sess:::.sess_env + orig_pending <- .sess_env$pending_responses + on.exit(.sess_env$pending_responses <- orig_pending, add = TRUE) + + .sess_env$pending_responses <- list() + + error_line <- as.character(jsonlite::toJSON( + list(jsonrpc = "2.0", id = "req_002", + error = list(code = -32601L, message = "Method not found")), + auto_unbox = TRUE + )) + + sess:::dispatch_message(error_line) + + resp <- .sess_env$pending_responses[["req_002"]] + expect_false(is.null(resp)) + expect_true(inherits(resp, "json_rpc_error")) + expect_equal(resp$code, -32601L) +}) + +test_that("ipc_write returns FALSE when no connection is open", { + .sess_env <- sess:::.sess_env + orig_con <- .sess_env$con + on.exit(.sess_env$con <- orig_con, add = TRUE) + + .sess_env$con <- NULL + result <- sess:::ipc_write(list(jsonrpc = "2.0", method = "test")) + expect_false(isTRUE(result)) +}) diff --git a/src/completions.ts b/src/completions.ts index 1af7882b..c5f00c1e 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -41,11 +41,11 @@ export class HoverProvider implements vscode.HoverProvider { let hoverRange = document.getWordRangeAtPosition(position); let hoverText = null; - if (session.server) { + if (session.globalPipePath) { const exprRegex = /([a-zA-Z0-9._$@ ])+(?('plot.format', 'svglite'); const devArgs = config().get>('plot.devArgs'); - const response = await sessionRequest(server, { + const response = await sessionRequest({ method: 'plot_latest', params: { width: this.viewWidth, diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 13085616..a9f75913 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -179,7 +179,7 @@ export async function runFromLineToEnd(): Promise { await runTextInTerm(text); } -import { getGlobalSessionServer, writeSessionFile } from './session'; +import { getGlobalPipePath, writeSessionFile } from './session'; export async function makeTerminalOptions(): Promise { const workspaceFolderPath = getCurrentWorkspaceFolder()?.uri.fsPath; @@ -193,12 +193,11 @@ export async function makeTerminalOptions(): Promise { }; const newRprofile = extensionContext.asAbsolutePath(path.join('R', 'profile.R')); if (config().get('sessionWatcher')) { - const { port, token } = await getGlobalSessionServer(); + const pipePath = await getGlobalPipePath(); termOptions.env = { R_PROFILE_USER_OLD: process.env.R_PROFILE_USER, R_PROFILE_USER: newRprofile, - SESS_PORT: port.toString(), - SESS_TOKEN: token, + SESS_PIPE: pipePath, SESS_RSTUDIOAPI: config().get('session.emulateRStudioAPI') ? 'TRUE' : 'FALSE', SESS_USE_HTTPGD: config().get('plot.useHttpgd') ? 'TRUE' : 'FALSE' }; @@ -220,10 +219,10 @@ export async function createRTerm(preserveshow?: boolean): Promise { rTerm = vscode.window.createTerminal(termOptions); rTerm.show(preserveshow); - void rTerm.processId.then(async (pid) => { + void rTerm.processId.then(async (pid: number | undefined) => { if (pid) { - const { port, token } = await getGlobalSessionServer(); - await writeSessionFile(pid.toString(), port, token); + const pipePath = await getGlobalPipePath(); + await writeSessionFile(pid.toString(), pipePath); } }); diff --git a/src/session.ts b/src/session.ts index 1b0a730b..b58257c6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,6 +3,8 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import * as os from 'os'; +import * as net from 'net'; +import * as crypto from 'crypto'; import * as vscode from 'vscode'; import { commands, Uri, ViewColumn, Webview, window, workspace, env } from 'vscode'; @@ -15,20 +17,12 @@ import { homeExtDir, rWorkspace, globalRHelp, globalPlotManager, sessionStatusBa import { showWebView } from './webViewer'; -import WebSocket from 'ws'; - export interface SessionInfo { version: string; command: string; start_time: string; } -interface ExtWebSocket extends WebSocket { - _terminalPid?: number; - _port?: number; - _token?: string; -} - export interface GlobalEnv { [key: string]: { class: string[]; @@ -48,15 +42,15 @@ export interface WorkspaceData { globalenv: GlobalEnv; } -export interface SessionServer { - host: string; - port: number; - token: string; +// Thin adapter to track per-socket metadata alongside net.Socket +interface IpcSocket extends net.Socket { + _terminalPid?: number; + _pipePath?: string; } export class Session { - public server: SessionServer; - public ws: WebSocket; + public pipePath: string; + public socket: IpcSocket; public pid: string; public rVer: string; public info: SessionInfo; @@ -64,9 +58,9 @@ export class Session { public workingDir: string; public workspaceData: WorkspaceData; - constructor(server: SessionServer, ws: WebSocket) { - this.server = server; - this.ws = ws; + constructor(pipePath: string, socket: IpcSocket) { + this.pipePath = pipePath; + this.socket = socket; this.pid = ''; this.rVer = ''; this.info = { version: '', command: '', start_time: '' }; @@ -85,7 +79,7 @@ export let workingDir: string; let rVer: string; let pid: string; let info: SessionInfo; -export let server: SessionServer | undefined; +export let globalPipePath: string | undefined; export let workspaceFile: string; const sessions = new Map(); @@ -96,10 +90,9 @@ export function deploySessionWatcher(extensionPath: string): void { console.info(`[deploySessionWatcher] extensionPath: ${extensionPath}`); resDir = path.join(extensionPath, 'dist', 'resources'); - // Initialize the WebSocket server when the extension activates - void getGlobalSessionServer().then(async (srv) => { + void getGlobalPipePath().then(async (pipePath) => { await pruneSessionFiles(); - await updateActiveTerminalFiles(srv.port, srv.token); + await updateActiveTerminalFiles(pipePath); }).catch(err => { console.error('Failed to initialize global session server', err); }); @@ -112,14 +105,15 @@ export function deploySessionWatcher(extensionPath: string): void { }); } -import * as crypto from 'crypto'; - -let wsClient: ExtWebSocket | undefined; -export const activeConnections = new Set(); +let pipeClient: IpcSocket | undefined; +export const activeConnections = new Set(); const pendingRequests = new Map void, reject: (reason?: unknown) => void }>(); -let globalSessionServer: { port: number, token: string } | undefined; +// Per-socket read buffers for NDJSON framing +const readBuffers = new Map(); + +let globalSessionServer: net.Server | undefined; function isPidRunning(pid: number): boolean { try { @@ -154,105 +148,111 @@ async function pruneSessionFiles() { } } -export async function writeSessionFile(pid: string, port: number, token: string) { +export async function writeSessionFile(pid: string, pipePath: string) { const homeDir = os.homedir(); const sessionsDir = path.join(homeDir, '.vscode-R', 'sessions'); await fs.ensureDir(sessionsDir); const filePath = path.join(sessionsDir, `${pid}.json`); - await fs.writeJson(filePath, { port, token }); + await fs.writeJson(filePath, { pipe: pipePath }); } -async function updateActiveTerminalFiles(port: number, token: string) { +async function updateActiveTerminalFiles(pipePath: string) { const terminals = vscode.window.terminals; for (const term of terminals) { if (term.name === 'R Interactive') { const pid = await term.processId; if (pid) { - await writeSessionFile(pid.toString(), port, token); + await writeSessionFile(pid.toString(), pipePath); } } } } -export async function getGlobalSessionServer(): Promise<{ port: number, token: string }> { - if (globalSessionServer) { - return globalSessionServer; +function makePipePath(): string { + const suffix = crypto.randomBytes(8).toString('hex'); + if (process.platform === 'win32') { + return `\\\\.\\pipe\\vscode-r-${suffix}`; + } else { + return path.join(os.tmpdir(), `vscode-r-${suffix}.sock`); } +} - return new Promise((resolve, reject) => { - const token = crypto.randomBytes(16).toString('hex'); - const wss = new WebSocket.Server({ port: 0, host: '127.0.0.1' }); - - wss.on('listening', () => { - const address = wss.address(); - if (typeof address === 'object' && address !== null) { - globalSessionServer = { port: address.port, token }; - console.info(`[SessionServer] Listening on ws://127.0.0.1:${address.port}?token=${token}`); - - // Initialize the old global server object for compatibility - server = { host: '127.0.0.1', port: address.port, token }; - resolve(globalSessionServer); - } else { - reject(new Error('Failed to get WebSocket server address')); - } - }); - - wss.on('connection', (ws: ExtWebSocket, req) => { - const url = new URL(req.url || '', `http://${req.headers.host || '127.0.0.1'}`); - const clientToken = url.searchParams.get('token'); - - if (clientToken !== token) { - console.warn('[SessionServer] Connection rejected: invalid token'); - ws.close(); - return; - } - - console.info('[SessionServer] Client connected'); - activeConnections.add(ws); - wsClient = ws; +export async function getGlobalPipePath(): Promise { + if (globalPipePath) { + return globalPipePath; + } - ws.on('message', (data: WebSocket.Data) => { - void (async () => { - try { - const message = JSON.parse(data.toString()) as Record; - if (message.id !== undefined && !message.method) { - // Response to a client request - const id = Number(message.id); - const pending = pendingRequests.get(id); - if (pending) { - pendingRequests.delete(id); - if (message.error) { - pending.reject(message.error); - } else { - pending.resolve(message.result); + return new Promise((resolve, reject) => { + const pipePath = makePipePath(); + const server = net.createServer((rawSocket) => { + const socket = rawSocket as IpcSocket; + console.info('[SessionServer] Client connected via IPC pipe'); + activeConnections.add(socket); + pipeClient = socket; + readBuffers.set(socket, ''); + + socket.on('data', (data: Buffer) => { + const incoming = data.toString('utf8'); + const buf = (readBuffers.get(socket) ?? '') + incoming; + const lines = buf.split('\n'); + // Last element is a potentially incomplete line — keep in buffer + readBuffers.set(socket, lines[lines.length - 1]); + + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i].trim(); + if (!line) continue; + void (async () => { + try { + const message = JSON.parse(line) as Record; + if (message.id !== undefined && !message.method) { + // Response to a request we sent + const id = Number(message.id); + const pending = pendingRequests.get(id); + if (pending) { + pendingRequests.delete(id); + if (message.error) { + pending.reject(message.error); + } else { + pending.resolve(message.result); + } } + } else if (!message.id) { + await handleNotification(message, socket); + } else { + await handleRequest(message, socket); } - } else if (!message.id) { - // Notification from server - await handleNotification(message, ws); - } else { - // Request from server - await handleRequest(message, ws); + } catch (e) { + console.error('[SessionServer] Error handling message', e); } - } catch (e) { - console.error('[SessionServer] Error handling message', e); - } - })(); + })(); + } }); - ws.on('close', () => { + socket.on('close', () => { console.info('[SessionServer] Client disconnected'); - activeConnections.delete(ws); - if (wsClient === ws) { - wsClient = undefined; + readBuffers.delete(socket); + activeConnections.delete(socket); + if (pipeClient === socket) { + pipeClient = undefined; } }); + + socket.on('error', (err) => { + console.error('[SessionServer] Socket error', err); + }); }); - wss.on('error', (err) => { + server.on('error', (err) => { console.error('[SessionServer] Server error', err); reject(err); }); + + server.listen(pipePath, () => { + globalPipePath = pipePath; + globalSessionServer = server; + console.info(`[SessionServer] Listening on ${pipePath}`); + resolve(pipePath); + }); }); } @@ -325,14 +325,14 @@ function writeSettings() { } async function updatePlot() { - if (!server) {return;} + if (!globalPipePath) {return;} await globalPlotManager?.showStandardPlot(); } export async function updateWorkspace() { - if (!server) {return;} + if (!globalPipePath) {return;} try { - const response = await sessionRequest(server, { method: 'workspace' }); + const response = await sessionRequest({ method: 'workspace' }); if (response) { workspaceData = response as WorkspaceData; if (activeSession) { @@ -693,14 +693,14 @@ import * as rstudioapi from './rstudioapi'; export async function activateSession(session: Session): Promise { activeSession = session; - wsClient = session.ws as ExtWebSocket; - server = session.server; + pipeClient = session.socket; + globalPipePath = session.pipePath; pid = session.pid; rVer = session.rVer; info = session.info; sessionDir = session.sessionDir; workingDir = session.workingDir; - + if (sessionStatusBarItem) { sessionStatusBarItem.text = `R ${rVer}: ${pid}`; sessionStatusBarItem.tooltip = `${info.version}\nProcess ID: ${pid}\nCommand: ${info.command}\nStart time: ${info.start_time}\nClick to attach to active terminal.`; @@ -727,7 +727,13 @@ export async function switchSessionByTerminal(terminal: vscode.Terminal | undefi } } -async function handleNotification(message: Record, ws: ExtWebSocket) { +function sendToSocket(socket: IpcSocket, data: Record): void { + if (!socket.destroyed) { + socket.write(JSON.stringify(data) + '\n'); + } +} + +async function handleNotification(message: Record, socket: IpcSocket) { const method = String(message.method); const params = (message.params as Record) || {}; @@ -735,13 +741,12 @@ async function handleNotification(message: Record, ws: ExtWebSo case 'attach': { if (!params.tempdir || !params.wd) {return;} const rPid = String(params.pid); - const terminalPid = ws._terminalPid ? String(ws._terminalPid) : rPid; - + const terminalPid = socket._terminalPid ? String(socket._terminalPid) : rPid; + let session = sessions.get(terminalPid); if (!session) { - session = new Session({ host: '127.0.0.1', port: ws._port || 0, token: ws._token || '' }, ws); + session = new Session(socket._pipePath ?? globalPipePath ?? '', socket); sessions.set(terminalPid, session); - // Also map R PID if it's different if (rPid !== terminalPid) { sessions.set(rPid, session); } @@ -752,7 +757,6 @@ async function handleNotification(message: Record, ws: ExtWebSo session.sessionDir = String(params.tempdir); session.workingDir = String(params.wd); - // Switch active session await activateSession(session); console.info(`[startSessionWatcher] attach R PID: ${rPid}, terminal PID: ${terminalPid}`); @@ -760,17 +764,11 @@ async function handleNotification(message: Record, ws: ExtWebSo if (params.plot_url) { await globalPlotManager?.showHttpgdPlot(String(params.plot_url)); } - void updateWorkspace(); // Initial workspace fetch + void updateWorkspace(); void watchProcess(rPid).then((v: string) => { void cleanupSession(v); }); break; } - // case 'detach': { - // if (params.pid) { - // await cleanupSession(String(params.pid)); - // } - // break; - // } case 'workspace_updated': { void updateWorkspace(); break; @@ -844,13 +842,13 @@ async function handleNotification(message: Record, ws: ExtWebSo } } -async function handleRequest(message: Record, ws: ExtWebSocket) { +async function handleRequest(message: Record, socket: IpcSocket) { if (message.method) { const method = String(message.method); const params = (message.params as Record) || {}; let result: unknown = null; let error: unknown = null; - + try { switch (method) { case 'rstudioapi/active_editor_context': @@ -910,22 +908,19 @@ async function handleRequest(message: Record, ws: ExtWebSocket) } catch (e) { error = { code: -32603, message: String(e) }; } - - if (ws.readyState === ws.OPEN) { - ws.send(JSON.stringify({ - jsonrpc: '2.0', - id: message.id, - result: result, - error: error - })); - } + + sendToSocket(socket, { + jsonrpc: '2.0', + id: message.id, + result: result, + error: error + }); } } export async function cleanupSession(pidArg: string): Promise { const session = sessions.get(pidArg); if (session) { - // Find all keys in sessions that point to this session and remove them const keysToRemove: string[] = []; for (const [k, v] of sessions.entries()) { if (v === session) { @@ -933,12 +928,11 @@ export async function cleanupSession(pidArg: string): Promise { } } keysToRemove.forEach(k => sessions.delete(k)); - // Terminate the WebSocket - session.ws.terminate(); + session.socket.destroy(); } if (activeSession === session || pid === pidArg) { resetStatusBar(); - server = undefined; + globalPipePath = undefined; activeSession = undefined; workspaceData.globalenv = {}; workspaceData.loaded_namespaces = []; @@ -972,12 +966,12 @@ async function watchProcess(pid: string): Promise { return pid; } -export async function sessionRequest(server: SessionServer, data: Record): Promise { +export async function sessionRequest(data: Record): Promise { try { - if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { - throw new Error('WebSocket is not connected'); + if (!pipeClient || pipeClient.destroyed) { + throw new Error('IPC socket is not connected'); } - + return await new Promise((resolve, reject) => { const id = data.id !== undefined ? Number(data.id) : Math.floor(Math.random() * 1000000); const payload = data.jsonrpc ? data : { @@ -985,17 +979,16 @@ export async function sessionRequest(server: SessionServer, data: Record { if (pendingRequests.has(id)) { pendingRequests.delete(id); @@ -1015,8 +1008,14 @@ export async function sessionRequest(server: SessionServer, data: Record { - const { port, token } = await getGlobalSessionServer(); - const command = `sess::connect(port=${port}, token="${token}")`; + const pipePath = await getGlobalPipePath(); + const command = `sess::connect(pipe_path="${pipePath.replace(/\\/g, '\\\\')}")`; void vscode.env.clipboard.writeText(command); void vscode.window.showInformationMessage(`R command copied to clipboard: ${command}`); } + +// Kept for backward compatibility - callers in rTerminal.ts use this +export async function getGlobalSessionServer(): Promise<{ port: number, token: string }> { + await getGlobalPipePath(); + return { port: 0, token: '' }; +} diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index e6ce5a0f..a66b706f 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -100,7 +100,7 @@ suite('Session Communication', () => { expr: 'my_list', trigger: '$' }; - const completionResult = await session.sessionRequest(session.activeSession.server, { + const completionResult = await session.sessionRequest({ method: 'completion', params: completionRequestParams }) as Record[]; @@ -162,7 +162,7 @@ suite('Session Communication', () => { let svgliteResp: { data?: string, format?: string, error?: unknown } | undefined; await waitFor(async () => { try { - svgliteResp = await session.sessionRequest(activeSession.server, { + svgliteResp = await session.sessionRequest({ method: 'plot_latest', params: { width: 800, height: 600, format: 'svglite' } }) as { data?: string, format?: string, error?: unknown }; @@ -193,7 +193,7 @@ suite('Session Communication', () => { let pngResp: { data?: string, format?: string } | undefined; await waitFor(async () => { try { - pngResp = await session.sessionRequest(activeSession.server, { + pngResp = await session.sessionRequest({ method: 'plot_latest', params: { width: 800, height: 600, format: 'png' } }) as { data?: string, format?: string }; diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index dabb045f..065b33d4 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -130,8 +130,8 @@ suite('R Terminal', () => { sessionDir: '', workingDir: '', workspaceData: { search: [], loaded_namespaces: [], globalenv: {} }, - server: { host: '127.0.0.1', port: 1234, token: 'abc' }, - ws: {} as unknown as session.Session['ws'] + pipePath: '', + socket: { destroyed: true, destroy: () => undefined } as unknown as session.Session['socket'] }; await session.activateSession(fakeSession as unknown as session.Session); From 878ee68d18da08027d39d8bef5a55c282be86a01 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Tue, 5 May 2026 05:55:19 +0000 Subject: [PATCH 2/7] chore(deps): remove ws package, use Node.js built-in net module --- package-lock.json | 120 +--------------------------------------------- package.json | 4 +- 2 files changed, 3 insertions(+), 121 deletions(-) diff --git a/package-lock.json b/package-lock.json index a13d75e3..7f586b28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,9 +21,7 @@ "js-yaml": "^4.1.1", "node-fetch": "^2.7.0", "vscode-languageclient": "^9.0.1", - "vsls": "^1.0.4753", - "winreg": "^1.2.5", - "ws": "^8.19.0" + "winreg": "^1.2.5" }, "devDependencies": { "@types/cheerio": "^0.22.35", @@ -38,7 +36,6 @@ "@types/sinon": "^10.0.20", "@types/vscode": "^1.75.0", "@types/winreg": "^1.2.36", - "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vscode/test-cli": "^0.0.12", @@ -877,28 +874,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@microsoft/servicehub-framework": { - "version": "2.6.74", - "resolved": "https://registry.npmjs.org/@microsoft/servicehub-framework/-/servicehub-framework-2.6.74.tgz", - "integrity": "sha512-QJ//zzvxffupIkzupnVbMYY5YDOP+g5FlG6x0Pl7svRyq8pAouiibckJJcZlMtsMypKWwAnVBKb9/sonEOsUxw==", - "license": "LICENSE.txt", - "dependencies": { - "await-semaphore": "^0.1.3", - "msgpack-lite": "^0.1.26", - "nerdbank-streams": "2.5.60", - "strict-event-emitter-types": "^2.0.0", - "vscode-jsonrpc": "^4.0.0" - } - }, - "node_modules/@microsoft/servicehub-framework/node_modules/vscode-jsonrpc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", - "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", - "license": "MIT", - "engines": { - "node": ">=8.0.0 || >=10.0.0" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1647,12 +1622,6 @@ "dev": true, "license": "MIT" }, - "node_modules/await-semaphore": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/await-semaphore/-/await-semaphore-0.1.3.tgz", - "integrity": "sha512-d1W2aNSYcz/sxYO4pMGX9vq65qOTu0P800epMud+6cYYX0QcT7zyqcxec3VWzpgvdXo57UWmVbZpLMjX2m1I7Q==", - "license": "MIT" - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1778,18 +1747,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cancellationtoken": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cancellationtoken/-/cancellationtoken-2.2.0.tgz", - "integrity": "sha512-uF4sHE5uh2VdEZtIRJKGoXAD9jm7bFY0tDRCzH4iLp262TOJ2lrtNHjMG2zc8H+GICOpELIpM7CGW5JeWnb3Hg==", - "license": "MIT" - }, - "node_modules/caught": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/caught/-/caught-0.1.3.tgz", - "integrity": "sha512-DTWI84qfoqHEV5jHRpsKNnEisVCeuBDscXXaXyRLXC+4RD6rFftUNuTElcQ7LeO7w622pfzWkA1f6xu5qEAidw==", - "license": "MIT" - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2746,12 +2703,6 @@ "node": ">=0.10.0" } }, - "node_modules/event-lite": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/event-lite/-/event-lite-0.1.3.tgz", - "integrity": "sha512-8qz9nOz5VeD2z96elrEKD2U433+L3DWdUdDkOINLGOJvx1GsMBbMn0aCeu28y8/e85A6mCigBiFlYMnTBEGlSw==", - "license": "MIT" - }, "node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -3475,26 +3426,6 @@ "node": ">=18.18.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3558,12 +3489,6 @@ "dev": true, "license": "ISC" }, - "node_modules/int64-buffer": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-0.1.10.tgz", - "integrity": "sha512-v7cSY1J8ydZ0GyjUHqF+1bshJ6cnEVLo9EnjB8p+4HDRPZc9N5jjmvUV7NvEsqQOKyH0pmIBFWXVQbiS0+OBbA==", - "license": "MIT" - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3686,6 +3611,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -4213,21 +4139,6 @@ "dev": true, "license": "MIT" }, - "node_modules/msgpack-lite": { - "version": "0.1.26", - "resolved": "https://registry.npmjs.org/msgpack-lite/-/msgpack-lite-0.1.26.tgz", - "integrity": "sha512-SZ2IxeqZ1oRFGo0xFGbvBJWMp3yLIY9rlIJyxy8CGrwZn1f0ZK4r6jV/AM1r0FZMDUkWkglOk/eeKIL9g77Nxw==", - "license": "MIT", - "dependencies": { - "event-lite": "^0.1.1", - "ieee754": "^1.1.8", - "int64-buffer": "^0.1.9", - "isarray": "^1.0.0" - }, - "bin": { - "msgpack": "bin/msgpack" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4242,18 +4153,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nerdbank-streams": { - "version": "2.5.60", - "resolved": "https://registry.npmjs.org/nerdbank-streams/-/nerdbank-streams-2.5.60.tgz", - "integrity": "sha512-saQaMyTtVDAEc+S+BPXKM6K1AF3FyrorFSDzaCkdmtDe2kZzu1aYPQZNLmnxJhxbTcghYrEmYFFoaDxBDVadCw==", - "license": "MIT", - "dependencies": { - "await-semaphore": "^0.1.3", - "cancellationtoken": "^2.0.1", - "caught": "^0.1.3", - "msgpack-lite": "^0.1.26" - } - }, "node_modules/nise": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", @@ -5186,12 +5085,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strict-event-emitter-types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", - "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", - "license": "ISC" - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -5686,15 +5579,6 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "license": "MIT" }, - "node_modules/vsls": { - "version": "1.0.4753", - "resolved": "https://registry.npmjs.org/vsls/-/vsls-1.0.4753.tgz", - "integrity": "sha512-hmrsMbhjuLoU8GgtVfqhbV4ZkGvDpLV2AFmzx+cCOGNra2qk0Q36dYkfwENqy/vJVQ/2/lhxcn+69FYnKQRhgg==", - "license": "SEE LICENSE IN LICENSE.txt", - "dependencies": { - "@microsoft/servicehub-framework": "^2.6.74" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 9c988832..3ac798ae 100644 --- a/package.json +++ b/package.json @@ -2000,7 +2000,6 @@ "@types/sinon": "^10.0.20", "@types/vscode": "^1.75.0", "@types/winreg": "^1.2.36", - "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vscode/test-cli": "^0.0.12", @@ -2026,8 +2025,7 @@ "js-yaml": "^4.1.1", "node-fetch": "^2.7.0", "vscode-languageclient": "^9.0.1", - "winreg": "^1.2.5", - "ws": "^8.19.0" + "winreg": "^1.2.5" }, "extensionDependencies": [ "REditorSupport.r-syntax" From b672439bc921c98c998132f21031f7dc7ad76750 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Tue, 5 May 2026 05:57:11 +0000 Subject: [PATCH 3/7] fix(session): align IPC tests and id handling --- src/session.ts | 2 +- src/test/suite/terminal.test.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/session.ts b/src/session.ts index b58257c6..5744b0f7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -216,7 +216,7 @@ export async function getGlobalPipePath(): Promise { pending.resolve(message.result); } } - } else if (!message.id) { + } else if (message.id === undefined || message.id === null) { await handleNotification(message, socket); } else { await handleRequest(message, socket); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 065b33d4..af3bac1b 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -49,8 +49,7 @@ suite('R Terminal', () => { assert.strictEqual(options.name, 'R Interactive'); assert.ok(options.env); - assert.ok(options.env['SESS_PORT']); - assert.ok(options.env['SESS_TOKEN']); + assert.ok(options.env['SESS_PIPE']); assert.strictEqual(options.env['SESS_RSTUDIOAPI'], 'TRUE'); assert.strictEqual(options.env['SESS_USE_HTTPGD'], 'TRUE'); assert.ok(options.env['R_PROFILE_USER']); @@ -72,7 +71,7 @@ suite('R Terminal', () => { const options = await rTerminal.makeTerminalOptions(); - assert.ok(options.env === undefined || options.env['SESS_PORT'] === undefined); + assert.ok(options.env === undefined || options.env['SESS_PIPE'] === undefined); }); test('createRTerm and restartRTerminal integration test', async () => { From a50867eb6ef7ef28c792d782e7e3a9498c4bb6b0 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Tue, 5 May 2026 05:58:32 +0000 Subject: [PATCH 4/7] docs(sess): update transport docs for pipe-based IPC --- sess/DESCRIPTION | 2 +- sess/README.md | 222 +++++++++++++------------------------- sess/man/connect.Rd | 18 ++-- sess/man/notify_client.Rd | 4 +- sess/man/rpc_send.Rd | 2 +- 5 files changed, 84 insertions(+), 164 deletions(-) diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index c260a06e..e2966117 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -4,7 +4,7 @@ Title: Modern R IPC Server Version: 3.0.0 Author: Gemini Maintainer: Gemini -Description: Implements a high-performance HTTP and WebSocket server for R Inter-Process Communication, replacing legacy file-system watchers. This package provides a generic protocol for IDEs and other clients to communicate with an R session. +Description: Implements a high-performance IPC client for R sessions using Unix domain sockets and Windows named pipes. Replaces legacy file-system watcher based workflows while keeping JSON-RPC communication semantics for IDE/editor integration. License: MIT Encoding: UTF-8 LazyData: true diff --git a/sess/README.md b/sess/README.md index 145ebaf0..3d355b61 100644 --- a/sess/README.md +++ b/sess/README.md @@ -1,188 +1,110 @@ -# `sess`: Modern R IPC Server Protocol +# `sess`: Modern R IPC Protocol -The `sess` package provides a high-performance, token-authenticated IPC (Inter-Process Communication) mechanism between R and a client (such as an IDE or editor extension). It uses a pure **WebSocket** architecture to replace legacy file-based watchers. +The `sess` package provides a lightweight IPC layer between an R session and a client (such as the VS Code R extension). It uses JSON-RPC 2.0 messages over: -## 1. Connection Handshake +- Unix domain sockets (macOS/Linux) +- Windows named pipes -### Starting the Server +## 1. Connection Handshake -The server can be started by calling `sess::sess_app()`: +Start the client connection from R: ```r -sess::sess_app( - port = NULL, # Integer: Server port (random if NULL) - token = NULL, # String: Authentication token (random if NULL) - use_rstudioapi = TRUE, # Logical: Enable RStudio API emulation - use_httpgd = TRUE # Logical: Use httpgd for plotting if available +sess::connect( + pipe_path = NULL, # Character: pipe/socket path. NULL -> SESS_PIPE or session file fallback + use_rstudioapi = TRUE, # Logical: enable rstudioapi emulation + use_httpgd = TRUE # Logical: use httpgd for plotting if available ) ``` -It prints a connection string to the R console: - -```text -[sess] Server address: ws://127.0.0.1:PORT?token=TOKEN -``` - -## 2. Communication Channels - -`sess` uses a pure WebSocket architecture following the **JSON-RPC 2.0** specification for all structured data exchange. The WebSocket connection serves three main purposes: pushing instantaneous events from R to the client via notifications, allowing R to synchronously call client-side methods, and allowing the client to query R state via asynchronous requests. +If `pipe_path` is omitted, `connect()` resolves it in this order: -### 1. Client Notifications (`notify_client`) +1. `SESS_PIPE` environment variable +2. `~/.vscode-R/sessions/{PID}.json` (`pipe` field) -The WebSocket is used for instantaneous events pushed from R to the client as **JSON-RPC Notifications** (no `id`). +After connecting, `sess` sends an `attach` notification with R version, process id, and session metadata. -**Notification Format:** +## 2. Message Transport -```json -{ - "jsonrpc": "2.0", - "method": "method_name", - "params": { ... } -} -``` - -The following methods are sent as notifications from R to the client: - -- **`attach`**: Sent immediately upon connection. Includes PID, R version, and session metadata. -- **`detach`**: Sent when the R session is shutting down (params: `pid`). -- **`dataview`**: Triggered by `View()`. Params include a temporary JSON file path containing the data. -- **`plot_updated`**: Notifies that a new static plot is available. The client should request the `plot_latest` method. -- **`httpgd`**: Provides a URL for an `httpgd` live plot server (params: `url`). -- **`help`**: Requests the client to display an R help page (params: `requestPath`). -- **`browser`**: Requests the client to open a URL (params: `url`, `title`, `viewer`). -- **`webview`**: Requests the client to open a local HTML file or URL in a webview (params: `file`, `title`, `viewer`). -- **`restart_r`**: Requests the client to restart the R session (params: `command`, `clean`). -- **`send_to_console`**: Sends code to the console for execution without blocking the R session (params: `code`, `execute`, `focus`, `animate`). +Transport is NDJSON (newline-delimited JSON). Each line is one JSON-RPC message. -### 2. Synchronous Client Requests (`request_client`) +- R writes JSON-RPC payloads with a trailing `\n` +- R polls the pipe periodically and dispatches complete lines -The `request_client()` function allows R to call client-side functions synchronously by sending a **JSON-RPC Request** (with an `id`) over the WebSocket. This is primarily used to emulate the RStudio API. +The protocol semantics remain JSON-RPC 2.0. -**Coordinate Handling**: The `sess` protocol uses **1-indexed** coordinates for all rows (lines) and columns (characters) on the wire. This aligns with R's internal representation. The client (e.g., VS Code extension) is responsible for converting these to its internal 0-indexed representation if necessary. +### Notifications (`notify_client`) -**Serialization Format**: +R sends JSON-RPC notifications (without `id`) for one-way events such as: -- **Position**: A numeric array `[row, column]`. -- **Range**: An object `{ "start": [row, column], "end": [row, column] }`. +- `attach` +- `dataview` +- `plot_updated` +- `httpgd` +- `help` +- `browser` +- `webview` +- `restart_r` +- `send_to_console` -Below are the JSON-RPC methods sent from R to the client to emulate RStudio API functionality: +### Requests (`request_client`) -- **`rstudioapi/active_editor_context`**: Requests the current context of the active editor. -- **`rstudioapi/replace_text_in_current_selection`**: Replaces text in the current selection (params: `text`, `id`). -- **`rstudioapi/insert_or_modify_text`**: Inserts or modifies text at specific locations (params: `query`, `id`). -- **`rstudioapi/show_dialog`**: Displays a message dialog to the user (params: `message`). -- **`rstudioapi/navigate_to_file`**: Opens and navigates to a specific file, line, and column (params: `file`, `line`, `column`). -- **`rstudioapi/set_selection_ranges`**: Sets the cursor or selection ranges in the editor (params: `ranges`, `id`). -- **`rstudioapi/document_save`**: Saves the specified document (params: `id`). -- **`rstudioapi/get_project_path`**: Retrieves the current project path. -- **`rstudioapi/document_context`**: Retrieves the context of a specific document (params: `id`). -- **`rstudioapi/document_save_all`**: Saves all open documents. -- **`rstudioapi/document_new`**: Creates a new document with specified text and type (params: `text`, `type`, `position`). -- **`rstudioapi/document_close`**: Closes the specified document (params: `id`, `save`). +R can synchronously call client methods (JSON-RPC request with `id`) via `request_client()`. +This is used for RStudio API emulation methods, such as: -### 3. Server Requests (Pull API) +- `rstudioapi/active_editor_context` +- `rstudioapi/replace_text_in_current_selection` +- `rstudioapi/insert_or_modify_text` +- `rstudioapi/show_dialog` +- `rstudioapi/navigate_to_file` +- `rstudioapi/set_selection_ranges` +- `rstudioapi/document_save` +- `rstudioapi/get_project_path` +- `rstudioapi/document_context` +- `rstudioapi/document_save_all` +- `rstudioapi/document_new` +- `rstudioapi/document_close` -The Pull API allows the client to query state using **JSON-RPC Requests** sent over the active WebSocket. - -#### JSON-RPC Request Format - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "method_name", - "params": { ... } -} -``` +### Client Pull Requests -#### Available Methods +The client can request state from R with JSON-RPC requests: -**`workspace`** -Returns object metadata from the Global Environment. - -- **Request Params**: None. -- **Response Result**: - - ```json - { - "globalenv": { - "my_df": { "class": "data.frame", "type": "list", "length": 5, "str": "data.frame: 32 obs. of 11 variables:" } - }, - "search": ["package:stats", "package:graphics"], - "loaded_namespaces": ["sess", "httpuv"] - } - ``` - -**`plot_latest`** -Returns the most recent static plot captured by the R session. - -- **Request Params**: None. -- **Response Result**: `{"data": "iVBORw0KGgoAAAANSUhEUgA..."}` (base64 encoded PNG string). Returns `{"data": null}` if no plot exists. - -**`hover`** - -- **Request Params**: `{"expr": "head(mtcars)"}` -- **Response Result**: `{"str": " 'data.frame': 6 obs. of 11 variables: ..."}` - -**`completion`** - -- **Request Params**: `{"expr": "mtcars", "trigger": "$"}` -- **Response Result**: - - ```json - [ - { "name": "mpg", "type": "double", "str": "numeric" }, - { "name": "cyl", "type": "double", "str": "numeric" } - ] - ``` - ---- +- `workspace` +- `plot_latest` +- `hover` +- `completion` ## 3. Hook Registration & Options -By default, the package does not inject hooks into the R session on load. Calling `sess::sess_app()` will start the server and automatically call `sess::register_hooks()` to enable features like automatic `View()` interception or plot redirection. - -### Intercepted Functions +`connect()` initializes runtime hooks via `register_hooks()`. -- **`utils::View()`**: Redirects data to the client's data viewer. Supports `data.frame`, `matrix`, `list`, and `ArrowTabular` objects. -- **`browser()`**, **`viewer()`**, **`page_viewer()`**: Redirects URLs and HTML files to the client's browser or webview. -- **Help System**: Intercepts help topic printing to route HTML help to the client. +Intercepted features include: -### Global Options +- `utils::View()` +- `browser()`, `viewer()`, `page_viewer()` +- help topic rendering hooks -- **`sess.row_limit`**: Limits the number of rows sent to the data viewer (default: 100). Set to 0 for no limit. -- **`sess.dataview`**: Target viewer column for data (default: `"Two"`). -- **`sess.browser`**: Target viewer for browser (default: `"Active"`). -- **`sess.webview`**: Target viewer for webview (default: `"Two"`). -- **`sess.helpPanel`**: Target viewer for help (default: `"Two"`). +Relevant options include: -## 4. Comparison with Legacy IPC +- `sess.row_limit` +- `sess.dataview` +- `sess.browser` +- `sess.webview` +- `sess.helpPanel` -The `sess` package replaces the legacy file-based IPC mechanism with a modern, in-memory WebSocket architecture using **JSON-RPC 2.0**. +## 4. Connection Discovery -| Feature | Legacy IPC (File-based) | Modern IPC (`sess`) | -| :--- | :--- | :--- | -| **Command Dispatch** | `request.log` + `request.lock` | **WS Notification** (JSON-RPC) | -| **Workspace State** | `workspace.json` + `workspace.lock` | **WS Request `workspace`** (On-demand) | -| **Static Plots** | `plot.png` + `plot.lock` | **WS Notification** + **WS Request `plot_latest`** | -| **RStudio API (Sync)**| `request.log` + `response.lock` | **WS Request** (JSON-RPC) | -| **Client Queries** | Internal HTTP Server (`httpuv`) | **WS Request** (JSON-RPC 2.0) | -| **Transport Reliability**| OS-level File System Watchers | **WebSocket** | -| **Protocol Standard** | Ad-hoc JSON formats | **JSON-RPC 2.0** | +To support VS Code reloads and attach workflows, the extension writes: -### Architectural Shifts +- `~/.vscode-R/sessions/{PID}.json` -1. **Elimination of File Watchers**: Replaces unreliable OS-level file system watchers with persistent WebSocket connections for instantaneous event pushing. -2. **On-Demand Evaluation**: Evaluations of the Global Environment are now performed only when requested by the client, reducing R's background workload. -3. **Unified Standard**: Unifies all structured communication under the **JSON-RPC 2.0** standard across a single WebSocket connection. +`sess::connect()` reads this file as a fallback when direct connection parameters are not provided. -### Connection Discovery & Reconnection +## 5. Legacy IPC Comparison -While `sess` primarily uses WebSockets for low-latency communication, it uses a file-based -discovery mechanism to handle VS Code window reloads and support manual connections: +Compared with legacy file-watcher IPC, `sess` provides: -1. **Initial Connection**: VS Code passes `SESS_PORT` and `SESS_TOKEN` environment variables when launching R. R connects directly. -2. **Discovery File**: VS Code writes the current connection info to `~/.vscode-R/sessions/{PID}.json`. -3. **Reconnection**: If disconnected (e.g., after a window reload), R retries connecting by - reading the file to find the new port and token. Automatic reconnection is skipped - for manual connections (where port/token were passed as arguments). +- JSON-RPC 2.0 for structured messaging +- socket/pipe transport instead of lock-file command channels +- on-demand workspace queries +- lower background churn and fewer file watch races diff --git a/sess/man/connect.Rd b/sess/man/connect.Rd index c88d9c82..801ef873 100644 --- a/sess/man/connect.Rd +++ b/sess/man/connect.Rd @@ -2,21 +2,19 @@ % Please edit documentation in R/server.R \name{connect} \alias{connect} -\title{Start the client R IPC connection} +\title{Connect to the VS Code IPC server} \usage{ -connect(port = NULL, token = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE) +connect(pipe_path = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE) } \arguments{ -\item{port}{Integer. The port of the VS Code WebSocket server. -If NULL, it will use SESS_PORT env var.} +\item{pipe_path}{Character. Path to the named pipe / Unix domain socket. +If NULL, uses the SESS_PIPE environment variable, then falls back to the +session JSON file written by the extension.} -\item{token}{String. The authentication token. If NULL, it will use SESS_TOKEN env var.} +\item{use_rstudioapi}{Logical. Enable rstudioapi emulation. Defaults to TRUE.} -\item{use_rstudioapi}{Logical. Should the rstudioapi emulation layer -be enabled? Defaults to TRUE.} - -\item{use_httpgd}{Logical. Should httpgd be used for plotting if available? Defaults to TRUE} +\item{use_httpgd}{Logical. Use httpgd for plotting if available. Defaults to TRUE.} } \description{ -Start the client R IPC connection +Connect to the VS Code IPC server } diff --git a/sess/man/notify_client.Rd b/sess/man/notify_client.Rd index 1545127e..9799e6c5 100644 --- a/sess/man/notify_client.Rd +++ b/sess/man/notify_client.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/dispatch.R \name{notify_client} \alias{notify_client} -\title{Notify the client via WebSocket (JSON-RPC 2.0 Notification)} +\title{Notify the client via IPC pipe (JSON-RPC 2.0 Notification)} \usage{ notify_client(method, params = list()) } @@ -12,5 +12,5 @@ notify_client(method, params = list()) \item{params}{A list containing the arguments for the command} } \description{ -Pushes an event instantly to the client extension via the active WebSocket connection. +Pushes an event instantly to the client extension via the active IPC pipe connection. } diff --git a/sess/man/rpc_send.Rd b/sess/man/rpc_send.Rd index 7404dcfd..a559ebb8 100644 --- a/sess/man/rpc_send.Rd +++ b/sess/man/rpc_send.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/dispatch.R \name{rpc_send} \alias{rpc_send} -\title{Send a message to the client via WebSocket (JSON-RPC 2.0)} +\title{Send a message to the client via IPC pipe (JSON-RPC 2.0)} \usage{ rpc_send(method, params = list(), request = FALSE) } From b62c97971d463d3e194f5e466235aa244595ee97 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Tue, 5 May 2026 06:07:00 +0000 Subject: [PATCH 5/7] fix(sess): normalize Windows named pipe path for processx --- sess/R/server.R | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sess/R/server.R b/sess/R/server.R index 7d92a0b9..06918b7f 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -41,6 +41,14 @@ connect <- function(pipe_path = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE) return(invisible(NULL)) } + # processx uses the \\?\pipe\ namespace on Windows. + # Normalize \\.\pipe\* paths from Node.js to improve compatibility. + if (.Platform$OS.type == "windows") { + if (startsWith(pipe_path, "\\\\.\\pipe\\")) { + pipe_path <- sub("^\\\\\\\\\\.\\\\pipe\\\\", "\\\\\\\\?\\\\pipe\\\\", pipe_path) + } + } + print_async_msg <- function(msg) { prompt <- if (interactive()) getOption("prompt") else "" cat(sprintf("\r%s\n\n%s", msg, prompt)) From 70692c2332c4376a5868e60875c7bfc3028d1ee6 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Tue, 5 May 2026 06:12:47 +0000 Subject: [PATCH 6/7] fix(sess): handle partial conn_write for large IPC payloads --- sess/R/dispatch.R | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sess/R/dispatch.R b/sess/R/dispatch.R index ec5a4bbc..1bc26021 100644 --- a/sess/R/dispatch.R +++ b/sess/R/dispatch.R @@ -7,7 +7,14 @@ ipc_write <- function(data) { line <- paste0(jsonlite::toJSON(data, auto_unbox = TRUE, null = "null", force = TRUE), "\n") tryCatch( { - processx::conn_write(con, line) + remainder <- processx::conn_write(con, line) + + # processx::conn_write() may perform a partial write and return + # remaining bytes; keep writing until all data is flushed. + while (is.raw(remainder) && length(remainder) > 0) { + remainder <- processx::conn_write(con, remainder) + } + invisible(TRUE) }, error = function(e) { From fdc193b2260dad89c90a5aa62a1bb5128ba78e91 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Tue, 5 May 2026 06:15:59 +0000 Subject: [PATCH 7/7] docs(sess): restore protocol detail in README --- sess/README.md | 210 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 181 insertions(+), 29 deletions(-) diff --git a/sess/README.md b/sess/README.md index 3d355b61..60ca932e 100644 --- a/sess/README.md +++ b/sess/README.md @@ -1,10 +1,17 @@ # `sess`: Modern R IPC Protocol -The `sess` package provides a lightweight IPC layer between an R session and a client (such as the VS Code R extension). It uses JSON-RPC 2.0 messages over: +The `sess` package provides an IPC layer between an R session and a client (such as the VS Code R extension). + +Transport: - Unix domain sockets (macOS/Linux) - Windows named pipes +Protocol: + +- JSON-RPC 2.0 messages +- JSON Lines (JSONL, newline-delimited JSON) framing (one JSON message per line) + ## 1. Connection Handshake Start the client connection from R: @@ -22,20 +29,86 @@ If `pipe_path` is omitted, `connect()` resolves it in this order: 1. `SESS_PIPE` environment variable 2. `~/.vscode-R/sessions/{PID}.json` (`pipe` field) -After connecting, `sess` sends an `attach` notification with R version, process id, and session metadata. +After connecting, `sess` sends an `attach` notification. + +Example: + +```json +{ + "jsonrpc": "2.0", + "method": "attach", + "params": { + "version": "4.5.0", + "pid": 12345, + "tempdir": "/tmp/Rtmp.../sess", + "wd": "/path/to/project", + "info": { + "command": "/usr/bin/R", + "version": "R version 4.5.0 (...) ", + "start_time": "2026-05-05 06:00:00" + } + } +} +``` + +## 2. Message Transport and Framing + +Transport uses JSON Lines (JSONL, newline-delimited JSON): -## 2. Message Transport +- sender writes one JSON-RPC object + `\n` +- receiver buffers stream chunks and dispatches complete lines only -Transport is NDJSON (newline-delimited JSON). Each line is one JSON-RPC message. +This preserves JSON-RPC semantics while handling stream fragmentation safely. -- R writes JSON-RPC payloads with a trailing `\n` -- R polls the pipe periodically and dispatches complete lines +## 3. JSON-RPC Message Types -The protocol semantics remain JSON-RPC 2.0. +### Notification (one-way) -### Notifications (`notify_client`) +```json +{ + "jsonrpc": "2.0", + "method": "method_name", + "params": {} +} +``` + +### Request (expects response) + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "method_name", + "params": {} +} +``` + +### Response (success) -R sends JSON-RPC notifications (without `id`) for one-way events such as: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": {} +} +``` + +### Response (error) + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32601, + "message": "Method not found" + } +} +``` + +## 4. Notifications from R to Client + +`notify_client()` sends one-way events (no `id`), including: - `attach` - `dataview` @@ -47,10 +120,11 @@ R sends JSON-RPC notifications (without `id`) for one-way events such as: - `restart_r` - `send_to_console` -### Requests (`request_client`) +## 5. Requests from R to Client (`request_client`) + +`request_client()` sends JSON-RPC requests and waits for matching response `id`. -R can synchronously call client methods (JSON-RPC request with `id`) via `request_client()`. -This is used for RStudio API emulation methods, such as: +Used by RStudio API emulation methods, such as: - `rstudioapi/active_editor_context` - `rstudioapi/replace_text_in_current_selection` @@ -65,16 +139,89 @@ This is used for RStudio API emulation methods, such as: - `rstudioapi/document_new` - `rstudioapi/document_close` -### Client Pull Requests +Coordinate convention on the wire: + +- rows/columns are 1-indexed (R-style) +- client may convert to internal 0-indexed representation + +## 6. Requests from Client to R (Pull API) + +Client queries R state through JSON-RPC requests. + +### `workspace` + +Request: + +```json +{"jsonrpc":"2.0","id":1,"method":"workspace","params":{}} +``` + +Response (example): + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "globalenv": { + "my_df": {"class": ["data.frame"], "type": "list", "length": 11} + }, + "search": ["package:stats", "package:graphics"], + "loaded_namespaces": ["sess", "utils"] + } +} +``` + +### `plot_latest` + +Request params example: + +```json +{"width":800,"height":600,"format":"svglite"} +``` + +Response example: + +```json +{"jsonrpc":"2.0","id":2,"result":{"format":"svglite","data":""}} +``` + +### `hover` -The client can request state from R with JSON-RPC requests: +Request params example: -- `workspace` -- `plot_latest` -- `hover` -- `completion` +```json +{"expr":"head(mtcars)"} +``` -## 3. Hook Registration & Options +Response example: + +```json +{"jsonrpc":"2.0","id":3,"result":{"str":"'data.frame': 6 obs. ..."}} +``` + +### `completion` + +Request params example: + +```json +{"expr":"mtcars","trigger":"$"} +``` + +Response example: + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": [ + {"name":"mpg","type":"double","str":"numeric"}, + {"name":"cyl","type":"double","str":"numeric"} + ] +} +``` + +## 7. Hook Registration and Options `connect()` initializes runtime hooks via `register_hooks()`. @@ -84,7 +231,7 @@ Intercepted features include: - `browser()`, `viewer()`, `page_viewer()` - help topic rendering hooks -Relevant options include: +Relevant options: - `sess.row_limit` - `sess.dataview` @@ -92,19 +239,24 @@ Relevant options include: - `sess.webview` - `sess.helpPanel` -## 4. Connection Discovery +## 8. Discovery File -To support VS Code reloads and attach workflows, the extension writes: +To support reloads and attach workflows, the extension writes: - `~/.vscode-R/sessions/{PID}.json` -`sess::connect()` reads this file as a fallback when direct connection parameters are not provided. +`sess::connect()` reads this file as fallback when direct pipe parameters are unavailable. + +## 9. What Changed from the WebSocket Transport + +Changed: -## 5. Legacy IPC Comparison +- transport is now UDS / named pipe +- framing is JSON Lines (JSONL) over stream sockets +- authentication token exchange is removed -Compared with legacy file-watcher IPC, `sess` provides: +Unchanged: -- JSON-RPC 2.0 for structured messaging -- socket/pipe transport instead of lock-file command channels -- on-demand workspace queries -- lower background churn and fewer file watch races +- JSON-RPC method names and payload shapes +- request/response correlation by `id` +- high-level feature behavior (workspace, hover, completion, plot, dataview, RStudio API emulation)