diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66eb4ba69..2ab3958c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,10 +42,15 @@ it to avoid repeating preparation when installed through remotes. `Config/vscode-R/source-revision` controls installation independently of package versions and the runtime `protocol_version` handshake. vscode-R installs only the bundled snapshot; missing Imports come from the configured repository. -Missing or different installed metadata requires the bundle, and installation -verifies the exact source revision and visibility through `.libPaths()`. Run the -source/bootstrap checks with `pnpm run test:sess-source`, or just the -base-R identity checks with `pnpm run test:sess-identity`. +Ordinary terminals and manual attach ask before installing a missing or different +copy. They install into an extension-managed library, keep normal `.libPaths()` +unchanged, and explicitly load the matching namespace; exact matches in normal +libraries remain usable. Preparation and installation use the target R's +platform, version, and normal libraries; when needed, R asks the extension for +consent. R Interactive prepares its separate runtime as part of starting an +Interactive session. Run the source/bootstrap checks with +`pnpm run test:sess-source`, or just the base-R identity checks with +`pnpm run test:sess-identity`. ## Testing R Interactive diff --git a/R/attach_sess.R b/R/attach_sess.R new file mode 100644 index 000000000..2f23f844c --- /dev/null +++ b/R/attach_sess.R @@ -0,0 +1,223 @@ +# Shared terminal and manual-attach preparation. Installation requires a +# single-use grant from the extension process; .libPaths() is never changed. +vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, + source_helper, installer_helper, + timeout_seconds = 180, + setup_timeout_seconds = 300) { + source(source_helper, local = TRUE) + expected <- sess_source_revision(file.path(pkg_path, "DESCRIPTION")) + if (is.null(expected)) { + stop("Bundled sess has no valid source revision.") + } + runtime <- sess_runtime_identity() + managed_library <- sess_managed_library(managed_root, expected) + + loaded <- "sess" %in% loadedNamespaces() + if (loaded) { + loaded_revision <- sess_loaded_source_revision() + if (!identical(loaded_revision, expected)) { + stop("A different sess namespace is already loaded. Restart R before attaching the session watcher.") + } + ns <- asNamespace("sess") + } else { + library <- sess_find_source_library(expected, .libPaths()) + if (!is.null(library)) { + ns <- sess_load_namespace(library, expected) + } else { + lock_parent <- dirname(managed_library) + dir.create(lock_parent, recursive = TRUE, showWarnings = FALSE) + if (!dir.exists(lock_parent) || file.access(lock_parent, 2L) != 0L) { + stop("The vscode-R managed sess setup directory is not writable.") + } + lock_path <- file.path(lock_parent, ".setup-lock") + # Keep lock ownership rules in sync with src/interactive/backends/sessPreparation.ts: + # mkdir claims atomically; only the owner releases via on.exit; timeout never clears a stale lock. + # This revision lock covers consent, install, and load; a follower without a ready copy does not prompt. + # Publish .ready with the source revision only after the exact namespace has loaded successfully. + lock_deadline <- Sys.time() + setup_timeout_seconds + lock_timeout_message <- paste( + "Timed out waiting for another sess setup. Its owner may have crashed;", + "retrying alone will not clear the stale lock. Remove", + shQuote(lock_path), "only if its owner has exited, then retry." + ) + followed_setup <- FALSE + repeat { + acquired <- dir.create(lock_path, showWarnings = FALSE, mode = "0700") + if (isTRUE(acquired)) { + on.exit(unlink(lock_path, recursive = TRUE, force = TRUE), add = TRUE) + break + } + followed_setup <- TRUE + if (!file.exists(lock_path)) { + if (file.access(lock_parent, 2L) != 0L) { + stop("The vscode-R managed sess setup directory is not writable.") + } + if (Sys.time() >= lock_deadline) { + stop(lock_timeout_message) + } + Sys.sleep(0.1) + next + } + if (!dir.exists(lock_path)) { + stop("A file is blocking the vscode-R managed sess setup lock.") + } + if (Sys.time() >= lock_deadline) { + stop(lock_timeout_message) + } + Sys.sleep(0.1) + } + + ready_path <- file.path(lock_parent, ".ready") + ready_revision <- tryCatch( + readLines(ready_path, warn = FALSE, n = 2L), + warning = function(e) character(), + error = function(e) character() + ) + installed <- sess_find_source_library(expected, managed_library) + if (length(ready_revision) == 1L && identical(ready_revision, expected) && + !is.null(installed)) { + ns <- sess_load_namespace(installed, expected) + } else if (followed_setup) { + return(NULL) + } else { + existing <- any(vapply(.libPaths(), function(library) { + file.exists(file.path(library, "sess", "DESCRIPTION")) + }, FALSE)) + reason <- if (existing) { + "mismatch" + } else { + "missing" + } + if (!dir.exists(consent_dir)) { + stop("The extension's sess consent service is unavailable. Restart VS Code and try again.") + } + new_id_part <- function() { + temporary <- basename(tempfile(pattern = "request-", tmpdir = consent_dir)) + gsub("[^A-Za-z0-9_-]", "", sub("^request-", "", temporary)) + } + id <- paste0(new_id_part(), new_id_part()) + if (!grepl("^[A-Za-z0-9_-]{16,64}$", id)) { + stop("Could not create a unique sess installation request.") + } + request <- paste( + "vscode-r-sess-consent-v1", id, expected, runtime, reason, sep = "\n") + request_path <- file.path(consent_dir, paste0(id, ".request")) + response_path <- file.path(consent_dir, paste0(id, ".response")) + temporary_path <- tempfile(pattern = paste0(id, "-"), tmpdir = consent_dir) + on.exit(unlink(c(temporary_path, request_path, response_path)), add = TRUE) + writeLines(request, temporary_path, useBytes = TRUE) + if (.Platform$OS.type == "unix") { + Sys.chmod(temporary_path, "0600") + } + if (!file.rename(temporary_path, request_path)) { + stop("Could not request permission to install bundled sess.") + } + + deadline <- Sys.time() + timeout_seconds + response <- "" + while (Sys.time() < deadline && dir.exists(consent_dir) && !nzchar(response)) { + if (file.exists(response_path)) { + lines <- tryCatch( + readLines(response_path, warn = FALSE, n = 2L), + error = function(e) character()) + if (length(lines) == 1L && lines %in% c("approve", "decline")) { + response <- lines + } else { + stop("Invalid response to the sess installation request.") + } + } else { + Sys.sleep(0.2) + } + } + if (!identical(response, "approve")) { + message("Bundled sess was not installed. The session watcher was not attached.") + return(NULL) + } + + unlink(ready_path, force = TRUE) + ready_link <- Sys.readlink(ready_path) + if (file.exists(ready_path) || dir.exists(ready_path) || + (length(ready_link) && !is.na(ready_link) && nzchar(ready_link))) { + stop("Could not clear the previous vscode-R managed sess completion marker.") + } + configured <- getOption("repos") + repo <- if ("CRAN" %in% names(configured)) { + configured[["CRAN"]] + } else if (length(configured)) { + configured[[1L]] + } else { + "https://cloud.r-project.org" + } + if (!length(repo) || is.na(repo) || !nzchar(repo) || identical(repo, "@CRAN@")) { + repo <- "https://cloud.r-project.org" + } + dir.create(managed_library, recursive = TRUE, showWarnings = FALSE) + if (file.access(managed_library, 2L) != 0L) { + stop("The vscode-R managed sess library is not writable.") + } + installer <- new.env(parent = baseenv()) + sys.source(installer_helper, envir = installer) + installer$sess_install(pkg_path, managed_library, repo) + ns <- sess_load_namespace(managed_library, expected) + ready_temporary <- tempfile(pattern = ".ready-", tmpdir = lock_parent) + on.exit(unlink(ready_temporary), add = TRUE) + writeLines(expected, ready_temporary, useBytes = TRUE) + if (.Platform$OS.type == "unix") { + Sys.chmod(ready_temporary, "0600") + } + if (!file.rename(ready_temporary, ready_path)) { + stop("Could not publish the vscode-R managed sess completion marker.") + } + } + } + } + + ns +} + +vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, + source_helper, installer_helper, plot_backend, + timeout_seconds = 180, + setup_timeout_seconds = 300) { + registered <- getOption("vscodeR.terminalStartup") + profile_process <- is.list(registered) && identical(registered$pid, Sys.getpid()) + startup_context <- NULL + if (profile_process) { + notifier_available <- exists("vscode_r_startup_existing", mode = "function") && + exists("vscode_r_startup_run", mode = "function") + if (!notifier_available) { + startup_helper <- Sys.getenv("VSCODE_R_SESS_STARTUP_HELPER", unset = "") + if (nzchar(startup_helper) && file.exists(startup_helper)) { + tryCatch(source(startup_helper, local = TRUE), error = function(error) { + message("vscode-R could not load terminal startup notifier: ", conditionMessage(error)) + }) + } + notifier_available <- exists("vscode_r_startup_existing", mode = "function") && + exists("vscode_r_startup_run", mode = "function") + } + if (!notifier_available) { + message("vscode-R terminal startup notifier is unavailable; the session watcher was not attached.") + return(invisible(FALSE)) + } + startup_context <- vscode_r_startup_existing(endpoint) + if (is.null(startup_context)) { + message("vscode-R could not validate terminal startup status; the session watcher was not attached.") + return(invisible(FALSE)) + } + } + + attach <- function() { + ns <- vscode_r_prepare_sess(pkg_path, managed_root, consent_dir, source_helper, installer_helper, + timeout_seconds, setup_timeout_seconds) + if (is.null(ns)) { + return(invisible(FALSE)) + } + connect <- get("connect", envir = ns, inherits = FALSE) + connect(endpoint = endpoint, plot_backend = plot_backend) + } + + if (profile_process) { + return(vscode_r_startup_run(startup_context, attach)) + } + attach() +} diff --git a/R/install_sess.R b/R/install_sess.R index 700baeed2..b61d4bd2d 100644 --- a/R/install_sess.R +++ b/R/install_sess.R @@ -1,18 +1,14 @@ local({ args <- commandArgs(trailingOnly = TRUE) - pkg_path <- Sys.getenv("VSCODE_R_SESS_PKG_PATH", unset = "") - if (!nzchar(pkg_path) && length(args) >= 1) { - pkg_path <- args[1] - } + pkg_path <- if (length(args) >= 1L && nzchar(args[1L])) args[1L] else + Sys.getenv("VSCODE_R_SESS_PKG_PATH", unset = "") if (!nzchar(pkg_path)) { stop("Missing pkg_path (set VSCODE_R_SESS_PKG_PATH or pass as first command arg)") } - repo <- Sys.getenv("VSCODE_R_SESS_REPO", unset = "") - if (!nzchar(repo) && length(args) >= 2) { - repo <- args[2] - } + repo <- if (length(args) >= 2L && nzchar(args[2L])) args[2L] else + Sys.getenv("VSCODE_R_SESS_REPO", unset = "") if (!nzchar(repo)) { configured <- getOption("repos") repo <- if ("CRAN" %in% names(configured)) { @@ -23,6 +19,7 @@ local({ "" } } + library_arg <- if (length(args) >= 3L) args[3L] else "" if (!length(repo) || is.na(repo) || !nzchar(repo) || identical(repo, "@CRAN@")) { repo <- "https://cloud.r-project.org" } @@ -51,28 +48,17 @@ local({ stop("Bundled sess has no valid source revision. Rebuild or reinstall the vscode-R extension.") } - library <- Sys.getenv("VSCODE_R_SESS_LIBRARY", unset = "") - private_library <- nzchar(library) - if (!private_library) { - library <- .libPaths()[1L] - if (file.access(library, 2L) != 0L) { - user_library <- strsplit(Sys.getenv("R_LIBS_USER"), .Platform$path.sep, fixed = TRUE)[[1L]] - if (!length(user_library) || !nzchar(user_library[[1L]])) stop("No writable R library is available.") - library <- path.expand(user_library[[1L]]) - } + library <- if (nzchar(library_arg)) library_arg else + Sys.getenv("VSCODE_R_SESS_LIBRARY", unset = "") + if (!nzchar(library)) { + stop("Missing VSCODE_R_SESS_LIBRARY. Refusing to install sess into a default R library.") } dir.create(library, recursive = TRUE, showWarnings = FALSE) if (file.access(library, 2L) != 0L) stop(paste("R library is not writable:", library)) - if (!private_library) .libPaths(c(library, .libPaths())) installer <- new.env(parent = baseenv()) sys.source(file.path(script_directory, "sess-package-install.R"), envir = installer) installer$sess_install(pkg_path, library, repo, interactive = identical(Sys.getenv("VSCODE_R_SESS_INTERACTIVE"), "1")) - # Ordinary terminals must see the bundled source through their search path. - # Interactive uses its isolated library and the shared API/source verifier. - if (!private_library && !identical(sess_installed_source_revision(), expected_revision)) { - stop("sess installation did not make the bundled source available in .libPaths(). Check the installation log.") - } if ("sess" %in% loadedNamespaces()) { message("sess was already loaded. Restart R to use the newly installed source.") } diff --git a/R/profile.R b/R/profile.R index b2ed84b39..cec75ef30 100644 --- a/R/profile.R +++ b/R/profile.R @@ -24,12 +24,75 @@ local({ invisible() }) -if (requireNamespace("sess", quietly = TRUE)) { - local({ +local({ + initialize_sess <- function() { + bundled_path <- Sys.getenv("VSCODE_R_SESS_PKG_PATH", unset = "") + if (!nzchar(bundled_path)) { + return(invisible(FALSE)) + } + + helper <- Sys.getenv("VSCODE_R_SESS_SOURCE_HELPER", unset = "") + attach_helper <- Sys.getenv("VSCODE_R_SESS_ATTACH_HELPER", unset = "") + installer_helper <- Sys.getenv("VSCODE_R_SESS_INSTALLER_HELPER", unset = "") + managed_root <- Sys.getenv("VSCODE_R_SESS_ROOT", unset = "") + consent_directory <- Sys.getenv("VSCODE_R_SESS_CONSENT_DIRECTORY", unset = "") + resources <- c(helper, attach_helper, installer_helper) + resources_ok <- all(nzchar(c(resources, managed_root))) && + all(file.exists(resources)) + if (!resources_ok) { + message("vscode-R could not locate its sess setup resources; the session watcher was not started.") + return(invisible(FALSE)) + } + source(attach_helper, local = TRUE) + ns <- vscode_r_prepare_sess(bundled_path, managed_root, consent_directory, helper, installer_helper) + if (is.null(ns)) { + return(invisible(FALSE)) + } + plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") - sess::connect( - use_rstudioapi = as.logical(Sys.getenv("SESS_RSTUDIOAPI", "TRUE")), - plot_backend = plot_backend - ) - }) -} + connect <- get("connect", envir = ns, inherits = FALSE) + use_rstudioapi <- as.logical(Sys.getenv("SESS_RSTUDIOAPI", "TRUE")) + result <- connect(use_rstudioapi = use_rstudioapi, plot_backend = plot_backend) + isTRUE(result) + } + + startup_file <- Sys.getenv("VSCODE_R_SESS_STARTUP_FILE", unset = "") + startup_context <- NULL + startup_allowed <- TRUE + if (nzchar(startup_file)) { + startup_helper <- Sys.getenv("VSCODE_R_SESS_STARTUP_HELPER", unset = "") + startup_token <- Sys.getenv("VSCODE_R_SESS_STARTUP_TOKEN", unset = "") + startup_endpoint <- Sys.getenv("VSCODE_R_SESS_STARTUP_ENDPOINT", unset = "") + if (!nzchar(startup_helper) || !file.exists(startup_helper)) { + message("vscode-R could not locate its terminal startup notifier; the session watcher was not started.") + startup_allowed <- FALSE + } else { + tryCatch({ + sys.source(startup_helper, envir = environment()) + startup_context <- vscode_r_startup_register(startup_file, startup_token, startup_endpoint) + }, error = function(error) { + message("vscode-R could not register terminal startup status: ", conditionMessage(error)) + }) + if (is.null(startup_context)) { + message("vscode-R could not register terminal startup status; the session watcher was not started.") + startup_allowed <- FALSE + } + } + } + + if (startup_allowed) { + tryCatch({ + if (nzchar(startup_file)) { + vscode_r_startup_run(startup_context, initialize_sess) + } else { + initialize_sess() + } + }, error = function(error) { + message("vscode-R could not start the session watcher: ", conditionMessage(error)) + invisible(FALSE) + }, interrupt = function(error) { + message("vscode-R session watcher startup was interrupted: ", conditionMessage(error)) + invisible(FALSE) + }) + } +}) diff --git a/R/sess_source.R b/R/sess_source.R index a8202aed7..499470d16 100644 --- a/R/sess_source.R +++ b/R/sess_source.R @@ -10,14 +10,86 @@ sess_source_revision <- function(description_path) { }, error = function(e) NULL) } -sess_installed_source_revision <- function() { +sess_runtime_identity <- function() { + minor <- strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L] + version <- paste(R.version$major, minor, sep = ".") + if (!grepl("^[A-Za-z0-9_.-]+$", R.version$platform) || !grepl("^[0-9]+\\.[0-9]+$", version)) { + stop("Unsupported R platform or version for managed sess library.") + } + paste(R.version$platform, version, sep = "|") +} + +sess_managed_library <- function(root, revision) { + identity <- strsplit(sess_runtime_identity(), "|", fixed = TRUE)[[1L]] + if (length(identity) != 2L || !grepl("^git-tree:([a-f0-9]{40}|[a-f0-9]{64})$", revision)) { + stop("Cannot determine the managed sess library identity.") + } + file.path(root, identity[[1L]], identity[[2L]], sub("^git-tree:", "", revision), "library") +} + +sess_installed_source_revision <- function(lib.loc = .libPaths()) { # Explicit lib.loc reads the copy selected by .libPaths(), even if a different # copy is already loaded. Do not load sess just to inspect its DESCRIPTION. - pkg <- find.package("sess", lib.loc = .libPaths(), quiet = TRUE) - if (!length(pkg)) { + for (library in lib.loc) { + pkg <- file.path(library, "sess") + if (file.exists(file.path(pkg, "DESCRIPTION"))) { + revision <- sess_source_revision(file.path(pkg, "DESCRIPTION")) + if (!is.null(revision)) { + return(revision) + } + } + } + NULL +} + +sess_has_source_revision <- function(revision, lib.loc = .libPaths()) { + !is.null(sess_find_source_library(revision, lib.loc)) +} + +sess_find_source_library <- function(revision, lib.loc = .libPaths()) { + for (library in lib.loc) { + pkg <- file.path(library, "sess") + if (file.exists(file.path(pkg, "DESCRIPTION")) && + identical(sess_source_revision(file.path(pkg, "DESCRIPTION")), revision)) { + return(library) + } + } + NULL +} + +sess_loaded_source_revision <- function() { + if (!("sess" %in% loadedNamespaces())) { return(NULL) } - sess_source_revision(file.path(pkg, "DESCRIPTION")) + path <- getNamespaceInfo(asNamespace("sess"), "path") + sess_source_revision(file.path(path, "DESCRIPTION")) +} + +sess_load_namespace <- function(library, revision, normal_libraries = .libPaths()) { + paths <- unique(c(library, normal_libraries)) + support_paths <- unique(c(normal_libraries, library)) + description <- read.dcf(file.path(library, "sess", "DESCRIPTION")) + imports <- if ("Imports" %in% colnames(description)) { + trimws(gsub("\\s*\\(.*\\)", "", unlist(strsplit(description[1L, "Imports"], ",")))) + } else { + character() + } + # processx may load ps during .onLoad. Load it first so dependencies found + # only in the managed library remain visible without changing .libPaths(). + if ("processx" %in% imports) { + loadNamespace("ps", lib.loc = support_paths) + } + for (package in intersect(c("jsonlite", "later", "processx", "rstudioapi"), imports)) { + loadNamespace(package, lib.loc = support_paths) + } + ns <- loadNamespace("sess", lib.loc = paths) + actual_path <- normalizePath(getNamespaceInfo(ns, "path"), winslash = "/", mustWork = TRUE) + intended_path <- normalizePath(file.path(library, "sess"), winslash = "/", mustWork = TRUE) + actual_revision <- sess_source_revision(file.path(actual_path, "DESCRIPTION")) + if (!identical(actual_path, intended_path) || !identical(actual_revision, revision)) { + stop("Loaded sess does not match the selected vscode-R source. Restart R before attaching the session watcher.") + } + ns } sess_install_required <- function(pkg_path) { @@ -25,5 +97,5 @@ sess_install_required <- function(pkg_path) { if (is.null(bundled)) { stop("Bundled sess has no valid source revision. Rebuild or reinstall the vscode-R extension.") } - !identical(sess_installed_source_revision(), bundled) + !sess_has_source_revision(bundled) } diff --git a/R/terminal-startup.R b/R/terminal-startup.R new file mode 100644 index 000000000..d9dc035ac --- /dev/null +++ b/R/terminal-startup.R @@ -0,0 +1,156 @@ +# Base-R-only notification for one managed terminal startup attempt. +# Keep the wire format in sync with src/terminalStartup.ts: protocol, terminal +# token, attempt ID, originating R PID, endpoint, and state. +.vscode_startup_option <- "vscodeR.terminalStartup" +.vscode_startup_protocol <- "vscode-r-terminal-startup-v1" + +.vscode_startup_valid <- function(file, token) { + length(file) == 1L && !is.na(file) && nzchar(file) && + length(token) == 1L && !is.na(token) && grepl("^[[:xdigit:]]{32}$", token) +} + +.vscode_startup_valid_endpoint <- function(endpoint) { + length(endpoint) == 1L && !is.na(endpoint) && nzchar(endpoint) && + !grepl("[\r\n]", endpoint) +} + +.vscode_startup_registered <- function(context) { + registered <- getOption(.vscode_startup_option) + is.list(registered) && is.list(context) && + identical(registered$file, context$file) && + identical(registered$token, context$token) && + identical(registered$pid, Sys.getpid()) && + identical(context$pid, Sys.getpid()) && + is.function(registered$run) && identical(registered$run, context$run) && + .vscode_startup_valid_endpoint(context$endpoint) +} + +.vscode_startup_write <- function(context, attempt, state) { + lines <- c(.vscode_startup_protocol, context$token, attempt, + as.character(context$pid), context$endpoint, state) + temporary <- "" + on.exit(if (nzchar(temporary)) unlink(temporary), add = TRUE) + tryCatch(suppressWarnings({ + directory <- dirname(context$file) + if (!dir.exists(directory)) { + return(FALSE) + } + temporary <- tempfile(pattern = ".startup-", tmpdir = directory) + writeLines(lines, temporary, useBytes = TRUE) + if (.Platform$OS.type == "unix") { + Sys.chmod(temporary, "0600") + } + isTRUE(file.rename(temporary, context$file)) + }), error = function(error) FALSE) +} + +# Called only by the managed terminal profile. The option is process-local so +# inherited environment variables cannot authorize a child R to update a parent. +vscode_r_startup_register <- function(file, token, endpoint) { + if (!.vscode_startup_valid(file, token) || !.vscode_startup_valid_endpoint(endpoint)) { + return(NULL) + } + context <- list( + file = file, + token = tolower(token), + endpoint = endpoint, + pid = Sys.getpid(), + attempt = NULL + ) + context$run <- function(endpoint, setup) { + current <- vscode_r_startup_existing(endpoint) + if (is.null(current)) { + return(invisible(FALSE)) + } + vscode_r_startup_run(current, setup) + } + options(structure(list(context), names = .vscode_startup_option)) + context +} + +# Manual attach reuses the immutable profile identity while recording its current +# endpoint for this attempt. The endpoint may differ after a reconnect. +vscode_r_startup_existing <- function(endpoint) { + if (!.vscode_startup_valid_endpoint(endpoint)) { + return(NULL) + } + registered <- getOption(.vscode_startup_option) + if (!is.list(registered)) { + return(NULL) + } + if (!.vscode_startup_valid(registered$file, registered$token)) { + return(NULL) + } + if (!identical(registered$pid, Sys.getpid())) { + return(NULL) + } + if (!is.function(registered$run)) { + return(NULL) + } + registered$endpoint <- endpoint + registered +} + +# Publish pending before doing any package preparation or IPC work. A failed +# publish returns NULL, leaving callers free to keep ordinary R usable. +vscode_r_startup_begin <- function(context) { + if (!.vscode_startup_registered(context)) { + return(NULL) + } + candidate <- gsub("[^A-Za-z0-9_-]", "", basename(tempfile(pattern = "attempt-"))) + candidate <- paste0(candidate, substr(context$token, 1L, 16L)) + attempt <- substr(candidate, 1L, 64L) + if (!grepl("^[A-Za-z0-9_-]{16,64}$", attempt)) { + message("vscode-R could not create a terminal startup attempt ID.") + return(NULL) + } + if (!.vscode_startup_write(context, attempt, "pending")) { + message("vscode-R could not publish terminal startup status; the session watcher was not started.") + return(NULL) + } + context$attempt <- attempt + options(structure(list(context), names = .vscode_startup_option)) + attempt +} + +# A completion can update only the attempt currently registered in this process. +# If atomic publication fails, the last successfully published pending status remains. +vscode_r_startup_finish <- function(context, attempt, state) { + if (!state %in% c("ready", "failed")) { + return(invisible(FALSE)) + } + if (!.vscode_startup_registered(context)) { + return(invisible(FALSE)) + } + registered <- getOption(.vscode_startup_option) + if (!identical(registered$attempt, attempt)) { + return(invisible(FALSE)) + } + if (!identical(registered$endpoint, context$endpoint)) { + return(invisible(FALSE)) + } + if (!.vscode_startup_write(context, attempt, state)) { + message("vscode-R could not publish terminal startup completion; status remains pending.") + return(invisible(FALSE)) + } + invisible(TRUE) +} + +# Wrap one setup attempt for both the profile and generated manual attach. +# Without a matching profile registration, setup keeps its historical behavior. +vscode_r_startup_run <- function(context, setup) { + if (is.null(context)) { + return(setup()) + } + attempt <- vscode_r_startup_begin(context) + if (is.null(attempt)) { + return(invisible(FALSE)) + } + completed <- FALSE + on.exit({ + vscode_r_startup_finish(context, attempt, if (completed) "ready" else "failed") + }, add = TRUE) + result <- setup() + completed <- isTRUE(result) + result +} diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R new file mode 100644 index 000000000..007e3d988 --- /dev/null +++ b/R/tests/attach_sess.R @@ -0,0 +1,848 @@ +# Base-R integration coverage for manual attach consent; all packages are local fixtures. +source("R/sess_source.R") +root <- tempfile("attach-sess-") +dir.create(root) +on.exit(unlink(root, recursive = TRUE), add = TRUE) +Sys.unsetenv("VSCODE_R_SESS_STARTUP_FILE") + +revision <- paste0("git-tree:", strrep("a", 40)) +mismatch <- paste0("git-tree:", strrep("b", 40)) +pkg <- file.path(root, "bundled", "sess") +dir.create(file.path(pkg, "R"), recursive = TRUE) +writeLines(c( + "Package: sess", "Version: 1.0.0", "Title: Manual Attach Consent Fixture", + "Description: A local fixture for manual attach consent.", "License: MIT", + "Author: Test Author", "Maintainer: Test Author ", + paste0("Config/vscode-R/source-revision: ", revision) +), file.path(pkg, "DESCRIPTION")) +writeLines(c( + "connect <- function(endpoint=NULL, plot_backend=NULL, ...) TRUE", + "notify_client <- function(...) NULL", "request_client <- function(...) NULL", + ".onLoad <- function(libname, pkgname) {", + " complete <- Sys.getenv('VSCODE_R_TEST_INSTALL_COMPLETE', '')", + " if (nzchar(complete) && !file.exists(complete)) stop('loaded before install completed')", + " role <- Sys.getenv('VSCODE_R_TEST_ROLE', '')", + " owner_file <- Sys.getenv('VSCODE_R_TEST_LOCK_OWNER', '')", + " if (role == 'follower' && nzchar(owner_file) &&", + " identical(readLines(owner_file, warn=FALSE)[[1L]], 'owner')) {", + " stop('follower loaded while owner held setup lock')", + " }", + " log <- Sys.getenv('VSCODE_R_TEST_LOAD_LOG', '')", + " if (nzchar(log)) cat(role, '\\n', sep='', file=log, append=TRUE)", + "}" +), file.path(pkg, "R", "api.R")) +writeLines(c("export(connect)", "export(notify_client)", "export(request_client)"), + file.path(pkg, "NAMESPACE")) + +runner <- file.path(root, "run-case.R") +writeLines(c( + "args <- commandArgs(TRUE)", + "mode <- args[[1L]]; root <- args[[2L]]; pkg <- args[[3L]]", + "revision <- args[[4L]]; mismatch <- args[[5L]]", + "role <- if (length(args) >= 6L) args[[6L]] else ''", + "result_file <- if (length(args) >= 7L) args[[7L]] else ''", + "source(file.path(getwd(), 'R', 'sess_source.R'))", + "source(file.path(getwd(), 'R', 'terminal-startup.R'))", + "normal <- file.path(root, 'normal library')", + "dir.create(normal, recursive=TRUE, showWarnings=FALSE)", + "consent <- file.path(root, 'consent')", + "dir.create(consent, recursive=TRUE, showWarnings=FALSE)", + "managed_root <- file.path(root, 'vscode-R')", + "managed_library <- sess_managed_library(managed_root, revision)", + "ready_path <- file.path(dirname(managed_library), '.ready')", + "startup_status <- file.path(root, 'profile.status')", + "lock_path <- file.path(dirname(managed_library), '.setup-lock')", + "owner_file <- file.path(lock_path, 'fixture-owner')", + "owner_ready <- file.path(root, 'owner-lock-ready')", + "follower_observed <- file.path(root, 'follower-observed-lock')", + "load_log <- file.path(root, 'load-log')", + "install_complete <- file.path(root, 'install-complete')", + "install_helper <- Sys.getenv('VSCODE_R_TEST_INSTALL_HELPER', '')", + "if (startsWith(mode, 'parallel_')) {", + " Sys.setenv(VSCODE_R_TEST_ROLE=role, VSCODE_R_TEST_LOCK_OWNER=owner_file,", + " VSCODE_R_TEST_LOAD_LOG=load_log,", + " VSCODE_R_TEST_INSTALL_COMPLETE=install_complete)", + " dir.create <- function(path, ...) {", + " created <- base::dir.create(path, ...)", + " if (isTRUE(created) && identical(normalizePath(path, mustWork=FALSE),", + " normalizePath(lock_path, mustWork=FALSE))) {", + " writeLines(role, owner_file)", + " if (role == 'owner') file.create(owner_ready)", + " }", + " created", + " }", + " Sys.sleep <- function(time) {", + " if (role == 'follower' && file.exists(owner_file)) {", + " owner <- tryCatch(readLines(owner_file, warn=FALSE, n=1L),", + " error=function(e) character())", + " if (identical(owner, 'owner')) file.create(follower_observed)", + " }", + " base::Sys.sleep(time)", + " }", + " file.rename <- function(from, to) {", + " renamed <- base::file.rename(from, to)", + " if (isTRUE(renamed) && grepl('\\\\.request$', to)) {", + " cat(to, '\\n', file=Sys.getenv('VSCODE_R_TEST_REQUEST_LOG'), append=TRUE)", + " }", + " renamed", + " }", + "}", + "source(file.path(getwd(), 'R', 'attach_sess.R'))", + "project_library <- file.path(root, 'project library')", + "Sys.setenv(R_LIBS_USER=file.path(root, 'empty user library'), R_LIBS_SITE=.Library)", + "profile_mode <- startsWith(mode, 'profile_')", + "if (!profile_mode) .libPaths(c(normal, .Library))", + "ordinary <- .libPaths()", + "same_paths <- function(x, y) identical(normalizePath(x, winslash='/'),", + " normalizePath(y, winslash='/'))", + "startup_token <- Sys.getenv('VSCODE_R_SESS_STARTUP_TOKEN', '')", + "read_startup <- function(expected_state, expected_endpoint='endpoint') {", + " lines <- readLines(startup_status, warn=FALSE)", + " stopifnot(length(lines) == 6L, lines[[1L]] == 'vscode-r-terminal-startup-v1')", + " stopifnot(identical(lines[[2L]], startup_token))", + " stopifnot(grepl('^[A-Za-z0-9_-]{16,64}$', lines[[3L]]))", + " stopifnot(grepl('^[1-9][0-9]*$', lines[[4L]]),", + " identical(lines[[4L]], as.character(Sys.getpid())))", + " stopifnot(identical(lines[[5L]], expected_endpoint),", + " identical(lines[[6L]], expected_state))", + " lines", + "}", + "check_user_profile <- function() {", + " baseline <- readRDS(file.path(root, 'profile-libraries.rds'))", + " if (!same_paths(.libPaths(), baseline)) stop('user profile library paths changed')", + " stopifnot(file.exists(file.path(root, 'user-profile-ran'))) ", + " stopifnot(any(normalizePath(.libPaths()) == normalizePath(project_library)))", + "}", + "attach_sess <- function(endpoint='endpoint', timeout=180, setup_timeout=8) {", + " vscode_r_attach_sess(endpoint, pkg, managed_root, consent,", + " file.path(getwd(), 'R', 'sess_source.R'),", + " if (nzchar(install_helper)) install_helper else", + " file.path(getwd(), 'R', 'sess-package-install.R'),", + " 'standard', timeout_seconds=timeout, setup_timeout_seconds=setup_timeout)", + "}", + "publish_result <- function(value) {", + " temporary <- tempfile(tmpdir=dirname(result_file))", + " on.exit(unlink(temporary))", + " writeLines(value, temporary)", + " if (!file.rename(temporary, result_file)) stop('could not publish child result')", + "}", + "execute_case <- function() {", + "if (mode %in% c('exact', 'prepared', 'markers')) {", + " expected <- if (mode == 'exact') normal else", + " sess_managed_library(managed_root, revision)", + " dir.create(expected, recursive=TRUE, showWarnings=FALSE)", + " utils::install.packages(pkg, repos=NULL, type='source', lib=expected, quiet=TRUE)", + " if (mode == 'prepared') writeLines(revision, ready_path)", + " if (mode %in% c('exact', 'prepared')) {", + " result <- attach_sess(timeout=0)", + " stopifnot(identical(result, TRUE), same_paths(.libPaths(), ordinary))", + " stopifnot(identical(sess_loaded_source_revision(), revision))", + " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", + " stopifnot(same_paths(actual, file.path(expected, 'sess')))", + " if (mode == 'prepared') stopifnot(identical(readLines(ready_path), revision))", + " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + " if (mode == 'exact') {", + " blocked_file <- file.path(root, 'missing-directory', 'startup')", + " context <- vscode_r_startup_register(blocked_file, strrep('c', 32), 'endpoint')", + " called <- FALSE", + " result <- vscode_r_startup_run(context, function() { called <<- TRUE; TRUE })", + " stopifnot(identical(result, FALSE), !called, !file.exists(blocked_file))", + " reconnect_file <- file.path(root, 'reconnect.status')", + " reconnect_context <- vscode_r_startup_register(", + " reconnect_file, strrep('d', 32), 'endpoint-a')", + " result <- vscode_r_startup_run(reconnect_context, function() TRUE)", + " first <- readLines(reconnect_file, warn=FALSE)", + " stopifnot(identical(result, TRUE), length(first) == 6L,", + " first[[5L]] == 'endpoint-a', first[[6L]] == 'ready')", + " scheduler <- new.env(parent=globalenv())", + " scheduler$.sess_env <- new.env(parent=emptyenv())", + " scheduler$.sess_env$transport_generation <- 1L", + " scheduler$.sess_env$con <- NULL", + " scheduler$.sess_env$reconnect <- NULL", + " scheduler$.sess_env$pending_responses <- list()", + " scheduler$.sess_env$read_buffer <- ''", + " connect_count <- 0L; observed <- list(); callbacks <- list()", + " schedule <- function(callback, delay) {", + " callbacks[[length(callbacks) + 1L]] <<- callback", + " }", + " sys.source(file.path(getwd(), 'sess', 'R', 'server.R'), scheduler)", + " scheduler$.read_discovery <- function(path) list(endpoint='endpoint-b')", + " scheduler$.configure_discovery_jgd <- function(discovery, enabled) NULL", + " scheduler$runtime_stop <- function() invisible(NULL)", + " scheduler$connect <- function(endpoint, ...) {", + " connect_count <<- connect_count + 1L", + " observed[[connect_count]] <<- readLines(reconnect_file, warn=FALSE)", + " if (connect_count == 1L) return(FALSE)", + " scheduler$.sess_env$con <- list(endpoint=endpoint)", + " TRUE", + " }", + " settings <- list(path=file.path(root, 'discovery'), endpoint='endpoint-a',", + " options=list(plot_backend='standard'))", + " scheduler$.schedule_reconnect(settings, 1L, schedule)", + " callbacks[[1L]]()", + " failed <- readLines(reconnect_file, warn=FALSE)", + " stopifnot(length(observed) == 1L, observed[[1L]][[5L]] == 'endpoint-b',", + " observed[[1L]][[6L]] == 'pending')", + " stopifnot(failed[[5L]] == 'endpoint-b', failed[[6L]] == 'failed',", + " failed[[3L]] != first[[3L]], length(callbacks) == 2L)", + " callbacks[[2L]]()", + " ready <- readLines(reconnect_file, warn=FALSE)", + " stopifnot(length(observed) == 2L, observed[[2L]][[5L]] == 'endpoint-b',", + " observed[[2L]][[6L]] == 'pending')", + " stopifnot(ready[[5L]] == 'endpoint-b', ready[[6L]] == 'ready',", + " ready[[3L]] != failed[[3L]], connect_count == 2L)", + " }", + " } else if (mode == 'markers') {", + " description <- file.path(expected, 'sess', 'DESCRIPTION')", + " before <- readLines(description, warn=FALSE)", + " result <- attach_sess(timeout=8)", + " stopifnot(identical(result, FALSE), !('sess' %in% loadedNamespaces()))", + " stopifnot(same_paths(.libPaths(), ordinary), identical(readLines(description), before))", + " stopifnot(!file.exists(ready_path))", + " writeLines(mismatch, ready_path)", + " result <- attach_sess(timeout=8)", + " stopifnot(identical(result, FALSE), !('sess' %in% loadedNamespaces()))", + " stopifnot(same_paths(.libPaths(), ordinary), identical(readLines(description), before))", + " stopifnot(identical(readLines(ready_path), mismatch))", + " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + " }", + "} else if (mode == 'mismatch') {", + " utils::install.packages(pkg, repos=NULL, type='source', lib=normal, quiet=TRUE)", + " desc_path <- file.path(normal, 'sess', 'DESCRIPTION')", + " desc <- read.dcf(desc_path)", + " desc[1L, 'Config/vscode-R/source-revision'] <- mismatch", + " write.dcf(desc, desc_path)", + " set.seed(8841); seed <- .Random.seed", + " result <- attach_sess(timeout=8)", + " stopifnot(identical(result, FALSE), identical(seed, .Random.seed))", + " stopifnot(same_paths(.libPaths(), ordinary))", + " stopifnot(identical(read.dcf(desc_path), desc))", + " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", + "} else if (mode == 'profile_exact') {", + " expected <- normal", + " stopifnot('sess' %in% loadedNamespaces())", + " stopifnot(identical(sess_loaded_source_revision(), revision))", + " stopifnot(same_paths(.libPaths(), ordinary))", + " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", + " stopifnot(same_paths(actual, file.path(expected, 'sess')))", + " check_user_profile()", + " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + " stopifnot(identical(read_startup('ready')[[2L]], Sys.getenv('VSCODE_R_SESS_STARTUP_TOKEN')))", + "} else if (mode == 'profile_decline') {", + " wrong_library <- file.path(managed_root, 'fixture-other-platform',", + " '99.99', strrep('a', 40), 'library')", + " stopifnot(!('sess' %in% loadedNamespaces()))", + " stopifnot(same_paths(.libPaths(), ordinary))", + " check_user_profile()", + " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", + " stopifnot(!file.exists(ready_path))", + " first_startup <- read_startup('failed')", + " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')))", + " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + " context <- vscode_r_startup_existing('reload-endpoint')", + " stopifnot(!is.null(context), identical(context$endpoint, 'reload-endpoint'))", + " stopifnot(is.null(vscode_r_startup_existing(''))) ", + " saved_existing <- vscode_r_startup_existing", + " saved_run <- vscode_r_startup_run", + " rm(vscode_r_startup_existing, vscode_r_startup_run, envir=.GlobalEnv)", + " Sys.unsetenv('VSCODE_R_SESS_STARTUP_HELPER')", + " result <- attach_sess(timeout=0)", + " assign('vscode_r_startup_existing', saved_existing, envir=.GlobalEnv)", + " assign('vscode_r_startup_run', saved_run, envir=.GlobalEnv)", + " stopifnot(identical(result, FALSE), !('sess' %in% loadedNamespaces()))", + " stopifnot(identical(read_startup('failed')[[3L]], first_startup[[3L]]))", + " stopifnot(!length(list.files(consent, pattern='\\\\.request$'))) ", + " Sys.unsetenv(c('VSCODE_R_SESS_STARTUP_FILE', 'VSCODE_R_SESS_STARTUP_TOKEN',", + " 'VSCODE_R_SESS_STARTUP_ENDPOINT'))", + " result <- attach_sess(endpoint='reload-endpoint', timeout=10)", + " second_startup <- read_startup('ready', 'reload-endpoint')", + " stopifnot(identical(result, TRUE), first_startup[[3L]] != second_startup[[3L]])", + " vscode_r_startup_finish(context, first_startup[[3L]], 'failed')", + " stopifnot(identical(read_startup('ready', 'reload-endpoint')[[3L]], second_startup[[3L]]))", + " stopifnot(identical(sess_loaded_source_revision(), revision))", + " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", + " stopifnot(same_paths(actual, file.path(sess_managed_library(managed_root, revision), 'sess')))", + " stopifnot(identical(readLines(ready_path), revision))", + "} else if (mode == 'profile_approve') {", + " wrong_library <- file.path(managed_root, 'fixture-other-platform',", + " '99.99', strrep('a', 40), 'library')", + " library <- sess_managed_library(managed_root, revision)", + " stopifnot('sess' %in% loadedNamespaces())", + " stopifnot(identical(sess_loaded_source_revision(), revision))", + " stopifnot(same_paths(.libPaths(), ordinary))", + " check_user_profile()", + " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", + " stopifnot(same_paths(actual, file.path(library, 'sess')))", + " stopifnot(file.exists(file.path(library, 'sess', 'DESCRIPTION')))", + " stopifnot(identical(readLines(ready_path), revision))", + " stopifnot(identical(read_startup('ready')[[2L]], Sys.getenv('VSCODE_R_SESS_STARTUP_TOKEN')))", + " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')))", + "} else if (mode %in% c('parallel_approve', 'parallel_decline', 'parallel_third',", + " 'parallel_failure', 'parallel_retry')) {", + " timeout <- 10", + " if (mode == 'parallel_approve') {", + " result <- attach_sess(timeout=timeout, setup_timeout=20)", + " stopifnot(identical(result, TRUE), 'sess' %in% loadedNamespaces())", + " stopifnot(identical(sess_loaded_source_revision(), revision))", + " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", + " stopifnot(same_paths(actual, file.path(managed_library, 'sess')))", + " stopifnot(same_paths(.libPaths(), ordinary))", + " } else if (mode == 'parallel_failure') {", + " result <- tryCatch(attach_sess(timeout=10), error=identity)", + " stopifnot(inherits(result, 'error'), !('sess' %in% loadedNamespaces()))", + " stopifnot(file.exists(file.path(managed_library, 'sess', 'DESCRIPTION')))", + " stopifnot(!file.exists(ready_path), !dir.exists(lock_path))", + " stopifnot(same_paths(.libPaths(), ordinary))", + " } else {", + " result <- attach_sess(timeout=timeout, setup_timeout=20)", + " stopifnot(identical(result, FALSE), !('sess' %in% loadedNamespaces()))", + " if (mode == 'parallel_retry') stopifnot(file.exists(file.path(managed_library, 'sess', 'DESCRIPTION')))", + " else stopifnot(!file.exists(file.path(managed_library, 'sess', 'DESCRIPTION'))) ", + " stopifnot(!file.exists(ready_path))", + " stopifnot(same_paths(.libPaths(), ordinary))", + " }", + "} else if (mode == 'lock_timeout') {", + " lock <- file.path(dirname(managed_library), '.setup-lock')", + " lock_marker <- file.path(lock, 'foreign-owner')", + " stopifnot(dir.exists(lock), file.exists(lock_marker))", + " result <- tryCatch(attach_sess(timeout=0, setup_timeout=0.5), error=identity)", + " stopifnot(inherits(result, 'error'), grepl('Timed out waiting for another sess setup',", + " conditionMessage(result), fixed=TRUE))", + " stopifnot(dir.exists(lock), file.exists(lock_marker))", + " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + " stopifnot(!file.exists(ready_path))", + "} else stop('unknown case')", + "cat('manual attach case passed:', mode, '\\n')", + "}", + "if (nzchar(result_file)) {", + " tryCatch({ execute_case(); publish_result('ok') },", + " error=function(e) publish_result(paste0('error: ', conditionMessage(e))))", + "} else execute_case()" +), runner) + +agent <- file.path(root, "consent-agent.R") +writeLines(c( + "args <- commandArgs(TRUE)", + "directory <- args[[1L]]; count <- as.integer(args[[2L]])", + "answers <- strsplit(args[[3L]], ',', fixed=TRUE)[[1L]]", + "log <- args[[4L]]; identity_log <- args[[5L]]", + "status_log <- args[[6L]]; wait_marker <- args[[7L]]", + "tryCatch({", + "startup_helper <- Sys.getenv('VSCODE_R_SESS_STARTUP_HELPER', '')", + "if (nzchar(startup_helper)) {", + " source(startup_helper)", + " inherited <- vscode_r_startup_existing('endpoint')", + " if (!is.null(inherited)) stop('child reused the parent startup context')", + "}", + "ids <- character(); identities <- character(); deadline <- Sys.time() + 30", + "while (length(ids) < count && Sys.time() < deadline) {", + " requests <- list.files(directory, pattern='\\\\.request$', full.names=TRUE)", + " for (request in requests) {", + " lines <- readLines(request, warn=FALSE)", + " if (length(lines) != 5L || lines[[1L]] != 'vscode-r-sess-consent-v1') stop('bad request')", + " id <- lines[[2L]]; if (id %in% ids) next", + " if (nzchar(wait_marker) && !file.exists(wait_marker)) next", + " ids <- c(ids, id); temporary <- tempfile(tmpdir=directory)", + " identities <- c(identities, lines[[4L]])", + " answer <- answers[[min(length(ids), length(answers))]]", + " writeLines(answer, temporary)", + " response <- file.path(directory, paste0(id, '.response'))", + " if (!file.rename(temporary, response)) stop('response rename failed')", + " }", + " Sys.sleep(0.025)", + "}", + "if (length(ids) != count) stop('timed out waiting for consent requests')", + "writeLines(ids, log)", + "writeLines(identities, identity_log)", + "writeLines('ok', status_log)", + "}, error=function(e) {", + "writeLines(paste0('error: ', conditionMessage(e)), status_log)", + "})" +), agent) + +r_binary <- file.path(R.home("bin"), if (.Platform$OS.type == "windows") "R.exe" else "R") +r_literal <- function(value) encodeString(value, quote = "\"") +read_child_logs <- function(paths) { + read_log <- function(path) { + tryCatch(readLines(path, warn = FALSE), error = function(e) paste("could not read", path)) + } + c(read_log(paths$stdout), read_log(paths$stderr)) +} +run_case <- function(mode) { + case_root <- file.path(root, mode) + dir.create(case_root) + startup_status <- file.path(case_root, "profile.status") + consent <- file.path(case_root, "consent") + dir.create(consent) + profile_modes <- c("profile_exact", "profile_decline", "profile_approve") + startup <- mode %in% profile_modes + if (startup) { + normal <- file.path(case_root, "normal library") + project <- file.path(case_root, "project library") + managed_root <- file.path(case_root, "vscode-R") + dir.create(normal) + dir.create(project) + user_profile <- file.path(case_root, "user.Rprofile") + baseline <- file.path(case_root, "profile-libraries.rds") + marker <- file.path(case_root, "user-profile-ran") + writeLines(c( + paste0(".libPaths(c(", r_literal(project), ", .libPaths()))"), + paste0("saveRDS(.libPaths(), ", r_literal(baseline), ")"), + paste0("file.create(", r_literal(marker), ")") + ), user_profile) + startup_profile <- file.path(case_root, "startup.Rprofile") + startup_status <- file.path(case_root, "profile.status") + production_profile <- file.path(getwd(), "R", "profile.R") + writeLines(c( + "Sys.setenv(R_PROFILE_USER_OLD=Sys.getenv('VSCODE_R_TEST_USER_PROFILE'))", + paste0(".libPaths(c(Sys.getenv('VSCODE_R_TEST_NORMAL_LIBRARY'), .libPaths()))"), + "source(Sys.getenv('VSCODE_R_TEST_PROFILE'))" + ), startup_profile) + managed_library <- sess_managed_library(managed_root, revision) + install_library <- if (mode == "profile_exact") { + normal + } else { + file.path(managed_root, "fixture-other-platform", "99.99", + strrep("a", 40), "library") + } + dir.create(install_library, recursive = TRUE, showWarnings = FALSE) + if (mode %in% c("profile_decline", "profile_approve")) { + wrong_sess <- file.path(install_library, "sess") + dir.create(wrong_sess, recursive = TRUE) + writeLines(c("Package: sess", "Version: 1.0.0", + paste0("Config/vscode-R/source-revision: ", revision)), + file.path(wrong_sess, "DESCRIPTION")) + } else { + utils::install.packages(pkg, repos = NULL, type = "source", lib = install_library, + quiet = TRUE) + } + if (mode == "profile_exact") { + unlink(consent, recursive = TRUE) + } + profile_environment <- c( + R_PROFILE_USER = startup_profile, + VSCODE_R_TEST_USER_PROFILE = user_profile, + VSCODE_R_TEST_NORMAL_LIBRARY = normal, + VSCODE_R_TEST_PROFILE = production_profile, + VSCODE_R_SESS_PKG_PATH = pkg, + VSCODE_R_SESS_SOURCE_HELPER = file.path(getwd(), "R", "sess_source.R"), + VSCODE_R_SESS_ATTACH_HELPER = file.path(getwd(), "R", "attach_sess.R"), + VSCODE_R_SESS_INSTALLER_HELPER = file.path(getwd(), "R", "sess-package-install.R"), + VSCODE_R_SESS_ROOT = managed_root, + VSCODE_R_SESS_CONSENT_DIRECTORY = consent, + VSCODE_R_SESS_STARTUP_FILE = startup_status, + VSCODE_R_SESS_STARTUP_TOKEN = paste0(strrep("c", 32L)), + VSCODE_R_SESS_STARTUP_ENDPOINT = "endpoint", + VSCODE_R_SESS_STARTUP_HELPER = file.path(getwd(), "R", "terminal-startup.R")) + profile_keys <- names(profile_environment) + previous_environment <- Sys.getenv(profile_keys, unset = NA_character_) + on.exit({ + Sys.unsetenv(profile_keys) + if (any(!is.na(previous_environment))) { + do.call(Sys.setenv, as.list(previous_environment[!is.na(previous_environment)])) + } + }, add = TRUE) + do.call(Sys.setenv, as.list(profile_environment)) + } + consent_modes <- c("markers", "mismatch", "profile_decline", "profile_approve") + if (mode %in% consent_modes) { + log <- file.path(case_root, "consent-ids") + identity_log <- file.path(case_root, "runtime-identities") + status_log <- file.path(case_root, "consent-agent-status") + count <- if (mode %in% c("markers", "profile_decline")) 2L else 1L + answer <- switch(mode, profile_approve = "approve", profile_decline = "decline,approve", + "decline") + release_marker <- if (mode %in% c("profile_decline", "profile_approve")) { + file.path(case_root, "release-profile-consent") + } else { + "" + } + system2(r_binary, shQuote(c( + "--vanilla", "--slave", paste0("--file=", agent), "--args", + consent, as.character(count), answer, log, identity_log, status_log, release_marker + )), wait = FALSE, stdout = FALSE, stderr = FALSE) + } + flags <- if (startup) { + c("--no-site-file", "--no-environ", "--slave", "--no-save", "--no-restore") + } else { + c("--vanilla", "--slave", "--no-save", "--no-restore") + } + if (mode %in% c("profile_decline", "profile_approve")) { + result_file <- file.path(case_root, "startup-runner-result") + stdout_path <- file.path(case_root, "startup-runner-stdout") + stderr_path <- file.path(case_root, "startup-runner-stderr") + system2( + r_binary, + shQuote(c( + flags, + paste0("--file=", runner), "--args", mode, case_root, pkg, revision, mismatch, + "", result_file + )), + wait = FALSE, + stdout = stdout_path, + stderr = stderr_path + ) + deadline <- Sys.time() + 10 + requests <- character() + while (!length(requests) && Sys.time() < deadline) { + requests <- list.files(consent, pattern = "\\.request$", full.names = TRUE) + if (!length(requests)) Sys.sleep(0.01) + } + if (!length(requests)) stop("profile startup did not publish a consent request") + startup_record <- readLines(startup_status, warn = FALSE) + stopifnot(length(startup_record) == 6L, + startup_record[[1L]] == "vscode-r-terminal-startup-v1", + startup_record[[2L]] == Sys.getenv("VSCODE_R_SESS_STARTUP_TOKEN"), + grepl("^[A-Za-z0-9_-]{16,64}$", startup_record[[3L]]), + grepl("^[1-9][0-9]*$", startup_record[[4L]]), + startup_record[[5L]] == "endpoint", + startup_record[[6L]] == "pending") + file.create(release_marker) + deadline <- Sys.time() + 30 + while (!file.exists(result_file) && Sys.time() < deadline) Sys.sleep(0.01) + if (!file.exists(result_file)) { + child_output <- read_child_logs(list(stdout = stdout_path, stderr = stderr_path)) + stop(paste(mode, "startup child timed out; output:", paste(child_output, collapse = "\n"))) + } + child_result <- readLines(result_file, warn = FALSE) + if (!identical(child_result, "ok")) { + child_output <- read_child_logs(list(stdout = stdout_path, stderr = stderr_path)) + stop(paste(mode, "startup child failed:", paste(child_result, collapse = " "), + "output:", paste(child_output, collapse = "\n"))) + } + } else { + output <- suppressWarnings( + system2( + r_binary, + shQuote(c( + flags, + paste0("--file=", runner), "--args", mode, case_root, pkg, revision, mismatch, + "", "" + )), + stdout = TRUE, + stderr = TRUE + ) + ) + status <- attr(output, "status") + if (!is.null(status) && status != 0L) stop(paste(output, collapse = "\n")) + } + if (mode %in% consent_modes) { + status_path <- file.path(case_root, "consent-agent-status") + deadline <- Sys.time() + 5 + while (!file.exists(status_path) && Sys.time() < deadline) Sys.sleep(0.01) + if (!file.exists(status_path)) stop(paste("consent agent did not finish:", mode)) + agent_status <- readLines(status_path, warn = FALSE) + if (!identical(agent_status, "ok")) { + stop(paste("consent agent failed:", paste(agent_status, collapse = " "))) + } + log_path <- file.path(case_root, "consent-ids") + ids <- readLines(log_path, warn = FALSE) + expected_count <- if (mode %in% c("markers", "profile_decline")) 2L else 1L + stopifnot(length(ids) == expected_count, !anyDuplicated(ids)) + stopifnot(all(grepl("^[A-Za-z0-9_-]{16,64}$", ids))) + identity_log <- file.path(case_root, "runtime-identities") + runtime_identity <- readLines(identity_log, warn = FALSE) + minor <- strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L] + expected_runtime <- paste(R.version$platform, + paste(R.version$major, minor, sep = "."), sep = "|") + stopifnot(length(runtime_identity) == expected_count) + stopifnot(all(runtime_identity == expected_runtime)) + } +} + +for (mode in c( + "mismatch", "exact", "prepared", "markers", "profile_exact", "profile_decline", "profile_approve" +)) run_case(mode) + +wait_for_file <- function(path, seconds, description) { + deadline <- Sys.time() + seconds + while (!file.exists(path) && Sys.time() < deadline) Sys.sleep(0.01) + if (!file.exists(path)) stop(paste("Timed out waiting for", description)) +} + +wait_for_request <- function(directory, seconds) { + deadline <- Sys.time() + seconds + requests <- character() + while (!length(requests) && Sys.time() < deadline) { + requests <- list.files(directory, pattern = "\\.request$", full.names = TRUE) + if (!length(requests)) Sys.sleep(0.01) + } + if (!length(requests)) stop("Timed out waiting for owner consent request") + requests[[1L]] +} + +start_consent_agent <- function(case_root, answer, wait_marker = "", label = "consent") { + consent <- file.path(case_root, "consent") + log <- file.path(case_root, paste0(label, "-ids")) + identity_log <- file.path(case_root, paste0(label, "-identities")) + status <- file.path(case_root, paste0(label, "-status")) + stdout <- file.path(case_root, paste0(label, "-stdout")) + stderr <- file.path(case_root, paste0(label, "-stderr")) + system2( + r_binary, + shQuote(c( + "--vanilla", "--slave", paste0("--file=", agent), "--args", + consent, "1", answer, log, identity_log, status, wait_marker + )), + wait = FALSE, + stdout = stdout, + stderr = stderr + ) + list(log = log, identity_log = identity_log, status = status, + stdout = stdout, stderr = stderr) +} + +wait_for_agent <- function(paths) { + wait_for_file(paths$status, 10, "consent-agent completion") + result <- readLines(paths$status, warn = FALSE) + if (!identical(result, "ok")) { + stop(paste("Consent agent failed:", paste(result, collapse = " "))) + } + ids <- readLines(paths$log, warn = FALSE) + identities <- readLines(paths$identity_log, warn = FALSE) + stopifnot(length(ids) == 1L, grepl("^[A-Za-z0-9_-]{16,64}$", ids)) + minor <- strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L] + expected_runtime <- paste( + R.version$platform, + paste(R.version$major, minor, sep = "."), + sep = "|" + ) + stopifnot(identical(identities, expected_runtime)) + ids +} + +start_parallel_child <- function(case_root, mode, role, result_file, install_helper = "") { + environment_names <- c("VSCODE_R_TEST_INSTALL_HELPER", "VSCODE_R_TEST_REQUEST_LOG") + previous <- Sys.getenv(environment_names, unset = NA_character_) + do.call( + Sys.setenv, + as.list(c( + VSCODE_R_TEST_INSTALL_HELPER = install_helper, + VSCODE_R_TEST_REQUEST_LOG = file.path(case_root, "published-requests") + )) + ) + on.exit({ + Sys.unsetenv(environment_names) + if (any(!is.na(previous))) do.call(Sys.setenv, as.list(previous[!is.na(previous)])) + }) + stdout <- file.path(case_root, paste0(role, "-stdout")) + stderr <- file.path(case_root, paste0(role, "-stderr")) + system2( + r_binary, + shQuote(c( + "--vanilla", "--slave", paste0("--file=", runner), "--args", + mode, case_root, pkg, revision, mismatch, role, result_file + )), + wait = FALSE, + stdout = stdout, + stderr = stderr + ) + list(result = result_file, stdout = stdout, stderr = stderr) +} + +wait_for_child <- function(paths, description) { + deadline <- Sys.time() + 30 + while (!file.exists(paths$result) && Sys.time() < deadline) Sys.sleep(0.01) + child_output <- read_child_logs(paths) + if (!file.exists(paths$result)) { + stop(paste(description, "timed out; child output:", paste(child_output, collapse = "\n"))) + } + result <- readLines(paths$result, warn = FALSE) + if (!identical(result, "ok")) { + stop(paste(description, "failed:", paste(result, collapse = " "), + "child output:", paste(child_output, collapse = "\n"))) + } + invisible(TRUE) +} + +run_parallel_setup <- function(approve) { + mode <- if (approve) "parallel_approve" else "parallel_decline" + case_root <- file.path(root, mode) + dir.create(case_root) + consent <- file.path(case_root, "consent") + dir.create(consent) + managed_root <- file.path(case_root, "vscode-R") + managed_library <- sess_managed_library(managed_root, revision) + lock_path <- file.path(dirname(managed_library), ".setup-lock") + ready_path <- file.path(dirname(managed_library), ".ready") + owner_ready <- file.path(case_root, "owner-lock-ready") + follower_observed <- file.path(case_root, "follower-observed-lock") + install_complete <- file.path(case_root, "install-complete") + description_visible <- file.path(case_root, "install-description-visible") + install_release <- file.path(case_root, "allow-install-complete") + install_log <- file.path(case_root, "install-invocations") + load_log <- file.path(case_root, "load-log") + install_helper <- file.path(case_root, "blocking-installer.R") + writeLines(c( + "sess_install <- function(pkg_path, library, repo) {", + paste0(" cat('install\\n', file=", r_literal(install_log), ", append=TRUE)"), + " test_vars <- c('VSCODE_R_TEST_ROLE', 'VSCODE_R_TEST_LOCK_OWNER',", + " 'VSCODE_R_TEST_LOAD_LOG', 'VSCODE_R_TEST_INSTALL_COMPLETE')", + " previous <- Sys.getenv(test_vars, unset=NA_character_)", + " Sys.unsetenv(test_vars)", + " on.exit({", + " Sys.unsetenv(test_vars)", + " if (any(!is.na(previous))) do.call(Sys.setenv, as.list(previous[!is.na(previous)]))", + " }, add=TRUE)", + " utils::install.packages(pkg_path, repos=NULL, type='source', lib=library, quiet=TRUE)", + paste0(" if (!file.exists(file.path(library, 'sess', 'DESCRIPTION'))) stop('install failed')"), + paste0(" file.create(", r_literal(description_visible), ")"), + paste0(" deadline <- Sys.time() + 20; release <- ", r_literal(install_release)), + " while (!file.exists(release) && Sys.time() < deadline) Sys.sleep(0.01)", + " if (!file.exists(release)) stop('installer fixture release timed out')", + paste0(" file.create(", r_literal(install_complete), ")"), + "}" + ), install_helper) + + agent_paths <- start_consent_agent( + case_root, + if (approve) "approve" else "decline", + follower_observed + ) + owner_result <- file.path(case_root, "owner-result") + follower_result <- file.path(case_root, "follower-result") + owner <- start_parallel_child(case_root, mode, "owner", owner_result, install_helper) + wait_for_file(owner_ready, 10, "owner setup lock") + wait_for_request(consent, 10) + follower <- start_parallel_child(case_root, mode, "follower", follower_result) + wait_for_file(follower_observed, 10, "follower observation of the held setup lock") + + if (approve) { + wait_for_file(description_visible, 10, "installed package metadata while owner holds lock") + stopifnot(dir.exists(lock_path), file.exists(file.path(managed_library, "sess", "DESCRIPTION"))) + stopifnot(!file.exists(load_log), !file.exists(ready_path)) + file.create(install_release) + wait_for_file(install_complete, 10, "installer verification completion") + } + wait_for_child(owner, paste(mode, "role=owner")) + wait_for_child(follower, paste(mode, "role=follower")) + request_ids <- wait_for_agent(agent_paths) + published_requests <- readLines(file.path(case_root, "published-requests"), warn = FALSE) + stopifnot(length(published_requests) == 1L) + stopifnot(length(list.files(consent, pattern = "\\.(request|response)$")) == 0L) + stopifnot(!dir.exists(lock_path)) + if (approve) { + stopifnot(length(readLines(install_log, warn = FALSE)) == 1L) + stopifnot(file.exists(install_complete), file.exists(load_log)) + stopifnot(identical(readLines(ready_path, warn = FALSE), revision)) + loads <- readLines(load_log, warn = FALSE) + if (!identical(sort(loads), c("follower", "owner"))) { + stop(paste("unexpected package load sequence:", paste(loads, collapse = ", "))) + } + } else { + stopifnot(!file.exists(install_log), !dir.exists(managed_library)) + stopifnot(!file.exists(load_log), !file.exists(ready_path)) + + third_agent <- start_consent_agent(case_root, "decline", label = "third-consent") + third_result <- file.path(case_root, "third-result") + third <- start_parallel_child(case_root, "parallel_third", "third", third_result) + wait_for_child(third, "parallel_third role=third after decline") + third_id <- wait_for_agent(third_agent) + stopifnot(!identical(third_id, request_ids)) + published_requests <- readLines(file.path(case_root, "published-requests"), warn = FALSE) + stopifnot(length(published_requests) == 2L) + stopifnot(length(list.files(consent, pattern = "\\.(request|response)$")) == 0L) + stopifnot(!dir.exists(lock_path), !dir.exists(managed_library)) + stopifnot(!file.exists(install_log), !file.exists(load_log), !file.exists(ready_path)) + } +} + +run_parallel_setup(TRUE) +run_parallel_setup(FALSE) + +run_failed_install <- function() { + case_root <- file.path(root, "parallel-failed-install") + dir.create(case_root) + dir.create(file.path(case_root, "consent")) + managed_library <- sess_managed_library(file.path(case_root, "vscode-R"), revision) + lock_path <- file.path(dirname(managed_library), ".setup-lock") + ready_path <- file.path(dirname(managed_library), ".ready") + request_log <- file.path(case_root, "published-requests") + failure_helper <- file.path(case_root, "failing-installer.R") + dir.create(managed_library, recursive = TRUE) + utils::install.packages(pkg, repos = NULL, type = "source", lib = managed_library, quiet = TRUE) + existing_description <- file.path(managed_library, "sess", "DESCRIPTION") + description <- read.dcf(existing_description) + description[1L, "Config/vscode-R/source-revision"] <- mismatch + write.dcf(description, existing_description) + writeLines(revision, ready_path) + writeLines(c( + "sess_install <- function(pkg_path, library, repo) {", + " test_vars <- c('VSCODE_R_TEST_ROLE', 'VSCODE_R_TEST_LOCK_OWNER',", + " 'VSCODE_R_TEST_LOAD_LOG', 'VSCODE_R_TEST_INSTALL_COMPLETE')", + " previous <- Sys.getenv(test_vars, unset=NA_character_)", + " Sys.unsetenv(test_vars)", + " on.exit({", + " Sys.unsetenv(test_vars)", + " if (any(!is.na(previous))) do.call(Sys.setenv, as.list(previous[!is.na(previous)]))", + " }, add=TRUE)", + " utils::install.packages(pkg_path, repos=NULL, type='source', lib=library, quiet=TRUE)", + " if (!file.exists(file.path(library, 'sess', 'DESCRIPTION'))) stop('install failed')", + " stop('injected verification failure')", + "}" + ), failure_helper) + + first_agent <- start_consent_agent(case_root, "approve", label = "failure-consent") + first_result <- file.path(case_root, "failure-result") + first <- start_parallel_child( + case_root, + "parallel_failure", + "failure-owner", + first_result, + failure_helper + ) + wait_for_child(first, "parallel_failure role=failure-owner") + first_id <- wait_for_agent(first_agent) + stopifnot(file.exists(file.path(managed_library, "sess", "DESCRIPTION"))) + stopifnot(identical(sess_source_revision(file.path(managed_library, "sess", "DESCRIPTION")), revision)) + stopifnot(!file.exists(ready_path), !dir.exists(lock_path)) + stopifnot(length(readLines(request_log, warn = FALSE)) == 1L) + + retry_agent <- start_consent_agent(case_root, "decline", label = "retry-consent") + retry_result <- file.path(case_root, "retry-result") + retry <- start_parallel_child( + case_root, + "parallel_retry", + "retry", + retry_result + ) + wait_for_child(retry, "parallel_retry role=retry after failed setup") + retry_id <- wait_for_agent(retry_agent) + stopifnot(!identical(first_id, retry_id)) + stopifnot(length(readLines(request_log, warn = FALSE)) == 2L) + stopifnot(file.exists(file.path(managed_library, "sess", "DESCRIPTION"))) + stopifnot(!file.exists(ready_path), !dir.exists(lock_path)) + stopifnot(!file.exists(file.path(case_root, "load-log"))) +} + +run_failed_install() + +timeout_root <- file.path(root, "foreign-lock-timeout") +dir.create(timeout_root) +timeout_lock <- file.path( + dirname(sess_managed_library(file.path(timeout_root, "vscode-R"), revision)), + ".setup-lock" +) +dir.create(timeout_lock, recursive = TRUE) +foreign_marker <- file.path(timeout_lock, "foreign-owner") +writeLines("preserve", foreign_marker) +timeout_result <- file.path(timeout_root, "result") +timeout_output <- suppressWarnings( + system2( + r_binary, + shQuote(c( + "--vanilla", "--slave", paste0("--file=", runner), "--args", + "lock_timeout", timeout_root, pkg, revision, mismatch, "timeout", timeout_result + )), + stdout = TRUE, + stderr = TRUE + ) +) +if (!is.null(attr(timeout_output, "status")) && attr(timeout_output, "status") != 0L) { + stop(paste(timeout_output, collapse = "\n")) +} +stopifnot(identical(readLines(timeout_result, warn = FALSE), "ok")) +stopifnot(dir.exists(timeout_lock), identical(readLines(foreign_marker, warn = FALSE), "preserve")) +stopifnot(!file.exists(file.path(dirname(timeout_lock), ".ready"))) +unlink(root, recursive = TRUE) +cat("manual attach consent bridge tests passed\n") diff --git a/R/tests/sess_source.R b/R/tests/sess_source.R index 8b6988da8..944bf42ad 100644 --- a/R/tests/sess_source.R +++ b/R/tests/sess_source.R @@ -1,111 +1,95 @@ -# Base-R tests; no package dependencies or editor binary are needed. +# Base-R regression tests; no external packages or editor binary are needed. source("R/sess_source.R") +normalized_paths <- function(paths) normalizePath(paths, winslash = "/", mustWork = TRUE) + field <- "Config/vscode-R/source-revision" stable <- paste0("git-tree:", strrep("a", 40)) pre_release <- paste0("git-tree:", strrep("b", 40)) root <- tempfile("sess-source-") dir.create(root) -dir.create(file.path(root, "bundled")) -dir.create(file.path(root, "installed")) -bundled <- file.path(root, "bundled") -installed <- file.path(root, "installed") -write_description <- function(pkg, version, revision = NULL) { +on.exit(unlink(root, recursive = TRUE), add = TRUE) + +write_description <- function(pkg, version, revision = NULL, imports = NULL) { + dir.create(pkg, recursive = TRUE, showWarnings = FALSE) lines <- c("Package: sess", paste0("Version: ", version)) - if (!is.null(revision)) { - lines <- c(lines, paste0(field, ": ", revision)) - } + if (!is.null(revision)) lines <- c(lines, paste0(field, ": ", revision)) + if (!is.null(imports)) lines <- c(lines, paste0("Imports: ", imports)) writeLines(lines, file.path(pkg, "DESCRIPTION")) } -# Control the selected installation, while exercising the real DCF reader. -selected <- character() -find.package <- function(package, lib.loc, quiet) { - stopifnot(identical(package, "sess"), identical(lib.loc, .libPaths()), quiet) - selected -} + +# Installed metadata is read from library directories without loading sess. +bundled <- file.path(root, "bundled", "sess") +project_library <- file.path(root, "project library") +user_library <- file.path(root, "user library") +dir.create(bundled, recursive = TRUE) +dir.create(file.path(project_library, "sess"), recursive = TRUE) +dir.create(file.path(user_library, "sess"), recursive = TRUE) write_description(bundled, "3.0.1", stable) -stopifnot(sess_install_required(bundled)) # Missing package. -selected <- installed -write_description(installed, "99.0.0") -stopifnot(sess_install_required(bundled)) # Legacy field missing, newer version. -write_description(installed, "3.0.1", pre_release) -stopifnot(sess_install_required(bundled)) # Same version, different source. -write_description(installed, "3.0.2", pre_release) -stopifnot(sess_install_required(bundled)) # Switching from pre-release back to stable. +write_description(file.path(project_library, "sess"), "3.0.1", pre_release) +write_description(file.path(user_library, "sess"), "99.0.0", stable) + +original_libs <- .libPaths() +on.exit(.libPaths(original_libs), add = TRUE) +.libPaths(c(project_library, user_library, original_libs)) +stopifnot(!sess_install_required(bundled)) # Exact revision in any normal library wins. +stopifnot(identical(normalized_paths(sess_find_source_library(stable)), + normalized_paths(user_library))) +stopifnot(identical(normalized_paths(.libPaths()[1:2]), + normalized_paths(c(project_library, user_library)))) + +write_description(file.path(user_library, "sess"), "99.0.0", pre_release) +stopifnot(sess_install_required(bundled)) # Mismatch does not count as bundled source. +unlink(file.path(project_library, "sess"), recursive = TRUE) +unlink(file.path(user_library, "sess"), recursive = TRUE) +stopifnot(sess_install_required(bundled)) # Missing source requires preparation. +stopifnot(!dir.exists(file.path(project_library, "sess"))) +stopifnot(!dir.exists(file.path(user_library, "sess"))) write_description(bundled, "3.0.2", pre_release) -write_description(installed, "3.0.1", stable) -stopifnot(sess_install_required(bundled)) # Switching from stable to pre-release. -write_description(installed, "99.0.0", pre_release) -stopifnot(!sess_install_required(bundled)) # Same source, irrelevant version. +write_description(file.path(user_library, "sess"), "3.0.1", stable) +stopifnot(sess_install_required(bundled)) # Stable/pre-release revisions differ exactly. +write_description(file.path(user_library, "sess"), "99.0.0", pre_release) +stopifnot(!sess_install_required(bundled)) # Version is independent of source identity. for (invalid in c("", "unknown", "git-tree:123")) { - write_description(installed, "99.0.0", invalid) + write_description(file.path(user_library, "sess"), "99.0.0", invalid) stopifnot(sess_install_required(bundled)) } write_description(bundled, "3.0.2") stopifnot(inherits(tryCatch(sess_install_required(bundled), error = identity), "error")) +write_description(bundled, "3.0.1", stable) + +# Runtime-specific managed paths include the R platform and major/minor version. +managed_root <- file.path(root, "vscode-R managed") +identity <- sess_runtime_identity() +minor <- strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L] +runtime_version <- paste(R.version$major, minor, sep = ".") +expected_identity <- paste(R.version$platform, runtime_version, sep = "|") +stopifnot(identical(identity, expected_identity)) +managed_library <- sess_managed_library(managed_root, stable) +stopifnot(identical(managed_library, file.path(managed_root, R.version$platform, + runtime_version, strrep("a", 40), "library"))) +stopifnot(!identical(managed_library, sess_managed_library(managed_root, pre_release))) -# Exercise the real installer flow while replacing the package installation with -# controlled success/failure, including a shadowed installation. +# Install a dependency-free fixture into the managed library, then verify that +# explicit namespace loading selects it without changing ordinary .libPaths(). extension <- file.path(root, "extension with spaces") -dir.create(extension) -dir.create(file.path(extension, "R")) -stopifnot(file.copy("R/sess_source.R", file.path(extension, "R", "sess_source.R"))) -stopifnot(file.copy("R/sess-package-install.R", file.path(extension, "R", "sess-package-install.R"))) -installer <- file.path(extension, "R", "install_sess.R") -stopifnot(file.copy("R/install_sess.R", installer)) pkg <- file.path(extension, "dist", "resources", "sess") -dir.create(pkg, recursive = TRUE) -write_description(pkg, "3.0.1", stable) -Sys.setenv(VSCODE_R_SESS_PKG_PATH = pkg, VSCODE_R_SESS_REPO = "https://example.com") -simulate <- function(outcome, private = FALSE) { - original_libs <- .libPaths() - library <- file.path(root, "simulated-library") - dir.create(library, showWarnings = FALSE) - .libPaths(c(library, original_libs)) - on.exit({ - .libPaths(original_libs) - Sys.unsetenv(c("VSCODE_R_SESS_LIBRARY", "VSCODE_R_SESS_INTERACTIVE")) - }) - if (private) Sys.setenv(VSCODE_R_SESS_LIBRARY = library, VSCODE_R_SESS_INTERACTIVE = "1") - env <- new.env(parent = globalenv()) - env$sys.source <- function(file, envir) { - stopifnot(identical(normalizePath(file), - normalizePath(file.path(extension, "R", "sess-package-install.R")))) - envir$sess_install <- function(pkg_path, library, repos, interactive) { - stopifnot(identical(pkg_path, pkg), identical(repos, "https://example.com"), - identical(interactive, private)) - if (outcome == "success") { - write_description(installed, "3.0.1", stable) - } else if (outcome == "shadowed") { - write_description(installed, "3.0.1", pre_release) - } else { - stop("Installation failed") - } - } - } - suppressWarnings(tryCatch(source(installer, local = env), error = identity)) -} -write_description(installed, "3.0.1", pre_release) -failure <- simulate("failure") -stopifnot(inherits(failure, "error"), identical(conditionMessage(failure), "Installation failed")) -stopifnot(inherits(simulate("shadowed"), "error")) -assert_success <- function(result) { - if (inherits(result, "error")) stop(conditionMessage(result), call. = FALSE) -} -# A private runtime uses the shared API/source verifier rather than requiring -# its source to be visible in the ordinary terminal's library search path. -assert_success(simulate("shadowed", private = TRUE)) -assert_success(simulate("success")) -stopifnot(!sess_install_required(pkg)) -Sys.unsetenv(c("VSCODE_R_SESS_PKG_PATH", "VSCODE_R_SESS_REPO")) - -# Verify actual R installation preserves the custom field and that querying -# installed metadata does not load the package or run its .onLoad hook. -rm(find.package) -library_path <- file.path(root, "library") -dir.create(library_path) -original_libs <- .libPaths() -.libPaths(c(library_path, original_libs)) -write_description(pkg, "3.0.1", stable) +dir.create(file.path(pkg, "R"), recursive = TRUE) +dependency <- file.path(root, "sessfixturedep") +dir.create(file.path(dependency, "R"), recursive = TRUE) +writeLines(c( + "Package: sessfixturedep", + "Version: 1.0.0", + "Title: Source Identity Dependency Test", + "Description: A dependency-free test dependency.", + "License: MIT", + "Author: Test Author", + "Maintainer: Test Author " +), file.path(dependency, "DESCRIPTION")) +writeLines("fixture_value <- function() 'normal-library-dependency'", file.path(dependency, "R", "api.R")) +writeLines("export(fixture_value)", file.path(dependency, "NAMESPACE")) +utils::install.packages(dependency, repos = NULL, type = "source", lib = user_library, + quiet = TRUE) +write_description(pkg, "3.0.1", stable, imports = "sessfixturedep") cat(paste0( "Title: Source Identity Test\n", "Description: A dependency-free installation fixture.\n", @@ -113,49 +97,110 @@ cat(paste0( "Author: Test Author\n", "Maintainer: Test Author \n" ), file = file.path(pkg, "DESCRIPTION"), append = TRUE) -dir.create(file.path(pkg, "R")) -writeLines(".onLoad <- function(...) stop('Metadata lookup must not load sess')", file.path(pkg, "R", "zzz.R")) -writeLines("", file.path(pkg, "NAMESPACE")) -utils::install.packages(pkg, repos = NULL, type = "source", lib = library_path, - INSTALL_opts = "--no-test-load", quiet = TRUE) -stopifnot(identical(sess_installed_source_revision(), stable), !sess_install_required(pkg)) +writeLines(c( + "connect <- function(...) sessfixturedep::fixture_value()", + "notify_client <- function(...) NULL", + "request_client <- function(...) NULL" +), file.path(pkg, "R", "api.R")) +writeLines(c("export(connect)", "export(notify_client)", "export(request_client)", + "importFrom(sessfixturedep,fixture_value)"), + file.path(pkg, "NAMESPACE")) + +install_sess_script <- file.path(extension, "R", "install_sess.R") +dir.create(dirname(install_sess_script), recursive = TRUE) +for (script in c("install_sess.R", "sess_source.R", "sess-package-install.R")) { + stopifnot(file.copy(file.path("R", script), file.path(dirname(install_sess_script), script))) +} + +ordinary_before <- .libPaths() +previous_r_libs <- Sys.getenv("R_LIBS", unset = NA_character_) +Sys.setenv(R_LIBS = paste(ordinary_before, collapse = .Platform$path.sep)) +inherited_library <- file.path(root, "must not be used") +r_binary <- file.path(R.home("bin"), if (.Platform$OS.type == "windows") "R.exe" else "R") +stopifnot(sess_install_required(bundled)) +existing_user_sess <- readLines(file.path(user_library, "sess", "DESCRIPTION")) +Sys.unsetenv("VSCODE_R_SESS_LIBRARY") +refusal <- suppressWarnings(system2(r_binary, shQuote(c( + "--vanilla", "--silent", "--no-echo", "--no-save", "--no-restore", + paste0("--file=", install_sess_script), "--args", pkg, "https://example.com" +)), stdout = TRUE, stderr = TRUE)) +stopifnot(!is.null(attr(refusal, "status")), attr(refusal, "status") != 0L) +stopifnot(identical(readLines(file.path(user_library, "sess", "DESCRIPTION")), existing_user_sess)) +stopifnot(!dir.exists(inherited_library)) + +Sys.setenv(VSCODE_R_SESS_PKG_PATH = pkg, + VSCODE_R_SESS_REPO = "https://example.com", + VSCODE_R_SESS_LIBRARY = inherited_library, + VSCODE_R_SESS_INTERACTIVE = "0") +output <- suppressWarnings(system2(r_binary, shQuote(c( + "--vanilla", "--silent", "--no-echo", "--no-save", "--no-restore", + paste0("--file=", install_sess_script), "--args", pkg, + "https://example.com", managed_library +)), stdout = TRUE, stderr = TRUE)) +if (!is.null(attr(output, "status")) && attr(output, "status") != 0L) { + stop(paste(output, collapse = "\n")) +} +stopifnot(file.exists(file.path(managed_library, "sess", "DESCRIPTION"))) +stopifnot(!dir.exists(file.path(managed_library, "sessfixturedep"))) +stopifnot(!dir.exists(file.path(inherited_library, "sess"))) +stopifnot(identical(readLines(file.path(user_library, "sess", "DESCRIPTION")), existing_user_sess)) +stopifnot(identical(normalized_paths(.libPaths()), normalized_paths(ordinary_before))) + +# A mismatching namespace already loaded from a project library must survive a +# managed-load refusal. The profile must also stop before connecting the watcher. +write_description(pkg, "3.0.1", pre_release, imports = "sessfixturedep") +utils::install.packages(pkg, repos = NULL, type = "source", lib = project_library, quiet = TRUE) +write_description(pkg, "3.0.1", stable, imports = "sessfixturedep") +child <- file.path(root, "check-loaded-mismatch.R") +profile_consent <- file.path(root, "profile consent") +dir.create(profile_consent) +writeLines(c( + "args <- commandArgs(TRUE)", + "source(args[1])", + "normal <- strsplit(args[5], .Platform$path.sep, fixed=TRUE)[[1]]", + ".libPaths(normal)", + "loadNamespace('sess', lib.loc=normal)", + "expected <- args[3]; mismatch <- args[4]", + "err <- tryCatch(sess_load_namespace(args[2], expected, normal), error=identity)", + "stopifnot(inherits(err, 'error'), identical(sess_loaded_source_revision(), mismatch))", + paste0("Sys.setenv(VSCODE_R_SESS_PKG_PATH=args[6], ", + "VSCODE_R_SESS_SOURCE_HELPER=args[1], ", + "VSCODE_R_SESS_ATTACH_HELPER=file.path(dirname(args[1]), 'attach_sess.R'), ", + "VSCODE_R_SESS_INSTALLER_HELPER=file.path(dirname(args[1]), 'sess-package-install.R'), ", + "VSCODE_R_SESS_ROOT=args[7], VSCODE_R_SESS_CONSENT_DIRECTORY=args[9])"), + "messages <- character()", + paste0("withCallingHandlers(source(args[8]), message=function(m) { ", + "messages <<- c(messages, conditionMessage(m)); invokeRestart('muffleMessage') })"), + paste0("stopifnot(any(grepl('different sess namespace is already loaded', messages)), ", + "identical(sess_loaded_source_revision(), mismatch))"), + "stopifnot(identical(normalizePath(.libPaths(), winslash='/'), normalizePath(normal, winslash='/')))" +), child) +profile <- file.path("R", "profile.R") +child_output <- suppressWarnings(system2(r_binary, shQuote(c( + "--vanilla", "--silent", "--no-echo", "--no-save", "--no-restore", + paste0("--file=", child), "--args", + file.path(getwd(), "R", "sess_source.R"), managed_library, stable, pre_release, + paste(ordinary_before, collapse = .Platform$path.sep), pkg, managed_root, + file.path(getwd(), profile), profile_consent +)), stdout = TRUE, stderr = TRUE)) +if (!is.null(attr(child_output, "status")) && attr(child_output, "status") != 0L) { + stop(paste(child_output, collapse = "\n")) +} + +# Metadata queries and explicit load target the intended copy. The package is +# loaded only after installation, and the user's search path remains untouched. stopifnot(!"sess" %in% loadedNamespaces()) -.libPaths(original_libs) +stopifnot(identical(sess_installed_source_revision(managed_library), stable)) +ns <- sess_load_namespace(managed_library, stable, normal_libraries = ordinary_before) +loaded_path <- normalizePath(getNamespaceInfo(ns, "path"), winslash = "/") +intended_path <- normalizePath(file.path(managed_library, "sess"), winslash = "/") +stopifnot(identical(loaded_path, intended_path)) +stopifnot(identical(sess_loaded_source_revision(), stable)) +stopifnot(identical(getExportedValue("sess", "connect")(), "normal-library-dependency")) +stopifnot(identical(normalized_paths(.libPaths()), normalized_paths(ordinary_before))) -# Run the same R executable invocation as the managed installer, rather than -# source() (which supplies an ofile frame and hides command-line path failures). -local({ - project <- file.path(root, "project with spaces") - cli_library <- file.path(project, "library") - dir.create(cli_library, recursive = TRUE) - writeLines(".libPaths(c(file.path(getwd(), \"library\"), .libPaths()))", file.path(project, ".Rprofile")) - exports <- c("connect", "notify_client", "request_client") - writeLines(paste0(exports, " <- function(...) NULL"), file.path(pkg, "R", "zzz.R")) - writeLines(paste0("export(", exports, ")"), file.path(pkg, "NAMESPACE")) - previous_profile <- Sys.getenv("R_PROFILE_USER", unset = NA_character_) - previous_cwd <- getwd() - on.exit({ - setwd(previous_cwd) - if (is.na(previous_profile)) { - Sys.unsetenv("R_PROFILE_USER") - } else { - Sys.setenv(R_PROFILE_USER = previous_profile) - } - .libPaths(original_libs) - }) - Sys.unsetenv("R_PROFILE_USER") - setwd(project) - r_binary <- file.path(R.home("bin"), if (.Platform$OS.type == "windows") "R.exe" else "R") - output <- suppressWarnings(system2(r_binary, shQuote(c( - "--silent", "--no-echo", "--no-save", "--no-restore", - paste0("--file=", installer), "--args", pkg, "https://example.com" - )), stdout = TRUE, stderr = TRUE)) - if (!is.null(attr(output, "status")) && attr(output, "status") != 0L) { - stop(paste(output, collapse = "\n")) - } - stopifnot(file.exists(file.path(cli_library, "sess", "DESCRIPTION"))) - .libPaths(c(cli_library, original_libs)) - stopifnot(identical(sess_installed_source_revision(), stable)) -}) +Sys.unsetenv(c("VSCODE_R_SESS_PKG_PATH", "VSCODE_R_SESS_REPO", + "VSCODE_R_SESS_LIBRARY", "VSCODE_R_SESS_INTERACTIVE")) +if (is.na(previous_r_libs)) Sys.unsetenv("R_LIBS") else Sys.setenv(R_LIBS = previous_r_libs) unlink(root, recursive = TRUE) -cat("sess source identity and installer tests passed\n") +cat("sess source identity and managed installer tests passed\n") diff --git a/package.json b/package.json index 471eff3d6..e0d1f8af9 100644 --- a/package.json +++ b/package.json @@ -2606,7 +2606,8 @@ "lint": "oxlint src", "typecheck": "tsc --noEmit -p ./", "test:sess-identity": "Rscript --vanilla R/tests/sess_source.R", - "test:sess-source": "node --test scripts/sess-source.test.js && pnpm run test:sess-identity", + "test:sess-attach": "Rscript --vanilla R/tests/attach_sess.R", + "test:sess-source": "node --test scripts/sess-source.test.js && pnpm run test:sess-identity && pnpm run test:sess-attach", "test:interactive": "pnpm run pretest && pnpm run test:node", "test:interactive:examples": "pnpm run compile && tsc && node src/test/examples/run.cjs" }, diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index f8d47c3ea..7861bd33f 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.0.9000.9002 +Version: 3.0.9000.9003 Authors@R: c( person(given = "Randy", family = "Lai", diff --git a/sess/R/server.R b/sess/R/server.R index f90bfcf93..e84dda88e 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -5,6 +5,8 @@ #' @param use_rstudioapi Logical. Enable rstudioapi emulation. Defaults to TRUE. #' @param plot_backend Plot backend: `auto`, `jgd`, `httpgd`, `standard`, or #' `native`. NULL also selects `auto`. +#' @return Invisibly returns `TRUE` when the IPC connection and runtime startup +#' succeed, or `FALSE` when the IPC endpoint is unavailable. #' @details When SESS_DISCOVERY_FILE describes the connected endpoint, an #' unexpected disconnect waits for a replacement endpoint in that file and #' reconnects with the same runtime options and session identity. The optional @@ -34,7 +36,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, endpoint <- .resolve_endpoint(endpoint) if (!nzchar(endpoint)) { warning("[sess] Connection info not available. Cannot connect to VS Code.") - return(invisible(NULL)) + return(invisible(FALSE)) } discovery_file <- Sys.getenv("SESS_DISCOVERY_FILE") @@ -124,7 +126,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, ) } - invisible(NULL) + invisible(isTRUE(connected) && !is.null(.sess_env$con)) } # Resolve the direct argument, environment variables, then discovery data. @@ -281,7 +283,9 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, settings$options$plot_backend %in% c("auto", "jgd")) .sess_env$reconnecting <- TRUE tryCatch( - do.call(connect, c(list(endpoint = endpoint), settings$options)), + .reconnect_with_startup(endpoint, function() { + do.call(connect, c(list(endpoint = endpoint), settings$options)) + }), error = function(e) message("[sess] Reconnection failed: ", conditionMessage(e)), finally = { .sess_env$reconnecting <- NULL @@ -301,6 +305,18 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE, invisible(NULL) } +.reconnect_with_startup <- function(endpoint, setup) { + registration <- getOption("vscodeR.terminalStartup") + if (!is.list(registration) || !identical(registration$pid, Sys.getpid())) { + return(setup()) + } + if (!is.function(registration$run)) { + message("[sess] Terminal startup notifier is unavailable; reconnect was skipped.") + return(invisible(FALSE)) + } + registration$run(endpoint, setup) +} + .transport_disconnect <- function(silent = FALSE, reconnect = FALSE) { con <- .sess_env$con settings <- .sess_env$reconnect diff --git a/sess/README.md b/sess/README.md index 19d69e7fe..c99f92bec 100644 --- a/sess/README.md +++ b/sess/README.md @@ -23,19 +23,43 @@ socket on macOS/Linux, named pipe on Windows) using > ### Bundled install for VS Code > > Users of the VS Code R extension (>=v3.0.0) do not need to install `sess` -> manually. The extension bundles its own copy of `sess` and will install it -> for you (along with any missing CRAN dependencies) if the installed package -> does not match the bundled source snapshot, including when switching between -> stable and pre-release builds. Managed R terminals ask first; attaching an -> existing session installs without prompting. When accepted, installation -> finishes before the managed terminal starts. If sess is already loaded, -> restart R after updating to use the new copy. +> manually. The extension bundles its own copy and asks before installing it +> when no installed copy has the exact bundled source revision. Accepted +> installs and missing dependencies go into a vscode-R-managed library; existing +> user, project, and package-manager libraries are left unchanged. The extension +> explicitly loads the matching copy for its session watcher. Preparation and +> installation use the terminal's R, platform, version, and normal libraries; +> when needed, that R asks the extension for consent. Manual attach follows the +> same rule for the attached R. If a different `sess` is already loaded, restart +> R before attaching. + +R Interactive prepares its isolated runtime as part of starting an Interactive +session; that runtime follows its own lifecycle and does not change ordinary R +libraries. The bundled `sess` is a pure R package and needs no compiler to install from source. Its dependencies, including `processx`, `later`, and `jsonlite`, contain -native code; the extension installs missing Imports from the configured CRAN -repository before installing its bundled copy. The extension does not substitute -a separately published `sess` package if the bundled installation fails. +native code; during initial preparation, the extension installs missing Imports +from the configured CRAN repository before installing its bundled copy. The +extension does not substitute a separately published `sess` package if the +bundled installation fails. + +> [!NOTE] +> +> ### Known limitation: isolated libraries +> +> A vscode-R-managed `sess` copy is reused across projects. Dependencies found in +> ordinary R libraries during initial preparation are not copied into that managed +> library. If an isolated project's `.libPaths()` cannot see them, `sess` may fail +> to load; R still starts, but its session watcher is unavailable. +> +> In the affected project, install the missing dependencies with +> [renv::install()](https://pkgs.rstudio.com/renv/reference/install.html). Then +> restart R or retry manual attach. You do not need to install `sess` in the project. +> +> renv's default `implicit` snapshot type includes packages detected in project +> files, so editor-only dependencies may be omitted; see +> [snapshot types](https://pkgs.rstudio.com/renv/reference/snapshot.html#snapshot-types). R Interactive requires arf on the R host. Managed headless arf sessions stream console output and retain rich tables, HTML, and plots. Attached arf sessions diff --git a/sess/inst/tinytest/test-connect-failure.R b/sess/inst/tinytest/test-connect-failure.R index e199212c7..68fa0772e 100644 --- a/sess/inst/tinytest/test-connect-failure.R +++ b/sess/inst/tinytest/test-connect-failure.R @@ -94,4 +94,9 @@ local({ expect_true(isTRUE(sess:::.runtime_state()$active)) expect_equal(length(callbacks), 0L) expect_equal(sum(getTaskCallbackNames() == "sess.workspace"), 1L) + + close_peer() + listen() + expect_true(isTRUE(do.call(env$connect, c(list(endpoint = endpoint), opts)))) + close_peer() }) diff --git a/sess/inst/tinytest/test-reconnect.R b/sess/inst/tinytest/test-reconnect.R index 23c6065ec..5e8f365a1 100644 --- a/sess/inst/tinytest/test-reconnect.R +++ b/sess/inst/tinytest/test-reconnect.R @@ -5,6 +5,8 @@ local({ env$.sess_env$transport_generation <- 1L env$.schedule_reconnect <- sess:::.schedule_reconnect 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$.sess_env$con <- NULL env$.sess_env$transport_generation <- env$.sess_env$transport_generation + 1L @@ -31,7 +33,12 @@ local({ seen_jgd <<- c(seen_jgd, Sys.getenv("JGD_SOCKET", unset = "")) attempts[[length(attempts) + 1L]] <<- list(...) env$.sess_env$transport_generation <- env$.sess_env$transport_generation + 1L - if (succeed) env$.sess_env$con <- "new connection" + if (succeed) { + env$.sess_env$con <- "new connection" + TRUE + } else { + FALSE + } } path <- tempfile() on.exit(unlink(path), add = TRUE) @@ -124,4 +131,38 @@ local({ expect_equal(Sys.getenv("JGD_SOCKET"), "external-jgd") expect_equal(length(callbacks), 0L) + # A registered notifier receives the replacement endpoint and owns the + # setup callback. A same-process registration without a notifier fails closed. + startup_option <- "vscodeR.terminalStartup" + original_startup <- getOption(startup_option) + on.exit(options(structure(list(original_startup), names = startup_option)), add = TRUE) + notified_endpoint <- character() + registration <- list( + pid = Sys.getpid(), + run = function(endpoint, setup) { + notified_endpoint <<- c(notified_endpoint, endpoint) + setup() + } + ) + options(structure(list(registration), names = startup_option)) + env$.sess_env$con <- NULL + succeed <- TRUE + env$.schedule_reconnect(native_settings, schedule = schedule) + tick() + expect_equal(notified_endpoint, "new") + expect_equal(tail(attempts, 1L)[[1L]], c(list(endpoint = "new"), native_options)) + expect_equal(length(callbacks), 0L) + + registration$run <- NULL + options(structure(list(registration), names = startup_option)) + env$.sess_env$con <- NULL + attempts_before <- length(attempts) + env$.schedule_reconnect(native_settings, schedule = schedule) + tick() # A same-process registration without its notifier must not connect. + expect_equal(length(attempts), attempts_before) + expect_equal(length(callbacks), 1L) + env$.sess_env$transport_generation <- env$.sess_env$transport_generation + 1L + tick() # Invalidate the retry without invoking setup. + expect_equal(length(callbacks), 0L) + }) diff --git a/sess/man/connect.Rd b/sess/man/connect.Rd index e912113ec..0fe74301f 100644 --- a/sess/man/connect.Rd +++ b/sess/man/connect.Rd @@ -19,6 +19,10 @@ If NULL, uses SESS_ENDPOINT, then SESS_DISCOVERY_FILE.} \item{plot_backend}{Plot backend: \code{auto}, \code{jgd}, \code{httpgd}, \code{standard}, or \code{native}. NULL also selects \code{auto}.} } +\value{ +Invisibly returns \code{TRUE} when the IPC connection and runtime startup +succeed, or \code{FALSE} when the IPC endpoint is unavailable. +} \description{ Connect to the VS Code IPC server } diff --git a/src/interactive/backends/sessPreparation.ts b/src/interactive/backends/sessPreparation.ts index 9ae25e094..ed914cee1 100644 --- a/src/interactive/backends/sessPreparation.ts +++ b/src/interactive/backends/sessPreparation.ts @@ -27,6 +27,11 @@ export async function installSessRuntime(extensionPath: string, root: string, rP const resources = path.join(runtime, 'R'); if (fs.existsSync(path.join(runtime, 'ready'))) { return { library, resources }; } const lock = `${runtime}.lock`; + // Keep lock ownership rules in sync with R/attach_sess.R: mkdir claims atomically, + // only the owner releases in finally, and timeout never clears a stale lock. + // Scope: TS lock spans resource staging through ready; the R lock spans consent, package setup, and load. + // Both ready markers mean setup succeeded: TS ready follows install, while R .ready stores the expected + // source revision after namespace load. R followers do not prompt again after contention. const deadline = Date.now() + 120000; for (;;) { try { fs.mkdirSync(lock, { mode: 0o700 }); break; } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 4919a458e..1c6e3228c 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -1,6 +1,7 @@ 'use strict'; import * as path from 'path'; +import * as crypto from 'node:crypto'; import { getMigratedSetting } from './configuration'; import { isDeepStrictEqual } from 'util'; @@ -15,6 +16,7 @@ import { createSessionDiscoveryFile, deferWorkspaceRefresh, getGlobalPipePath, + getSessConsentDirectory, isTerminalClosed, removeTerminalDiscoveryFile, updateTerminalSessionDiscoveryFile, @@ -30,23 +32,37 @@ export let rTerm: vscode.Terminal | undefined = undefined; let rTermResource: vscode.Uri | undefined; const terminalStartup = new WeakMap; }>(); async function prepareTerminalForInput(terminal: vscode.Terminal): Promise { - const startup = terminalStartup.get(terminal); - if (!startup || startup.ready) { + let startup = terminalStartup.get(terminal); + if (!startup) { + const creationOptions = terminal.creationOptions; + const env = creationOptions && 'env' in creationOptions ? creationOptions.env : undefined; + const startupFilePath = env?.['VSCODE_R_SESS_STARTUP_FILE']; + const startupToken = env?.['VSCODE_R_SESS_STARTUP_TOKEN']; + // Terminal profile providers bypass createRTerm, so recover the same + // startup gate from the options VS Code retained for this terminal. + if (typeof startupFilePath === 'string' && typeof startupToken === 'string') { + startup = { integrated: true, gateComplete: false, startupFilePath, startupToken }; + terminalStartup.set(terminal, startup); + } + } + if (!startup || startup.gateComplete) { return !isTerminalClosed(terminal) && !terminal.exitStatus; } if (!startup.pending) { startup.pending = (async () => { const ready = startup.integrated - ? await waitForTerminalReady(terminal) + ? await waitForTerminalReady(terminal, 30000, startup.startupFilePath, startup.startupToken) : await delay(200).then(() => !isTerminalClosed(terminal) && !terminal.exitStatus); - startup.ready = ready; + startup.gateComplete = ready; if (!ready && !isTerminalClosed(terminal) && !terminal.exitStatus) { - void vscode.window.showWarningMessage('R session did not attach, so code was not sent. Install or update sess and restart the R terminal, or disable r.sessionWatcher and reload VS Code before retrying.'); + void vscode.window.showWarningMessage('R session did not attach, so code was not sent. Finish sess setup in the R terminal and retry, or disable r.sessionWatcher and reload VS Code before retrying.'); } return ready; })(); @@ -252,24 +268,55 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise('sessionWatcher')) { - const pipePath = await getGlobalPipePath(); - const discoveryFile = await createSessionDiscoveryFile(pipePath); - const backend = resolveBackend(); - termOptions.env = { - R_PROFILE_USER_OLD: process.env.R_PROFILE_USER, - R_PROFILE_USER: newRprofile, - // Remove inherited endpoint overrides so the per-terminal discovery file - // remains authoritative, including after a VS Code window reload. - SESS_ENDPOINT: null, - SESS_DISCOVERY_FILE: discoveryFile, - SESS_RSTUDIOAPI: config().get('session.emulateRStudioAPI') ? 'TRUE' : 'FALSE', - SESS_PLOT_BACKEND: backend, - }; - if (jgdEnabled(backend)) { - const jgdVars = (globalPlotManager as CommonPlotManager)?.getJgdEnvVars() ?? {}; - Object.assign(termOptions.env, jgdVars); + const skipsProfile = shellArgs.some(arg => arg === '--vanilla' || arg === '--no-init-file'); + const executableAvailable = Boolean(termPath && fs.existsSync(termPath)); + if (currentConfig.get('sessionWatcher') + && !skipsProfile && executableAvailable) { + let discoveryFile: string | undefined; + try { + // Resolve all values that can fail before creating terminal-local files. + const backend = resolveBackend(); + const jgdVars = jgdEnabled(backend) + ? (globalPlotManager as CommonPlotManager)?.getJgdEnvVars() ?? {} + : {}; + const pipePath = await getGlobalPipePath(); + const consentDirectory = await getSessConsentDirectory(); + discoveryFile = await createSessionDiscoveryFile(pipePath); + const startupFile = `${discoveryFile}.startup`; + const startupToken = crypto.randomBytes(16).toString('hex'); + const newRprofile = extensionContext.asAbsolutePath(path.join('R', 'profile.R')); + const env: Record = { + R_PROFILE_USER_OLD: process.env.R_PROFILE_USER, + R_PROFILE_USER: newRprofile, + VSCODE_R_SESS_PKG_PATH: extensionContext.asAbsolutePath(path.join('dist', 'resources', 'sess')), + VSCODE_R_SESS_SOURCE_HELPER: extensionContext.asAbsolutePath(path.join('R', 'sess_source.R')), + VSCODE_R_SESS_ROOT: path.join(extensionContext.globalStorageUri.fsPath, 'sess'), + VSCODE_R_SESS_CONSENT_DIRECTORY: consentDirectory, + VSCODE_R_SESS_INSTALLER_HELPER: extensionContext.asAbsolutePath(path.join('R', 'sess-package-install.R')), + VSCODE_R_SESS_ATTACH_HELPER: extensionContext.asAbsolutePath(path.join('R', 'attach_sess.R')), + VSCODE_R_SESS_STARTUP_FILE: startupFile, + VSCODE_R_SESS_STARTUP_TOKEN: startupToken, + VSCODE_R_SESS_STARTUP_ENDPOINT: pipePath, + VSCODE_R_SESS_STARTUP_HELPER: extensionContext.asAbsolutePath(path.join('R', 'terminal-startup.R')), + // Remove inherited endpoint overrides so the per-terminal discovery file + // remains authoritative, including after a VS Code window reload. + SESS_ENDPOINT: null, + SESS_DISCOVERY_FILE: discoveryFile, + SESS_RSTUDIOAPI: config(configResource).get('session.emulateRStudioAPI') ? 'TRUE' : 'FALSE', + SESS_PLOT_BACKEND: backend, + }; + Object.assign(env, jgdVars); + termOptions.env = env; + } catch (error) { + if (discoveryFile) { + for (const filePath of [discoveryFile, `${discoveryFile}.startup`]) { + await fs.promises.rm(filePath, { recursive: true, force: true }).catch(cleanupError => { + console.error('Failed to clean incomplete session watcher files', cleanupError); + }); + } + } + void vscode.window.showWarningMessage( + `The R session watcher could not be prepared. R will start without session integration. ${String(error)}`); } } return termOptions; @@ -280,15 +327,17 @@ export async function createRTerm(preserveshow?: boolean, resource?: vscode.Uri) const termOptions = await makeTerminalOptions(resource); const termPath = termOptions.shellPath; const discoveryFile = termOptions.env?.['SESS_DISCOVERY_FILE']; + const startupFile = termOptions.env?.['VSCODE_R_SESS_STARTUP_FILE']; const discardDiscoveryFile = async () => { - if (typeof discoveryFile === 'string') { + for (const filePath of [discoveryFile, startupFile]) { + if (typeof filePath !== 'string') { continue; } try { - await fs.promises.unlink(discoveryFile); + await fs.promises.unlink(filePath); } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return; + continue; } - console.error('Failed to remove unused session discovery file', error); + console.error('Failed to remove unused session startup file', error); } } }; @@ -302,10 +351,6 @@ export async function createRTerm(preserveshow?: boolean, resource?: vscode.Uri) } let createdTerminal: vscode.Terminal; try { - if (!await util.promptToInstallSessPackage(termOptions.cwd)) { - await discardDiscoveryFile(); - return false; - } createdTerminal = vscode.window.createTerminal(termOptions); } catch (error) { await discardDiscoveryFile(); @@ -317,7 +362,10 @@ export async function createRTerm(preserveshow?: boolean, resource?: vscode.Uri) const skipsProfile = Array.isArray(args) && args.some(arg => arg === '--vanilla' || arg === '--no-init-file'); terminalStartup.set(createdTerminal, { integrated: typeof discoveryFile === 'string' && !skipsProfile, - ready: false, + gateComplete: false, + startupFilePath: typeof startupFile === 'string' ? startupFile : undefined, + startupToken: typeof termOptions.env?.['VSCODE_R_SESS_STARTUP_TOKEN'] === 'string' + ? termOptions.env['VSCODE_R_SESS_STARTUP_TOKEN'] : undefined, }); createdTerminal.show(preserveshow); @@ -329,7 +377,7 @@ export async function createRTerm(preserveshow?: boolean, resource?: vscode.Uri) } } }).catch(error => console.error('Failed to update terminal session discovery file', error)); - + return true; } diff --git a/src/sessConsent.ts b/src/sessConsent.ts new file mode 100644 index 000000000..d3e557ef3 --- /dev/null +++ b/src/sessConsent.ts @@ -0,0 +1,182 @@ +import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; + +export type SessConsentReason = 'missing' | 'mismatch'; + +export interface SessConsentRequest { + id: string; + revision: string; + runtime: string; + reason: SessConsentReason; +} + +export type SessConsentChoice = 'install' | 'notNow' | 'dontAskAgain' | 'dismiss'; + +export interface SessConsentServiceOptions { + directory: string; + expectedRevision: string; + isEnabled: () => boolean; + getDismissedRevision: () => string | undefined; + rememberDismissedRevision: (revision: string) => Promise; + prompt: (request: SessConsentRequest) => Promise; + intervalMs?: number; +} + +const REQUEST_NAME = /^[A-Za-z0-9_-]{1,64}\.request$/; +const REVISION = /^git-tree:(?:[a-f0-9]{40}|[a-f0-9]{64})$/; +const RUNTIME = /^[A-Za-z0-9_.-]+\|[0-9]+\.[0-9]+$/; + +/** Bridges the target R process's bounded consent request to a VS Code prompt. */ +export class SessConsentService { + private timer: NodeJS.Timeout | undefined; + private stopped = true; + private readonly pending = new Map>(); + private readonly seen = new Set(); + private readonly activeWrites = new Set>(); + + constructor(private readonly options: SessConsentServiceOptions) { } + + async start(): Promise { + if (!this.stopped) { return; } + await fs.mkdir(this.options.directory, { recursive: true, mode: 0o700 }); + await this.setOwnerOnlyPermissions(this.options.directory, 0o700); + this.stopped = false; + this.timer = setInterval(() => { void this.scan(); }, this.options.intervalMs ?? 200); + this.timer.unref?.(); + await this.scan(); + } + + async stop(): Promise { + this.stopped = true; + if (this.timer) { clearInterval(this.timer); this.timer = undefined; } + // A pending VS Code notification may outlive shutdown. A denial written + // here wins because the request has a unique, single-use ID. + await Promise.all([...this.pending.keys()].map(id => this.writeResponse(id, 'decline'))); + await Promise.all(this.activeWrites); + this.seen.clear(); + } + + private async scan(): Promise { + if (this.stopped) { return; } + let names: string[]; + try { names = await fs.readdir(this.options.directory); } + catch { return; } + if (this.stopped) { return; } + const present = new Set(names.filter(name => REQUEST_NAME.test(name)).map(name => name.slice(0, -'.request'.length))); + for (const id of this.seen) { + if (!present.has(id) && !this.pending.has(id)) { this.seen.delete(id); } + } + for (const name of names) { + if (!REQUEST_NAME.test(name)) { continue; } + const id = name.slice(0, -'.request'.length); + if (this.pending.has(id) || this.seen.has(id)) { continue; } + this.seen.add(id); + const work = this.processRequest(id).finally(() => this.pending.delete(id)); + this.pending.set(id, work); + } + } + + private async processRequest(id: string): Promise { + const requestPath = path.join(this.options.directory, `${id}.request`); + let request: SessConsentRequest; + try { + request = this.parseRequest(id, await this.readBoundedRequest(requestPath)); + } catch { + if (!this.stopped) { await this.writeResponse(id, 'decline'); } + return; + } + if (this.stopped) { return; } + if (request.revision !== this.options.expectedRevision || !this.options.isEnabled()) { + await this.writeResponse(id, 'decline'); + return; + } + try { + if (this.options.getDismissedRevision() === request.revision) { + await this.writeResponse(id, 'decline'); + return; + } + } catch { + // A state read failure cannot grant approval; ask for this request. + } + + let choice: SessConsentChoice = 'dismiss'; + try { choice = await this.options.prompt(request); } + catch { choice = 'dismiss'; } + if (this.stopped) { return; } + if (!this.options.isEnabled()) { + await this.writeResponse(id, 'decline'); + return; + } + if (choice === 'dontAskAgain') { + try { await this.options.rememberDismissedRevision(request.revision); } + catch { + if (!this.stopped) { await this.writeResponse(id, 'decline'); } + return; + } + if (this.stopped) { return; } + await this.writeResponse(id, 'decline'); + return; + } + const response = !this.stopped && this.options.isEnabled() && choice === 'install' ? 'approve' : 'decline'; + await this.writeResponse(id, response); + } + + private parseRequest(id: string, content: string): SessConsentRequest { + if (Buffer.byteLength(content, 'utf8') > 256 || !content.endsWith('\n')) { + throw new Error('Invalid sess consent request size or termination.'); + } + const normalized = content.replace(/\r\n/g, '\n'); + if (normalized.includes('\r')) { throw new Error('Invalid sess consent line ending.'); } + const lines = normalized.slice(0, -1).split('\n'); + if (lines.length !== 5 || lines[0] !== 'vscode-r-sess-consent-v1' || lines[1] !== id + || !REVISION.test(lines[2]) || !RUNTIME.test(lines[3]) + || (lines[4] !== 'missing' && lines[4] !== 'mismatch')) { + throw new Error('Malformed sess consent request.'); + } + return { id, revision: lines[2], runtime: lines[3], reason: lines[4] }; + } + + private async readBoundedRequest(filePath: string): Promise { + const handle = await fs.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(257); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > 256) { throw new Error('Sess consent request exceeds its size limit.'); } + return buffer.toString('utf8', 0, bytesRead); + } finally { await handle.close(); } + } + + private async writeResponse(id: string, response: 'approve' | 'decline'): Promise { + const write = this.publishResponse(id, response); + this.activeWrites.add(write); + try { await write; } + finally { this.activeWrites.delete(write); } + } + + private async publishResponse(id: string, response: 'approve' | 'decline'): Promise { + const finalPath = path.join(this.options.directory, `${id}.response`); + const temporaryPath = path.join(this.options.directory, `${id}.${crypto.randomBytes(8).toString('hex')}.tmp`); + try { + await fs.writeFile(temporaryPath, `${response}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + await this.setOwnerOnlyPermissions(temporaryPath, 0o600); + // Publish in one synchronous step so shutdown/config changes cannot + // interleave between the final grant check and its visible response. + const requestPath = path.join(this.options.directory, `${id}.request`); + if (response === 'approve' + && (this.stopped || !this.options.isEnabled() || !fsSync.existsSync(requestPath))) { + await fs.writeFile(temporaryPath, 'decline\n', { encoding: 'utf8', mode: 0o600 }); + await this.setOwnerOnlyPermissions(temporaryPath, 0o600); + } + fsSync.renameSync(temporaryPath, finalPath); + await this.setOwnerOnlyPermissions(finalPath, 0o600); + } catch { + await fs.rm(temporaryPath, { force: true }).catch(() => undefined); + } + } + + private async setOwnerOnlyPermissions(filePath: string, mode: number): Promise { + if (process.platform !== 'win32') { await fs.chmod(filePath, mode); } + } +} diff --git a/src/session.ts b/src/session.ts index 1e649e2ee..8a7d41550 100644 --- a/src/session.ts +++ b/src/session.ts @@ -11,7 +11,7 @@ import * as vscode from 'vscode'; import { commands, Uri, ViewColumn, Webview, window, env } from 'vscode'; import { restartRTerminal } from './rTerminal'; -import { config, readContent, setContext, UriIcon } from './util'; +import { config, readContent, readSessSourceRevision, setContext, UriIcon } from './util'; import * as rTerminal from './rTerminal'; import { getProcessAncestors } from './processTree'; import { TerminalSessionRegistry } from './terminalSessionRegistry'; @@ -28,6 +28,13 @@ import { getListViewerScript, listViewerStyle, ListViewNavigation } from './list import { getDataViewerScript, getDataViewerStyle, getDataViewerToolbarHtml } from './dataViewer'; import { getDataViewerColumnPanelHtml, getDataViewerColumnPanelScript, getDataViewerColumnPanelStyle } from './dataViewerColumnPanel'; import { createViewerSessionContext, formatSessionLabel, getViewerSessionScript, ViewerSessionContext, ViewerSessionSource } from './viewerSession'; +import { SessConsentChoice, SessConsentService } from './sessConsent'; +import { + assessTerminalStartup, + parseTerminalStartupRecord, + TerminalStartupRecord, + TerminalStartupStatus, +} from './terminalStartup'; export interface SessionInfo { version: string; @@ -213,26 +220,157 @@ export function registerSessionTransport(id: string, host: string, directory: st sessions.set(id, target); return target; } -/** Wait for the same connected owner used by execution and terminal selection. */ -export function waitForTerminalReady(terminal: vscode.Terminal, timeout = 30000): Promise { +const SESS_SETUP_TIMEOUT = 600000; +const TERMINAL_ATTACH_TIMEOUT = 30000; + +/** Read the bounded status sidecar written by the R startup helper. */ +async function readTerminalStartupRecord(filePath: string, token: string): Promise { + let file: fs.promises.FileHandle | undefined; + try { + file = await fsp.open(filePath, 'r'); + const buffer = Buffer.alloc(513); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > 512) { return undefined; } + return parseTerminalStartupRecord(buffer.toString('utf8', 0, bytesRead), token); + } catch { + return undefined; + } finally { + await file?.close().catch(() => undefined); + } +} + +/** + * Wait for this R process's setup attempt and its matching attached owner. + * A new manual-attach attempt may satisfy a fresh wait, but can never release + * input held by a wait that already observed a different attempt. + */ +export function waitForTerminalReady(terminal: vscode.Terminal, timeout = 30000, startupFilePath?: string, + startupToken?: string): Promise { return new Promise(resolve => { let settled = false; + let observedAttemptId: string | undefined; + let observedState: TerminalStartupStatus | undefined; + let latestRecord: TerminalStartupRecord | undefined; + let startupTimer: NodeJS.Timeout | undefined; + let pendingTimer: NodeJS.Timeout | undefined; + let attachTimer: NodeJS.Timeout | undefined; + let pollTimer: NodeJS.Timeout | undefined; + let checkingRecord = false; + const finish = (ready: boolean) => { if (settled) { return; } settled = true; - clearTimeout(timer); + if (startupTimer) { clearTimeout(startupTimer); } + if (pendingTimer) { clearTimeout(pendingTimer); } + if (attachTimer) { clearTimeout(attachTimer); } + if (pollTimer) { clearTimeout(pollTimer); } attached.dispose(); closed.dispose(); resolve(ready); }; + const isClosed = () => terminalRegistry.isClosed(terminal) || terminal.exitStatus !== undefined; + const checkOwner = () => { + if (settled) { return; } + if (isClosed()) { finish(false); } + else if (observedState === 'failed') { finish(false); } + else if (!startupFilePath && terminalRegistry.ownerOf(terminal)) { finish(true); } + else if (observedState === 'ready' && latestRecord) { + const owner = terminalRegistry.ownerOf(terminal); + if (assessTerminalStartup(observedAttemptId, latestRecord, owner?.pid, owner?.pipePath).readyForOwner) { + finish(true); + } + } + }; + const setRecord = (record: TerminalStartupRecord | undefined) => { + if (settled) { return; } + const owner = terminalRegistry.ownerOf(terminal); + const decision = assessTerminalStartup(observedAttemptId, record, owner?.pid, owner?.pipePath); + const observation = decision.observation; + if (observation === 'unknown') { + // A read that cannot identify the current status must not leave an + // older ready snapshot available to a later owner event. + latestRecord = undefined; + return; + } + if (observation === 'attempt-changed') { + finish(false); + return; + } + if (!record) { return; } + latestRecord = record; + if (observedAttemptId === undefined) { observedAttemptId = decision.attemptId; } + if (observation === 'failed') { + observedState = 'failed'; + finish(false); + } else if (observation === 'pending') { + if (observedState !== 'pending') { + observedState = 'pending'; + if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; } + pendingTimer = setTimeout(() => finish(false), SESS_SETUP_TIMEOUT); + } + } else if (observation === 'ready') { + if (observedState !== 'ready') { + observedState = 'ready'; + if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; } + if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = undefined; } + attachTimer = setTimeout(() => finish(false), Math.min(timeout, TERMINAL_ATTACH_TIMEOUT)); + } + if (decision.readyForOwner) { finish(true); } + else { checkOwner(); } + } + }; + const schedulePoll = (delay = 100) => { + if (settled) { return; } + if (pollTimer) { clearTimeout(pollTimer); pollTimer = undefined; } + pollTimer = setTimeout(() => { + pollTimer = undefined; + beginRead(); + }, delay); + }; + const readCurrentRecord = async () => { + if (settled || !startupFilePath || !startupToken || checkingRecord) { return; } + checkingRecord = true; + const ownerSnapshotIsCurrent = terminalRegistry.snapshot(terminal); + let record: TerminalStartupRecord | undefined; + try { + record = await readTerminalStartupRecord(startupFilePath, startupToken); + } finally { + checkingRecord = false; + } + if (settled) { return; } + if (!ownerSnapshotIsCurrent()) { + // Ownership changed during the file read. Retry against the + // current association before accepting any ready record. + latestRecord = undefined; + schedulePoll(0); + return; + } + setRecord(record); + if (!settled) { checkOwner(); } + schedulePoll(); + }; + const beginRead = () => { + void readCurrentRecord().catch(() => { + checkingRecord = false; + if (!settled) { latestRecord = undefined; checkOwner(); schedulePoll(); } + }); + }; const check = () => { - if (terminalRegistry.isClosed(terminal) || terminal.exitStatus) { finish(false); } - else if (terminalRegistry.ownerOf(terminal)) { finish(true); } + if (settled) { return; } + if (isClosed()) { finish(false); return; } + if (!startupFilePath || !startupToken) { checkOwner(); return; } + beginRead(); }; const attached = terminalRegistry.onDidChange(changed => { if (changed === terminal) { check(); } }); const closed = window.onDidCloseTerminal(changed => { if (changed === terminal) { finish(false); } }); - // A timeout rejects startup; it never authorizes sending input. - const timer = setTimeout(() => finish(false), timeout); + + // Missing or invalid state keeps the ordinary attachment timeout. + startupTimer = setTimeout(() => finish(false), timeout); + // Preserve the historical immediate path for terminals without profile setup. + if (!startupFilePath) { checkOwner(); } + if (startupFilePath && startupToken) { + beginRead(); + } check(); }); } @@ -433,6 +571,10 @@ function queueTerminalDiscoveryOperation(terminal: vscode.Terminal, operation: ( let globalSessionServer: net.Server | undefined; let globalSessionServerStartup: Promise | undefined; let attachSessionScriptPath: string | undefined; +let attachConsentDirectory: string | undefined; +let attachConsentService: SessConsentService | undefined; +let attachConsentStartup: Promise | undefined; +const SESS_INSTALL_PROMPT_DISMISSED_REVISION = 'sessInstallPromptDismissedRevision'; interface SessionDiscoveryFile { version: 1; @@ -491,7 +633,10 @@ export async function removeTerminalDiscoveryFile(terminal: vscode.Terminal): Pr } if (discoveryPath) { - await fsp.rm(discoveryPath, { recursive: true, force: true }); + await Promise.all([ + fsp.rm(discoveryPath, { recursive: true, force: true }), + fsp.rm(`${discoveryPath}.startup`, { force: true }), + ]); } }); } @@ -500,8 +645,15 @@ export async function createSessionDiscoveryFile(endpoint: string): Promise { + console.error('Failed to clean incomplete session discovery file', cleanupError); + }); + throw error; + } } async function writeSessionDiscoveryFile(filePath: string, endpoint: string, terminalPid?: number): Promise { @@ -838,39 +990,99 @@ function getAttachSessionScriptPath(pipePath: string): string { return path.join(extensionContext.globalStorageUri.fsPath, 'tmp', 'attach', `${scriptBase}.R`); } -function buildAttachSessionScript(pipePath: string, sessPath: string, installSessScriptPath: string): string { +function buildAttachSessionScript(pipePath: string, sessPath: string, consentDirectory: string): string { const backend = resolveBackend(); const jgdSocket = getSessionJgdSocket(); return [ 'local({', ` endpoint <- ${asRStringLiteral(pipePath)}`, ` sess_src <- ${asRStringLiteral(sessPath)}`, - ` install_sess_script <- ${asRStringLiteral(installSessScriptPath)}`, + ` managed_root <- ${asRStringLiteral(path.join(extensionContext.globalStorageUri.fsPath, 'sess').replace(/\\/g, '/'))}`, + ` consent_dir <- ${asRStringLiteral(consentDirectory.replace(/\\/g, '/'))}`, + ` source_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'sess_source.R')).replace(/\\/g, '/'))}`, + ` installer_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'sess-package-install.R')).replace(/\\/g, '/'))}`, + ` startup_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'terminal-startup.R')).replace(/\\/g, '/'))}`, + ` attach_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'attach_sess.R')).replace(/\\/g, '/'))}`, ...(backend === 'native' ? [] : [ jgdSocket ? ` Sys.setenv(JGD_SOCKET = ${asRStringLiteral(jgdSocket)})` : ' Sys.unsetenv("JGD_SOCKET")', ]), - ` source(${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'sess_source.R')).replace(/\\/g, '/'))}, local = TRUE)`, - ' if (sess_install_required(sess_src)) {', - ' if (!file.exists(install_sess_script)) {', - ' stop(sprintf("install_sess.R not found: %s", install_sess_script))', - ' }', - ' Sys.setenv(VSCODE_R_SESS_PKG_PATH = sess_src)', - ' on.exit(Sys.unsetenv(c("VSCODE_R_SESS_PKG_PATH", "VSCODE_R_SESS_REPO")), add = TRUE)', - ' source(install_sess_script, local = TRUE)', - ' }', - ` sess::connect(endpoint = endpoint, plot_backend = ${asRStringLiteral(backend)})`, + ' source(startup_helper, local = TRUE)', + ' source(attach_helper, local = TRUE)', + ` vscode_r_attach_sess(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, ${asRStringLiteral(backend)})`, '})', '', ].join('\n'); } +async function ensureAttachSessConsentService(): Promise { + if (attachConsentStartup) { return attachConsentStartup; } + if (attachConsentService && attachConsentDirectory) { return attachConsentDirectory; } + + const startup = (async (): Promise => { + const directory = path.join(extensionContext.globalStorageUri.fsPath, 'tmp', 'attach', 'consent', crypto.randomBytes(16).toString('hex')); + const sessDescription = await readContent( + extensionContext.asAbsolutePath(path.join('dist', 'resources', 'sess', 'DESCRIPTION')), 'utf8'); + const sourceRevision = readSessSourceRevision(sessDescription); + if (!sourceRevision) { + throw new Error('The bundled sess package has no valid source revision.'); + } + const service = new SessConsentService({ + directory, + expectedRevision: sourceRevision, + isEnabled: () => config().get('sessionWatcher') === true, + getDismissedRevision: () => { + const value = extensionContext.globalState.get(SESS_INSTALL_PROMPT_DISMISSED_REVISION); + return typeof value === 'string' ? value : undefined; + }, + rememberDismissedRevision: async revision => { + await Promise.resolve(extensionContext.globalState.update(SESS_INSTALL_PROMPT_DISMISSED_REVISION, revision)); + }, + prompt: async request => { + const mismatch = request.reason === 'mismatch'; + const message = mismatch + ? 'The installed sess does not match this build of vscode-R. Install the bundled copy in a vscode-R-managed library? Your existing sess installation will not be modified. “Don’t ask again” applies to this bundled sess revision.' + : 'vscode-R needs bundled sess to attach the session watcher. Install it in a vscode-R-managed library? Your existing sess installation will not be modified. “Don’t ask again” applies to this bundled sess revision.'; + const choice = await window.showWarningMessage( + message, 'Install bundled sess', 'Not now', "Don't ask again"); + const choices: Record = { + 'Install bundled sess': 'install', + 'Not now': 'notNow', + "Don't ask again": 'dontAskAgain', + }; + return choices[choice ?? ''] ?? 'dismiss'; + }, + }); + try { + await service.start(); + attachConsentDirectory = directory; + attachConsentService = service; + return directory; + } catch (error) { + await service.stop(); + await removePathIfExists(directory); + throw error; + } + })(); + attachConsentStartup = startup; + try { + return await startup; + } catch (error) { + if (attachConsentStartup === startup) { attachConsentStartup = undefined; } + throw error; + } +} + +export async function getSessConsentDirectory(): Promise { + return ensureAttachSessConsentService(); +} + export async function getAttachSessionCommand(): Promise { const pipePath = await getGlobalPipePath(); const sessPath = extensionContext.asAbsolutePath(path.join('dist', 'resources', 'sess')).replace(/\\/g, '/'); - const installSessScriptPath = extensionContext.asAbsolutePath(path.join('R', 'install_sess.R')).replace(/\\/g, '/'); + const consentDirectory = await getSessConsentDirectory(); const scriptPath = getAttachSessionScriptPath(pipePath); await fsp.mkdir(path.dirname(scriptPath), { recursive: true }); - await fsp.writeFile(scriptPath, buildAttachSessionScript(pipePath, sessPath, installSessScriptPath), { encoding: 'utf-8', mode: 0o600 }); + await fsp.writeFile(scriptPath, buildAttachSessionScript(pipePath, sessPath, consentDirectory), { encoding: 'utf-8', mode: 0o600 }); await setOwnerOnlyPermissions(scriptPath); attachSessionScriptPath = scriptPath; @@ -888,6 +1100,15 @@ async function removePathIfExists(pathLike: string): Promise { } export async function shutdownSessionWatcher(): Promise { + const consentStartup = attachConsentStartup; + await consentStartup?.catch(() => undefined); + await attachConsentService?.stop(); + attachConsentService = undefined; + if (attachConsentDirectory) { + await removePathIfExists(attachConsentDirectory); + attachConsentDirectory = undefined; + } + if (attachConsentStartup === consentStartup) { attachConsentStartup = undefined; } // Startup publishes the server only after listen and permission setup finish. // Wait before capturing it, otherwise it could survive extension shutdown. await globalSessionServerStartup?.catch(() => undefined); @@ -2492,6 +2713,10 @@ export async function sessionRequest( } export async function connectToSession(): Promise { + if (!config().get('sessionWatcher')) { + void window.showInformationMessage('This command requires that r.sessionWatcher be enabled.'); + return; + } const command = await getAttachSessionCommand(); void vscode.env.clipboard.writeText(command); void vscode.window.showInformationMessage(`R command copied to clipboard: ${command}`); diff --git a/src/terminalStartup.ts b/src/terminalStartup.ts new file mode 100644 index 000000000..47f6feb8f --- /dev/null +++ b/src/terminalStartup.ts @@ -0,0 +1,57 @@ +export type TerminalStartupStatus = 'pending' | 'ready' | 'failed'; + +export interface TerminalStartupRecord { + token: string; + attemptId: string; + pid: number; + endpoint: string; + status: TerminalStartupStatus; +} + +export type TerminalStartupObservation = TerminalStartupStatus | 'unknown' | 'attempt-changed'; + +export interface TerminalStartupDecision { + observation: TerminalStartupObservation; + attemptId?: string; + readyForOwner: boolean; +} + +const PROTOCOL = 'vscode-r-terminal-startup-v1'; +const MAX_RECORD_BYTES = 512; + +/** Parse the small base-R sidecar without accepting partial or cross-terminal records. */ +export function parseTerminalStartupRecord(contents: string, expectedToken: string): TerminalStartupRecord | undefined { + if (Buffer.byteLength(contents, 'utf8') > MAX_RECORD_BYTES) { return; } + const lines = contents.replace(/\r\n/g, '\n').split('\n'); + if (lines.at(-1) !== '') { return; } + lines.pop(); + if (lines.length !== 6 || lines[0] !== PROTOCOL || lines[1] !== expectedToken) { return; } + if (!/^[a-f0-9]{32}$/.test(lines[1])) { return; } + if (!/^[A-Za-z0-9_-]{16,64}$/.test(lines[2])) { return; } + if (!/^[1-9][0-9]*$/.test(lines[3])) { return; } + const pid = Number(lines[3]); + if (!Number.isSafeInteger(pid)) { return; } + if (!lines[4] || /[\r\n]/.test(lines[4])) { return; } + if (lines[5] !== 'pending' && lines[5] !== 'ready' && lines[5] !== 'failed') { return; } + return { token: lines[1], attemptId: lines[2], pid, endpoint: lines[4], status: lines[5] }; +} + +/** A wait belongs to one setup attempt; a later attempt cannot release its queued input. */ +export function assessTerminalStartup( + previousAttemptId: string | undefined, + record: TerminalStartupRecord | undefined, + ownerPid: string | undefined, + ownerEndpoint: string | undefined, +): TerminalStartupDecision { + if (!record) { return { observation: 'unknown', attemptId: previousAttemptId, readyForOwner: false }; } + if (previousAttemptId !== undefined && previousAttemptId !== record.attemptId) { + return { observation: 'attempt-changed', attemptId: record.attemptId, readyForOwner: false }; + } + const ownerMatches = Boolean(ownerPid && /^[1-9][0-9]*$/.test(ownerPid) && Number(ownerPid) === record.pid + && ownerEndpoint === record.endpoint); + return { + observation: record.status, + attemptId: record.attemptId, + readyForOwner: record.status === 'ready' && ownerMatches, + }; +} diff --git a/src/test/common/mockvscode.ts b/src/test/common/mockvscode.ts index 02ccde051..ebef7ea81 100644 --- a/src/test/common/mockvscode.ts +++ b/src/test/common/mockvscode.ts @@ -19,7 +19,7 @@ export function mockExtensionContext(extension_root: string, sandbox: sinon.Sino extensionUri: vscode.Uri.file(extension_root), globalState: { get: sinon.stub().callsFake((key: string, defaultValue?: unknown) => defaultValue), - set: sinon.stub() + update: sinon.stub().resolves() }, globalStorageUri: vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-test-global-storage', String(process.pid))), logUri: sandbox.stub(), diff --git a/src/test/integration/sessInstall.test.ts b/src/test/integration/sessInstall.test.ts deleted file mode 100644 index 96c758f53..000000000 --- a/src/test/integration/sessInstall.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import * as assert from 'assert'; -import * as fsp from 'node:fs/promises'; -import { pathExists } from '../../fileSystem'; -import * as path from 'path'; -import * as sinon from 'sinon'; -import * as vscode from 'vscode'; -import * as util from '../../util'; -import { mockExtensionContext } from '../common/mockvscode'; - -const bundledRevision = 'git-tree:' + 'a'.repeat(40); -const oldRevision = 'git-tree:' + 'b'.repeat(40); -const description = (revision: string) => [ - 'Package: sess', - 'Version: 0.0.1', - 'Title: Installer Test Fixture', - 'Description: A dependency-free installer regression fixture.', - 'License: MIT', - 'Author: Test Author', - 'Maintainer: Test Author ', - `Config/vscode-R/source-revision: ${revision}`, - '' -].join('\n'); - -suite('Sess installation with real R tasks', () => { - let sandbox: sinon.SinonSandbox; - let extensionRoot: string; - let projectA: string; - let projectB: string; - - setup(async () => { - sandbox = sinon.createSandbox(); - const folders = vscode.workspace.workspaceFolders; - assert.ok(folders, 'Run using the sess-install-tasks configuration'); - assert.strictEqual(folders.length, 2); - projectA = folders[0].uri.fsPath; - projectB = folders[1].uri.fsPath; - const root = path.dirname(projectA); - extensionRoot = path.join(root, 'extension with spaces'); - const repositoryRoot = path.join(__dirname, '..', '..', '..'); - await fsp.mkdir(path.join(extensionRoot, 'R'), { recursive: true }); - for (const script of ['install_sess.R', 'sess_source.R', 'sess-package-install.R']) { - await fsp.copyFile(path.join(repositoryRoot, 'R', script), path.join(extensionRoot, 'R', script)); - } - const pkg = path.join(extensionRoot, 'dist', 'resources', 'sess'); - await fsp.mkdir(path.join(pkg, 'R'), { recursive: true }); - await fsp.writeFile(path.join(pkg, 'DESCRIPTION'), description(bundledRevision)); - const exports = ['connect', 'notify_client', 'request_client']; - await fsp.writeFile(path.join(pkg, 'NAMESPACE'), exports.map(name => `export(${name})`).join('\n')); - await fsp.writeFile(path.join(pkg, 'R', 'fixture.R'), exports.map(name => `${name} <- function(...) NULL`).join('\n')); - for (const project of [projectA, projectB]) { - const library = path.join(project, 'library'); - await fsp.mkdir(path.join(library, 'sess'), { recursive: true }); - // Seed visible old metadata so the globally installed sess cannot - // make the project appear up to date or hide a failed installation. - await fsp.writeFile(path.join(library, 'sess', 'DESCRIPTION'), description(oldRevision)); - await fsp.writeFile(path.join(project, '.Rprofile'), '.libPaths(c(file.path(getwd(), "library"), .libPaths()))\n'); - } - mockExtensionContext(extensionRoot, sandbox); - sandbox.stub(vscode.window, 'showWarningMessage').resolves('Yes' as unknown as vscode.MessageItem); - }); - - teardown(async () => { - sandbox.restore(); - for (const project of [projectA, projectB]) { - await fsp.rm(path.join(project, 'library'), { recursive: true, force: true }); - await fsp.rm(path.join(project, '.Rprofile'), { recursive: true, force: true }); - } - await fsp.rm(extensionRoot, { recursive: true, force: true }); - }); - - for (const asUri of [false, true]) { - test(`installs into the second project's library (URI: ${String(asUri)})`, async () => { - const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const settings = { get: () => true } as unknown as vscode.WorkspaceConfiguration; - assert.strictEqual(await util.getInstalledSessSourceRevision(projectA), oldRevision); - assert.strictEqual(await util.getInstalledSessSourceRevision(projectB), oldRevision); - - // Use the production identity checks, R invocation and VS Code task. - const ready = await util.promptToInstallSessPackage( - asUri ? vscode.Uri.file(projectB) : projectB, () => settings); - - assert.strictEqual(ready, true, JSON.stringify(errors.args)); - assert.strictEqual(errors.called, false); - assert.strictEqual(await util.getInstalledSessSourceRevision(projectB), bundledRevision); - assert.strictEqual(await util.getInstalledSessSourceRevision(projectA), oldRevision); - // R may fold the installed DESCRIPTION field onto a continuation - // line. The production R query above reads it through read.dcf(). - assert.strictEqual(await pathExists(path.join(projectB, 'library', 'sess', 'Meta', 'package.rds')), true); - assert.strictEqual(await pathExists(path.join(projectA, 'library', 'sess', 'Meta')), false); - }).timeout(120000); - } -}); diff --git a/src/test/node/interactiveRuntime.test.ts b/src/test/node/interactiveRuntime.test.ts index 782bfad45..169cc111a 100644 --- a/src/test/node/interactiveRuntime.test.ts +++ b/src/test/node/interactiveRuntime.test.ts @@ -234,8 +234,10 @@ sess::display("display-${cell}", "text/plain"); cat("stderr-${cell}\\n", file=st }); test('interrupts evaluation without losing R and cancels queued code', async () => { - const id = await submit('kept <- 17; Sys.sleep(30)'); - await until(() => events.some(event => event.executionId === id && event.type === 'started')); + const marker = `interrupt-ready-${randomUUID()}`; + // Keep the marker and blocking call in one expression, without inter-expression hooks between them. + const id = await submit(`{ kept <- 17; cat(${JSON.stringify(marker)}, "\\n"); Sys.sleep(30) }`); + await until(() => text(id).includes(marker)); const queued = await submit('kept <- 0'); await client.request('cancel', { id: queued }); assert.strictEqual((await finished(queued)).state, 'cancelled'); await client.request('interrupt', { id }); assert.strictEqual((await finished(id)).state, 'interrupted'); diff --git a/src/test/node/sessConsent.test.ts b/src/test/node/sessConsent.test.ts new file mode 100644 index 000000000..9ba703e47 --- /dev/null +++ b/src/test/node/sessConsent.test.ts @@ -0,0 +1,234 @@ +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 { SessConsentChoice, SessConsentService } from '../../sessConsent'; + +const revision = `git-tree:${'a'.repeat(40)}`; +const nextRevision = `git-tree:${'c'.repeat(40)}`; + +async function waitForFile(file: string): Promise { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + try { return await fs.readFile(file, 'utf8'); } + catch { await new Promise(resolve => setTimeout(resolve, 10)); } + } + throw new Error(`Timed out waiting for ${file}`); +} + +async function writeRequest(directory: string, id: string, reason: 'missing' | 'mismatch' = 'missing', + requestRevision = revision): Promise { + await fs.writeFile(path.join(directory, `${id}.request`), + `vscode-r-sess-consent-v1\n${id}\n${requestRevision}\nlinux-x86_64|4.5\n${reason}\n`); +} + +suite('sess install consent bridge', () => { + let directory: string; + let dismissedRevision: string | undefined; + let failPreferenceSave: boolean; + const services: SessConsentService[] = []; + + setup(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-sess-consent-')); + dismissedRevision = undefined; + failPreferenceSave = false; + }); + + teardown(async () => { + await Promise.all(services.splice(0).map(service => service.stop())); + await fs.rm(directory, { recursive: true, force: true }); + }); + + function service(prompt: (request: { reason: 'missing' | 'mismatch' }) => Promise<'install' | 'notNow' | 'dontAskAgain' | 'dismiss'>, + isEnabled = () => true, + expectedRevision = revision): SessConsentService { + const result = new SessConsentService({ + directory, expectedRevision, prompt, isEnabled, intervalMs: 5, + getDismissedRevision: () => dismissedRevision, + rememberDismissedRevision: value => { + if (failPreferenceSave) { return Promise.reject(new Error('storage unavailable')); } + dismissedRevision = value; + return Promise.resolve(); + }, + }); + services.push(result); + return result; + } + + test('writes a fresh approval only after the prompt accepts', async () => { + const id = '1'.repeat(32); + const prompt = (request: { reason: 'missing' | 'mismatch' }) => { + assert.strictEqual(request.reason, 'missing'); + return Promise.resolve('install'); + }; + const broker = service(prompt); + await broker.start(); + await writeRequest(directory, id); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'approve\n'); + assert.strictEqual(dismissedRevision, undefined); + }); + + test('accepts the CRLF lines written by base R on Windows', async () => { + const id = '7'.repeat(32); + const broker = service(() => Promise.resolve('install')); + await broker.start(); + await fs.writeFile(path.join(directory, `${id}.request`), + `vscode-r-sess-consent-v1\r\n${id}\r\n${revision}\r\nwindows-x86_64|4.5\r\nmissing\r\n`); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'approve\n'); + }); + + test('does not prompt when the watcher is disabled before the request arrives', async () => { + let prompts = 0; + const broker = service(() => { prompts++; return Promise.resolve('install'); }, () => false); + await broker.start(); + const id = '2'.repeat(32); + await writeRequest(directory, id, 'mismatch'); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + assert.strictEqual(prompts, 0); + }); + + test('Not now and dismissal do not suppress later prompts', async () => { + let prompts = 0; + const choices = ['notNow', 'dismiss'] as const; + const broker = service(() => Promise.resolve(choices[prompts++])); + await broker.start(); + for (const id of ['b'.repeat(32), 'd'.repeat(32)]) { + await writeRequest(directory, id); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + } + assert.strictEqual(prompts, 2); + assert.strictEqual(dismissedRevision, undefined); + }); + + test("Don't ask again stores only this revision and survives service restart", async () => { + let prompts = 0; + const first = service(() => { prompts++; return Promise.resolve('dontAskAgain'); }); + await first.start(); + const firstId = 'e'.repeat(32); + await writeRequest(directory, firstId); + assert.strictEqual(await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n'); + assert.strictEqual(dismissedRevision, revision); + await first.stop(); + await fs.rm(path.join(directory, `${firstId}.request`), { force: true }); + await fs.rm(path.join(directory, `${firstId}.response`), { force: true }); + + const restarted = service(() => { prompts++; return Promise.resolve('install'); }); + await restarted.start(); + const sameId = 'f'.repeat(32); + await writeRequest(directory, sameId); + assert.strictEqual(await waitForFile(path.join(directory, `${sameId}.response`)), 'decline\n'); + assert.strictEqual(prompts, 1); + await restarted.stop(); + await fs.rm(path.join(directory, `${sameId}.request`), { force: true }); + await fs.rm(path.join(directory, `${sameId}.response`), { force: true }); + + const changed = service(() => { prompts++; return Promise.resolve('install'); }, () => true, nextRevision); + await changed.start(); + const changedId = 'c'.repeat(32); + await writeRequest(directory, changedId, 'missing', nextRevision); + assert.strictEqual(await waitForFile(path.join(directory, `${changedId}.response`)), 'approve\n'); + assert.strictEqual(prompts, 2); + assert.strictEqual(dismissedRevision, revision); + }); + + test("a failed Don't ask again preference save never grants installation", async () => { + failPreferenceSave = true; + let prompts = 0; + const broker = service(() => { prompts++; return Promise.resolve('dontAskAgain'); }); + await broker.start(); + const id = '9'.repeat(32); + await writeRequest(directory, id); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + assert.strictEqual(prompts, 1); + assert.strictEqual(dismissedRevision, undefined); + }); + + test('shutdown while the prompt is pending cannot publish a late approval', async () => { + let resolvePrompt!: (choice: 'install' | 'notNow' | 'dontAskAgain' | 'dismiss') => void; + let prompts = 0; + const broker = service(() => { + prompts++; + return new Promise<'install' | 'notNow' | 'dontAskAgain' | 'dismiss'>(resolve => { resolvePrompt = resolve; }); + }); + await broker.start(); + const id = '9'.repeat(32); + await writeRequest(directory, id); + const deadline = Date.now() + 3000; + while (prompts === 0 && Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 5)); } + assert.strictEqual(prompts, 1); + await broker.stop(); + const response = path.join(directory, `${id}.response`); + assert.strictEqual(await waitForFile(response), 'decline\n'); + resolvePrompt('install'); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.strictEqual(await fs.readFile(response, 'utf8'), 'decline\n'); + }); + + test('a watcher disabled while the prompt is pending cannot approve install', async () => { + let enabled = true; + let resolvePrompt!: (choice: 'install' | 'notNow' | 'dontAskAgain' | 'dismiss') => void; + let prompts = 0; + const broker = service(() => { + prompts++; + return new Promise<'install' | 'notNow' | 'dontAskAgain' | 'dismiss'>(resolve => { resolvePrompt = resolve; }); + }, () => enabled); + await broker.start(); + const id = 'a'.repeat(32); + await writeRequest(directory, id); + const deadline = Date.now() + 3000; + while (prompts === 0 && Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 5)); } + assert.strictEqual(prompts, 1); + enabled = false; + resolvePrompt('install'); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + }); + + test('declines requests whose claimed revision differs from the bundled revision', async () => { + let prompts = 0; + const broker = service(() => { prompts++; return Promise.resolve('install'); }, () => true, + `git-tree:${'b'.repeat(40)}`); + await broker.start(); + const id = '6'.repeat(32); + await writeRequest(directory, id); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + assert.strictEqual(prompts, 0); + }); + + test('deduplicates a pending request and a later execution needs a new grant', async () => { + let resolvePrompt!: (choice: 'install' | 'notNow' | 'dontAskAgain' | 'dismiss') => void; + let prompts = 0; + const broker = service(() => { + prompts++; + return prompts === 1 + ? new Promise<'install' | 'notNow' | 'dontAskAgain' | 'dismiss'>(resolve => { resolvePrompt = resolve; }) + : Promise.resolve('notNow'); + }); + await broker.start(); + const firstId = '3'.repeat(32); + await writeRequest(directory, firstId); + const firstResponse = path.join(directory, `${firstId}.response`); + const deadline = Date.now() + 3000; + while (prompts === 0 && Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 5)); } + await new Promise(resolve => setTimeout(resolve, 30)); + assert.strictEqual(prompts, 1); + resolvePrompt('install'); + assert.strictEqual(await waitForFile(firstResponse), 'approve\n'); + + const nextId = '4'.repeat(32); + await writeRequest(directory, nextId); + const nextResponse = path.join(directory, `${nextId}.response`); + await waitForFile(nextResponse); + assert.strictEqual(await fs.readFile(nextResponse, 'utf8'), 'decline\n'); + assert.strictEqual(prompts, 2); + }); + + test('malformed bounded requests are declined without showing UI', async () => { + let prompts = 0; + const broker = service(() => { prompts++; return Promise.resolve('install'); }); + await broker.start(); + const id = '5'.repeat(32); + await fs.writeFile(path.join(directory, `${id}.request`), `${id}\n${'x'.repeat(400)}\n`); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + assert.strictEqual(prompts, 0); + }); +}); diff --git a/src/test/node/terminalStartup.test.ts b/src/test/node/terminalStartup.test.ts new file mode 100644 index 000000000..89faeebfe --- /dev/null +++ b/src/test/node/terminalStartup.test.ts @@ -0,0 +1,58 @@ +import * as assert from 'node:assert/strict'; +import { + assessTerminalStartup, + parseTerminalStartupRecord, +} from '../../terminalStartup'; + +suite('terminal startup sidecar', () => { + const token = '0123456789abcdef0123456789abcdef'; + const attempt = 'abcdefghijklmnop'; + const endpoint = '/tmp/vscode-r-session.sock'; + + function record(status: string, pid = 1234, attemptId = attempt, target = endpoint): string { + return `vscode-r-terminal-startup-v1\n${token}\n${attemptId}\n${pid}\n${target}\n${status}\n`; + } + + test('parses the bounded six-line record and accepts CRLF', () => { + const parsed = parseTerminalStartupRecord(record('ready').replace(/\n/g, '\r\n'), token); + assert.deepStrictEqual(parsed, { token, attemptId: attempt, pid: 1234, endpoint, status: 'ready' }); + }); + + test('rejects malformed, oversized, and foreign records', () => { + for (const input of [ + record('ready').slice(0, -1), + record('ready').replace(token, 'f'.repeat(32)), + record('done'), + record('ready', 0), + `${record('ready')}${'x'.repeat(513)}`, + ]) { + assert.strictEqual(parseTerminalStartupRecord(input, token), undefined); + } + }); + + test('cancels a wait when its observed attempt changes', () => { + const pending = parseTerminalStartupRecord(record('pending'), token); + const nextAttemptReady = parseTerminalStartupRecord(record('ready', 1234, 'qrstuvwxyzABCDEF'), token); + assert.deepStrictEqual(assessTerminalStartup(undefined, pending, undefined, undefined), { + observation: 'pending', attemptId: attempt, readyForOwner: false, + }); + assert.deepStrictEqual(assessTerminalStartup(attempt, nextAttemptReady, '1234', endpoint), { + observation: 'attempt-changed', attemptId: 'qrstuvwxyzABCDEF', readyForOwner: false, + }); + assert.deepStrictEqual(assessTerminalStartup(undefined, nextAttemptReady, '1234', endpoint), { + observation: 'ready', attemptId: 'qrstuvwxyzABCDEF', readyForOwner: true, + }); + assert.deepStrictEqual(assessTerminalStartup(attempt, undefined, '1234', endpoint), { + observation: 'unknown', attemptId: attempt, readyForOwner: false, + }); + }); + + test('requires the sidecar R pid to match the attached owner', () => { + const parsed = parseTerminalStartupRecord(record('ready'), token); + assert.ok(parsed); + assert.strictEqual(assessTerminalStartup(undefined, parsed, '1234', endpoint).readyForOwner, true); + assert.strictEqual(assessTerminalStartup(undefined, parsed, '1235', endpoint).readyForOwner, false); + assert.strictEqual(assessTerminalStartup(undefined, parsed, '1234', '/tmp/another.sock').readyForOwner, false); + assert.strictEqual(assessTerminalStartup(undefined, parsed, undefined, endpoint).readyForOwner, false); + }); +}); diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index ea094ed75..6e18be77b 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -104,16 +104,24 @@ function treeTooltip(item: vscode.TreeItem): string { fs.rmSync(root, { recursive: true, force: true }); sourceDirectories.forEach(directory => fs.rmSync(directory, { recursive: true, force: true })); }); - const until = async (predicate: () => boolean): Promise => { + const until = async ( + predicate: () => boolean, + timeoutMessage: string | (() => string) = 'Timed out waiting for notebook execution', + ): Promise => { const deadline = Date.now() + 20000; while (!predicate()) { - if (Date.now() > deadline) { throw new Error('Timed out waiting for notebook execution'); } + if (Date.now() > deadline) { + throw new Error(typeof timeoutMessage === 'function' ? timeoutMessage() : timeoutMessage); + } await new Promise(resolve => setTimeout(resolve, 50)); } }; function bundleContext(): vscode.ExtensionContext { return (createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { extensionContext: vscode.ExtensionContext }).extensionContext; } + function workspaceProvider(): WorkspaceDataProvider { + return (createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { rWorkspace: WorkspaceDataProvider }).rWorkspace; + } function languageService(): { clients: Map; clientUpdates: Map> } { const service = bundleContext().subscriptions.find(item => (item as { clients?: unknown }).clients instanceof Map) as ReturnType | undefined; @@ -675,6 +683,17 @@ function treeTooltip(item: vscode.TreeItem): string { await vscode.workspace.applyEdit(edit); await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: index, end: index + 1 }], document: notebook.uri }); await until(() => notebook.cellAt(index).executionSummary?.success === true); + const workspace = workspaceProvider(); + const expectedOwner = `${manifests[0].id}:${manifests[0].generation}`; + const state = (): string => JSON.stringify({ + expectedOwner, + actualOwner: workspace.owner?.sessionId, + function: workspace.data?.globalenv.interactive_signature, + }); + await until(() => workspace.owner?.sessionId === expectedOwner, () => `Timed out waiting for Interactive owner: ${state()}`); + await until(() => workspace.owner?.sessionId === expectedOwner + && workspace.data?.globalenv.interactive_signature?.type === 'closure', + () => `Timed out waiting for Interactive workspace snapshot: ${state()}`); const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebook.uri); const input = await vscode.workspace.openTextDocument(result.inputUri); const previous = input.getText(); @@ -1523,7 +1542,7 @@ par(mfrow=c(1,1))`); } finally { client.close(); } }); test('Workspace follows native focus and bound sources, and actions retain their displayed owner', async () => { - const workspace = (): WorkspaceDataProvider => (createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { rWorkspace: WorkspaceDataProvider }).rWorkspace; + const workspace = workspaceProvider; const nodes = async (): Promise => { const provider = workspace(); const root = (await provider.getChildren()).find(item => item.id === 'globalenv'); @@ -1552,8 +1571,22 @@ par(mfrow=c(1,1))`); await until(() => workspace().owner === oldNode.owner); assert.strictEqual(workspace().data?.globalenv.workspace_marker.length, 1); const list = (await nodes()).find(node => node.label === 'workspace_list'); assert.ok(list); - const children = await workspace().getChildren(list); - assert.ok(children.some(child => String(child.description).includes('0'))); + const provider = workspace(); + const ownerBefore = provider.owner?.sessionId; + const generationBefore = (provider as unknown as { childPageGeneration: number }).childPageGeneration; + const children = await provider.getChildren(list); + const ownerAfter = provider.owner?.sessionId; + const generationAfter = (provider as unknown as { childPageGeneration: number }).childPageGeneration; + assert.ok(children.some(child => String(child.description).includes('0')), JSON.stringify({ + expectedOwner: oldNode.owner?.sessionId, + itemOwner: list.owner?.sessionId, + ownerBefore, + ownerAfter, + generationBefore, + generationAfter, + displayedList: provider.data?.globalenv.workspace_list, + returnedChildren: children.map(child => ({ label: child.label, description: child.description })), + })); await focus(1); await vscode.commands.executeCommand('r.workspaceViewer.remove', oldNode); const firstClient = new AgentClient(manifests[0]); await firstClient.connect({ claim: false }); diff --git a/src/test/suite/sessInstall.test.ts b/src/test/suite/sessInstall.test.ts deleted file mode 100644 index 1ccade21c..000000000 --- a/src/test/suite/sessInstall.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as sinon from 'sinon'; -import * as path from 'path'; -import * as util from '../../util'; -import { mockExtensionContext } from '../common/mockvscode'; - -const stableRevision = 'git-tree:' + 'a'.repeat(40); -const preReleaseRevision = 'git-tree:' + 'b'.repeat(40); -const description = (version: string, revision: string) => - `Package: sess\nVersion: ${version}\nConfig/vscode-R/source-revision: ${revision}\n`; - -const extension_root: string = path.join(__dirname, '..', '..', '..'); - -suite('Sess Install Test Suite', () => { - let sandbox: sinon.SinonSandbox; - let originalSessionWatcher: boolean | undefined; - - setup(() => { - sandbox = sinon.createSandbox(); - mockExtensionContext(extension_root, sandbox); - originalSessionWatcher = vscode.workspace.getConfiguration('r').get('sessionWatcher'); - }); - - teardown(async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', originalSessionWatcher, vscode.ConfigurationTarget.Global); - sandbox.restore(); - }); - - test('promptToInstallSessPackage does nothing if sessionWatcher is disabled', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', false, vscode.ConfigurationTarget.Global); - - const getRevisionStub = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(undefined); - const showMessageStub = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - - await util.promptToInstallSessPackage(undefined, undefined, getRevisionStub); - - assert.strictEqual(getRevisionStub.called, false); - assert.strictEqual(showMessageStub.called, false); - }); - - test('promptToInstallSessPackage prompts to install if not installed', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); - const getRevisionStub = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(undefined); - - // Mock reading DESCRIPTION file - const readFileStub = sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', stableRevision)); - - const showMessageStub = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, getRevisionStub, readFileStub), true); - - assert.strictEqual(readFileStub.firstCall.args[0], path.join( - extension_root, 'dist', 'resources', 'sess', 'DESCRIPTION')); - assert.strictEqual(showMessageStub.calledOnce, true); - const args = showMessageStub.getCall(0).args; - assert.ok(args[0].includes('required for the session watcher to work')); - assert.deepStrictEqual(args.slice(1), ['Yes', 'No']); - }); - - test('promptToInstallSessPackage prompts when switching pre-release to stable', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); - const getRevisionStub = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(preReleaseRevision); - - // Package version ordering has no role in the source comparison. - const readFileStub = sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', stableRevision)); - - const showMessageStub = sandbox.stub(vscode.window, 'showWarningMessage').resolves('No' as unknown as vscode.MessageItem); - const task = sinon.stub<[string, string, string[], true], Promise>().resolves(); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, getRevisionStub, readFileStub, task), true); - assert.strictEqual(task.called, false); - - assert.strictEqual(showMessageStub.calledOnce, true); - const args = showMessageStub.getCall(0).args; - assert.ok(args[0].includes('does not match this build')); - assert.ok(!args[0].includes('newer')); - }); - - test('promptToInstallSessPackage does not prompt for the same source revision', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); - const getRevisionStub = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(stableRevision); - - const readFileStub = sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', stableRevision)); - - const showMessageStub = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - - await util.promptToInstallSessPackage(undefined, undefined, getRevisionStub, readFileStub); - - assert.strictEqual(showMessageStub.called, false); - }); - - test('stable to pre-release with the same package version prompts', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(stableRevision); - const read = sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', preReleaseRevision)); - const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - await util.promptToInstallSessPackage(undefined, undefined, installed, read); - assert.strictEqual(prompt.calledOnce, true); - }); - - test('legacy packages without source metadata prompt once, then matching metadata skips', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); - installed.onFirstCall().resolves(undefined); - installed.onSecondCall().resolves(stableRevision); - const read = sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', stableRevision)); - const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - await util.promptToInstallSessPackage(undefined, undefined, installed, read); - await util.promptToInstallSessPackage(undefined, undefined, installed, read); - assert.strictEqual(prompt.calledOnce, true); - }); - - test('invalid bundled metadata reports a build problem without offering installation', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(stableRevision); - const read = sandbox.stub(util, 'readFileSyncSafe').returns('Package: sess\nVersion: 0.1.0\n'); - const prompt = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read), false); - assert.strictEqual(installed.called, false); - assert.match(prompt.firstCall.args[0], /Rebuild or reinstall/); - assert.strictEqual(prompt.firstCall.args.length, 1); - }); - - test('requested installation waits for the task and verifies the installed source', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); - installed.onFirstCall().resolves(stableRevision); - installed.onSecondCall().resolves(preReleaseRevision); - const read = sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', preReleaseRevision)); - sandbox.stub(vscode.window, 'showWarningMessage').resolves('Yes' as unknown as vscode.MessageItem); - const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - let finishTask!: () => void; - const taskFinished = new Promise(resolve => { finishTask = resolve; }); - let taskStarted!: () => void; - const started = new Promise(resolve => { taskStarted = resolve; }); - const task = sinon.stub<[string, string, string[], true], Promise>().callsFake(() => { - taskStarted(); - return taskFinished; - }); - let completed = false; - const setup = util.promptToInstallSessPackage(undefined, undefined, installed, read, task).then(result => { - completed = true; - return result; - }); - await started; - assert.strictEqual(completed, false); - assert.strictEqual(installed.calledOnce, true); - finishTask(); - assert.strictEqual(await setup, true); - assert.strictEqual(installed.calledTwice, true); - assert.strictEqual(errors.called, false); - assert.ok(task.firstCall.args[2].includes(path.join(extension_root, 'dist', 'resources', 'sess').replace(/\\/g, '/'))); - assert.ok(task.firstCall.args[2].includes( - `--file=${path.join(extension_root, 'R', 'install_sess.R').replace(/\\/g, '/')}`)); - }).timeout(15000); - - test('failed or ineffective installation prevents startup and reports an error', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(stableRevision); - const read = sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', preReleaseRevision)); - sandbox.stub(vscode.window, 'showWarningMessage').resolves('Yes' as unknown as vscode.MessageItem); - const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const task = sinon.stub<[string, string, string[], true], Promise>(); - task.onFirstCall().resolves(); - task.onSecondCall().rejects(new Error('Could not start installation task')); - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task), false); - assert.match(errors.firstCall.args[0], /not installed successfully/); - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task), false); - assert.match(errors.secondCall.args[0], /Could not start installation task/); - }).timeout(15000); - - test('source revision parsing validates metadata and handles CRLF', () => { - assert.strictEqual(util.readSessSourceRevision(description('9.0.0', stableRevision).replace(/\n/g, '\r\n')), stableRevision); - for (const value of ['', 'unknown', 'git-tree:123', 'git-tree:' + 'A'.repeat(40)]) { - assert.strictEqual(util.readSessSourceRevision(description('0.1.0', value)), undefined); - } - }); -}); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 0933cec94..5e1460e47 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -415,8 +415,6 @@ suite('Session Communication', () => { assert.ok(rPath, 'R path should be found'); sandbox.stub(util, 'getRterm').resolves(rPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); - const result = await rTerminal.createRTerm(true); assert.ok(result, 'createRTerm should return true'); assert.ok(rTerminal.rTerm, 'rTerminal.rTerm should be defined'); @@ -518,8 +516,6 @@ suite('Session Communication', () => { const rPath = await util.getRterm(); assert.ok(rPath, 'R path should be found'); sandbox.stub(util, 'getRterm').resolves(rPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); - const result = await rTerminal.createRTerm(true); assert.ok(result); await waitFor(() => session.activeSession, 15000, 200); @@ -650,9 +646,14 @@ suite('Session Communication', () => { const scriptPath = JSON.parse(commandMatch[1]) as string; const scriptContent = await fsp.readFile(scriptPath, 'utf8'); - assert.match(scriptContent, /sess::connect\(endpoint = endpoint/); - assert.match(scriptContent, /sess_install_required\(sess_src\)/); - assert.match(scriptContent, /sess_source\.R/); + const startupSource = scriptContent.indexOf('source(startup_helper, local = TRUE)'); + const attachSource = scriptContent.indexOf('source(attach_helper, local = TRUE)'); + assert.ok(startupSource >= 0 && attachSource > startupSource, + 'the startup helper must be loaded before the generated attach helper'); + assert.match(scriptContent, /vscode_r_attach_sess\(endpoint, sess_src/); + assert.match(scriptContent, /attach_sess\.R/); + assert.match(scriptContent, /sess-package-install\.R/); + assert.doesNotMatch(scriptContent, /install_sess\.R|sess::connect|sess_install_required/); assert.ok(scriptContent.includes(extension.extensionContext.asAbsolutePath( path.join('dist', 'resources', 'sess')).replace(/\\/g, '/'))); assert.doesNotMatch(scriptContent, /packageVersion|compareVersion/); @@ -687,6 +688,19 @@ suite('Session Communication', () => { await session.shutdownSessionWatcher(); }).timeout(15000); + test('connectToSession does not create attach artifacts when the watcher is disabled', async () => { + await session.shutdownSessionWatcher(); + sandbox.stub(util, 'config').returns({ + get: (key: string) => key === 'sessionWatcher' ? false : undefined, + } as unknown as vscode.WorkspaceConfiguration); + const information = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); + const before = session.globalPipePath; + await session.connectToSession(); + assert.strictEqual(session.globalPipePath, before); + assert.strictEqual(information.callCount, 1); + assert.deepStrictEqual(information.firstCall.args, ['This command requires that r.sessionWatcher be enabled.']); + }); + test('reconnecting terminals preserve the selected terminal in either attach order', async () => { const endpoint = await session.getGlobalPipePath(); const selected = { processId: Promise.resolve(46240) }; @@ -741,7 +755,7 @@ suite('Session Communication', () => { assert.ok(commandMatch); const scriptPath = JSON.parse(commandMatch[1]) as string; const scriptContent = await fsp.readFile(scriptPath, 'utf8'); - assert.match(scriptContent, /sess::connect\(endpoint = endpoint, plot_backend = "native"\)/); + assert.match(scriptContent, /vscode_r_attach_sess\(endpoint, sess_src.*"native"\)/); assert.doesNotMatch(scriptContent, /Sys\.(?:setenv|unsetenv)\(JGD_SOCKET/); }); diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index d9147b737..b812a48a6 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -1,5 +1,7 @@ import * as assert from 'assert'; import * as path from 'path'; +import * as os from 'os'; +import * as fsp from 'node:fs/promises'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import type { RSessionApi } from '../../api'; @@ -12,11 +14,95 @@ import * as util from '../../util'; import { mockExtensionContext } from '../common/mockvscode'; import { deferred, SessionConnections, waitForValue as waitFor } from '../common/sessionConnections'; +async function settleWithFakeTime(promise: Promise, clock: sinon.SinonFakeTimers): Promise { + let settled = false; + void promise.then(() => { settled = true; }, () => { settled = true; }); + for (let attempt = 0; attempt < 100 && !settled; attempt++) { + await clock.tickAsync(10); + } + assert.strictEqual(settled, true, 'async session handshake should settle while fake timers advance'); + return await promise; +} + suite('Session Terminal Lifecycle', () => { let sandbox: sinon.SinonSandbox; let ancestors: sinon.SinonStub; let connections: SessionConnections; + const testStartupToken = '0123456789abcdef0123456789abcdef'; + const testStartupAttempt = 'attempt_000000000000'; + + function startupRecord( + state: 'pending' | 'ready' | 'failed', + pid: number, + endpoint: string, + token = testStartupToken, + attempt = testStartupAttempt, + ): string { + return `vscode-r-terminal-startup-v1\n${token}\n${attempt}\n${pid}\n${endpoint}\n${state}\n`; + } + + function terminalStartupToken(options: vscode.TerminalOptions): string { + const token = options.env?.['VSCODE_R_SESS_STARTUP_TOKEN']; + if (typeof token !== 'string') { + throw new Error('managed terminal should receive a startup token'); + } + return token; + } + + async function makeStartupFile(state: 'pending' | 'ready' | 'failed', pid = 46250): + Promise<{ directory: string; file: string; discoveryFile: string; token: string; attempt: string; pid: number; endpoint: string }> { + const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'vscode-r-terminal-startup-')); + const discoveryFile = path.join(directory, 'session.json'); + const file = `${discoveryFile}.startup`; + const endpoint = await session.getGlobalPipePath(); + await fsp.writeFile(file, startupRecord(state, pid, endpoint)); + return { directory, file, discoveryFile, token: testStartupToken, attempt: testStartupAttempt, pid, endpoint }; + } + + async function waitForTimerDelay(timerSpy: sinon.SinonSpy, delay: number, startupFile: string): Promise<() => void> { + for (let attempt = 0; attempt < 100; attempt++) { + const call = timerSpy.getCalls().find(candidate => candidate.args[1] === delay); + if (call && typeof call.args[0] === 'function') { + return call.args[0] as () => void; + } + // Let the real filesystem read in the startup poll complete without + // waiting for wall-clock time or guessing how long libuv will take. + await fsp.readFile(startupFile); + } + assert.fail(`The startup wait did not schedule its ${delay}ms deadline`); + } + + function makeManagedTerminal(rPid: number, discoveryFile: string, bracketedPaste = true) { + const sendText = sandbox.stub(); + const terminal = { + name: 'R Interactive', processId: Promise.resolve(rPid), + show: sandbox.stub(), dispose: sandbox.stub(), sendText, + } as unknown as vscode.Terminal; + sandbox.stub(executionTarget, 'tryInteractiveExecution').resolves(false); + sandbox.stub(util, 'config').returns({ + get: (key: string) => ({ sessionWatcher: true, consoleArgs: [], bracketedPaste, 'source.focus': 'none' })[key], + } as unknown as vscode.WorkspaceConfiguration); + sandbox.stub(util, 'getRterm').resolves(process.execPath); + sandbox.stub(session, 'getSessConsentDirectory').resolves('/unused-test-consent'); + sandbox.stub(session, 'createSessionDiscoveryFile').resolves(discoveryFile); + sandbox.stub(session, 'updateTerminalSessionDiscoveryFile').resolves(); + sandbox.stub(vscode.window, 'terminals').value([terminal]); + sandbox.stub(vscode.window, 'activeTerminal').value(terminal); + const create = sandbox.stub(vscode.window, 'createTerminal').returns(terminal); + const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const close = new vscode.EventEmitter(); + sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); + return { + terminal, sendText, warning, close, create, + cleanup() { + close.fire(terminal); + rTerminal.deleteTerminal(terminal); + close.dispose(); + }, + }; + } + setup(() => { sandbox = sinon.createSandbox(); connections = new SessionConnections(); @@ -149,30 +235,19 @@ suite('Session Terminal Lifecycle', () => { for (const explicitBinding of [false, true]) { test(`managed terminal accepts its first source command after native attach (explicit binding: ${String(explicitBinding)})`, async () => { - sandbox.stub(executionTarget, 'tryInteractiveExecution').resolves(false); - sandbox.stub(util, 'config').returns({ - get: (key: string) => ({ sessionWatcher: true, consoleArgs: [], 'source.focus': 'none' })[key], - } as unknown as vscode.WorkspaceConfiguration); - sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); - sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); - sandbox.stub(session, 'updateTerminalSessionDiscoveryFile').resolves(); + const { directory, discoveryFile, file: startupPath, pid, endpoint } = await makeStartupFile('ready', 46260); + const fixture = makeManagedTerminal(46260, discoveryFile, false); const waitUntilReady = session.waitForTerminalReady; // Exercise the real readiness logic, with a bounded failure timeout. const readiness = sandbox.stub(session, 'waitForTerminalReady') - .callsFake(terminal => waitUntilReady(terminal, 1000)); - const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - const sendText = sandbox.stub(); - const terminal = { - name: 'R Interactive', processId: Promise.resolve(46260), - show: () => undefined, sendText, - } as unknown as vscode.Terminal; - sandbox.stub(vscode.window, 'terminals').value([terminal]); - sandbox.stub(vscode.window, 'activeTerminal').value(terminal); - sandbox.stub(vscode.window, 'createTerminal').returns(terminal); + .callsFake((terminal, _timeout, startupPath, startupToken) => + waitUntilReady(terminal, 1000, startupPath, startupToken)); + const { terminal, sendText, warning } = fixture; const sessionId = `source-readiness-${String(explicitBinding)}`; try { assert.strictEqual(await rTerminal.createRTerm(), true); + const token = terminalStartupToken(fixture.create.firstCall.args[0] as vscode.TerminalOptions); + await fsp.writeFile(startupPath, startupRecord('ready', pid, endpoint, token)); await connections.attach(sessionId, 46260); const owner = await waitFor(() => session.activeSession?.sessionId === sessionId ? session.activeSession : undefined); assert.ok(owner); @@ -182,12 +257,16 @@ suite('Session Terminal Lifecycle', () => { assert.strictEqual(await api.activate(owner.sessionId, { terminal }), true); } assert.strictEqual(await rTerminal.runTextInTerm('source("example.R")'), true); - sinon.assert.calledOnceWithExactly(readiness, terminal); + sinon.assert.calledOnceWithExactly(readiness, terminal, 30000, startupPath, token); sinon.assert.calledOnceWithExactly(sendText, 'source("example.R")'); sinon.assert.notCalled(warning); } finally { - rTerminal.deleteTerminal(terminal); - await session.cleanupSession(sessionId); + fixture.cleanup(); + try { + await session.cleanupSession(sessionId); + } finally { + await fsp.rm(directory, { recursive: true, force: true }); + } } }); } @@ -347,22 +426,332 @@ suite('Session Terminal Lifecycle', () => { }); test('terminal readiness aborts on close even before processId resolves', async () => { + const { directory, file, token } = await makeStartupFile('pending'); const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; + sandbox.stub(vscode.window, 'terminals').value([terminal]); + const close = new vscode.EventEmitter(); + sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); + const timerSandbox = sinon.createSandbox(); + const clock = timerSandbox.useFakeTimers(); + const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); + const waiting = session.waitForTerminalReady(terminal, 30000, file, token); + try { + await waitForTimerDelay(timerSpy, 600000, file); + close.fire(terminal); + assert.strictEqual(await waiting, false); + assert.strictEqual(clock.countTimers(), 0, 'closing the terminal removes the pending startup deadline and poll'); + } finally { + close.fire(terminal); + timerSandbox.restore(); + await waiting; + close.dispose(); + await fsp.rm(directory, { recursive: true, force: true }); + } + }); + + test('pending startup holds queued terminal input beyond 30 seconds until ready and this terminal attaches', async () => { + const rPid = 46291; + const otherPid = 46292; + const { directory, file: startupFile, discoveryFile, endpoint } = await makeStartupFile('pending', rPid); + const fixture = makeManagedTerminal(rPid, discoveryFile); + const { sendText } = fixture; + const waitForReady = session.waitForTerminalReady; + let readinessStarted!: () => void; + const started = new Promise(resolve => { readinessStarted = resolve; }); + const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath, startupToken) => { + readinessStarted(); + return waitForReady(current, timeout, startupPath, startupToken); + }); + const timerSandbox = sinon.createSandbox(); + const clock = timerSandbox.useFakeTimers(); + const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); + let commandsSettled: Promise[]> | undefined; + + try { + assert.strictEqual(await rTerminal.createRTerm(), true); + const token = terminalStartupToken(fixture.create.firstCall.args[0] as vscode.TerminalOptions); + await fsp.writeFile(startupFile, startupRecord('pending', rPid, endpoint, token)); + const first = rTerminal.runTextInTerm('first'); + const second = rTerminal.runTextInTerm('second'); + const commands = [first, second]; + commandsSettled = Promise.allSettled(commands); + let firstSettled = false; + const firstResult = first.then(value => { firstSettled = true; return value; }); + await started; + assert.strictEqual(readyStub.firstCall.args[2], startupFile); + assert.strictEqual(readyStub.firstCall.args[3], token); + await waitForTimerDelay(timerSpy, 600000, startupFile); + await clock.tickAsync(35000); + assert.strictEqual(firstSettled, false, 'pending setup suppresses the ordinary 30 second timeout'); + sinon.assert.notCalled(sendText); + + await fsp.writeFile(startupFile, startupRecord('ready', rPid, endpoint, token)); + await clock.tickAsync(500); + assert.strictEqual(sendText.called, false, 'ready state alone cannot release terminal input'); + + const unrelated = connections.attach('startup-unrelated', otherPid); + await settleWithFakeTime(unrelated, clock); + assert.strictEqual(sendText.called, false, 'an unrelated R attach cannot release this terminal'); + + const target = connections.attach('startup-target', rPid); + await settleWithFakeTime(target, clock); + assert.strictEqual(await firstResult, true); + assert.strictEqual(await second, true); + sinon.assert.calledTwice(sendText); + assert.deepStrictEqual(sendText.args, [ + ['\x1b[200~first\x1b[201~', true], + ['\x1b[200~second\x1b[201~', true], + ]); + } finally { + fixture.cleanup(); + timerSandbox.restore(); + if (commandsSettled) { await commandsSettled; } + await connections.dispose(); + await fsp.rm(directory, { recursive: true, force: true }); + } + }); + + test('a restored profile terminal registers its startup gate from creation metadata', async () => { + const rPid = 46290; + const { directory, file, token, endpoint } = await makeStartupFile('pending', rPid); + const sendText = sandbox.stub(); + const terminal = { + name: 'R Interactive', processId: Promise.resolve(rPid), sendText, show: sandbox.stub(), + creationOptions: { env: { + VSCODE_R_SESS_STARTUP_FILE: file, + VSCODE_R_SESS_STARTUP_TOKEN: token, + } }, + } as unknown as vscode.Terminal; + sandbox.stub(vscode.window, 'terminals').value([terminal]); + sandbox.stub(vscode.window, 'activeTerminal').value(terminal); const close = new vscode.EventEmitter(); sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); - const waiting = session.waitForTerminalReady(terminal); - close.fire(terminal); - assert.strictEqual(await waiting, false); - close.dispose(); + sandbox.stub(util, 'config').returns({ + get: (key: string) => ({ sessionWatcher: true, bracketedPaste: true, 'source.focus': 'none' })[key], + } as unknown as vscode.WorkspaceConfiguration); + sandbox.stub(executionTarget, 'tryInteractiveExecution').resolves(false); + const timerSandbox = sinon.createSandbox(); + let commandResult: Promise | undefined; + + try { + await connections.attach('restored-profile-terminal', rPid); + await waitFor(() => session.activeSession?.pipePath === endpoint ? true : undefined); + const clock = timerSandbox.useFakeTimers(); + const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); + commandResult = rTerminal.runTextInTerm('restored input'); + await waitForTimerDelay(timerSpy, 600000, file); + sinon.assert.notCalled(sendText); + await fsp.writeFile(file, startupRecord('ready', rPid, endpoint, token)); + let settled = false; + void commandResult.then(() => { settled = true; }); + for (let attempt = 0; attempt < 100 && !settled; attempt++) { + await clock.tickAsync(100); + await fsp.readFile(file); + } + assert.strictEqual(await commandResult, true); + sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~restored input\x1b[201~', true); + } finally { + close.fire(terminal); + timerSandbox.restore(); + if (commandResult) { await Promise.allSettled([commandResult]); } + await connections.dispose(); + close.dispose(); + await fsp.rm(directory, { recursive: true, force: true }); + } + }); + + for (const state of ['ready', 'failed'] as const) { + test(`an already attached managed terminal waits for pending startup to become ${state}`, async () => { + const rPid = state === 'ready' ? 46294 : 46295; + const { directory, file: startupFile, discoveryFile, endpoint } = await makeStartupFile('pending', rPid); + const fixture = makeManagedTerminal(rPid, discoveryFile); + const { sendText, warning } = fixture; + const timerSandbox = sinon.createSandbox(); + let commandSettled: Promise> | undefined; + + try { + assert.strictEqual(await rTerminal.createRTerm(), true); + const token = terminalStartupToken(fixture.create.firstCall.args[0] as vscode.TerminalOptions); + await fsp.writeFile(startupFile, startupRecord('pending', rPid, endpoint, token)); + await connections.attach(`startup-attached-before-wait-${state}`, rPid); + await waitFor(() => session.activeSession?.pid === String(rPid) ? session.activeSession : undefined); + + const clock = timerSandbox.useFakeTimers(); + const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); + const submitted = rTerminal.runTextInTerm(`run only after ${state}`); + commandSettled = Promise.allSettled([submitted]).then(results => results[0]); + await waitForTimerDelay(timerSpy, 600000, startupFile); + sinon.assert.notCalled(sendText); + + await fsp.writeFile(startupFile, startupRecord(state, rPid, endpoint, token)); + let settled = false; + void submitted.then(() => { settled = true; }); + for (let attempt = 0; attempt < 100 && !settled; attempt++) { + await clock.tickAsync(100); + await fsp.readFile(startupFile); + } + assert.strictEqual(settled, true, `${state} startup state should settle the command`); + assert.strictEqual(await submitted, state === 'ready'); + if (state === 'ready') { + sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~run only after ready\x1b[201~', true); + sinon.assert.notCalled(warning); + } else { + sinon.assert.notCalled(sendText); + sinon.assert.calledOnce(warning); + } + } finally { + fixture.cleanup(); + timerSandbox.restore(); + if (commandSettled) { await commandSettled; } + await connections.dispose(); + await fsp.rm(directory, { recursive: true, force: true }); + } + }); + } + + test('a fresh attached attempt recovers before the first input while the same failed attempt stays failed', async () => { + const rPid = 46296; + const { directory, file, token, endpoint } = await makeStartupFile('failed', rPid); + const sendText = sandbox.stub(); + const terminal = { + name: 'R Interactive', processId: Promise.resolve(rPid), sendText, show: sandbox.stub(), + creationOptions: { env: { + VSCODE_R_SESS_STARTUP_FILE: file, + VSCODE_R_SESS_STARTUP_TOKEN: token, + } }, + } as unknown as vscode.Terminal; + sandbox.stub(vscode.window, 'terminals').value([terminal]); + sandbox.stub(vscode.window, 'activeTerminal').value(terminal); + sandbox.stub(util, 'config').returns({ + get: (key: string) => ({ sessionWatcher: true, bracketedPaste: true, 'source.focus': 'none' })[key], + } as unknown as vscode.WorkspaceConfiguration); + sandbox.stub(executionTarget, 'tryInteractiveExecution').resolves(false); + try { + await connections.attach('startup-recovery-pending', rPid); + await waitFor(() => session.activeSession?.pid === String(rPid) ? session.activeSession : undefined); + + assert.strictEqual(await session.waitForTerminalReady(terminal, 1000, file, token), false); + assert.strictEqual(await session.waitForTerminalReady(terminal, 1000, file, token), false, + 'retrying the same failed attempt cannot turn its result into ready'); + await session.shutdownSessionWatcher(); + const reloadedEndpoint = await session.getGlobalPipePath(); + assert.notStrictEqual(reloadedEndpoint, endpoint, 'a window reload may create a new IPC endpoint'); + await connections.attach('startup-recovery-after-reload', rPid); + await waitFor(() => session.activeSession?.pipePath === reloadedEndpoint ? true : undefined); + await fsp.writeFile(file, startupRecord('ready', rPid, reloadedEndpoint, token, 'attempt_111111111111')); + assert.strictEqual(await rTerminal.runTextInTerm('first input after manual recovery'), true, + 'the first input accepts a completed new manual-attach attempt'); + sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~first input after manual recovery\x1b[201~', true); + } finally { + await connections.dispose(); + await fsp.rm(directory, { recursive: true, force: true }); + } + }); + + test('failed startup declines input before late attach, while a fresh attached-terminal retry can proceed', async () => { + const rPid = 46293; + const { directory, file: startupFile, discoveryFile, endpoint } = await makeStartupFile('pending', rPid); + const fixture = makeManagedTerminal(rPid, discoveryFile); + const { sendText, warning } = fixture; + const waitForReady = session.waitForTerminalReady; + let readinessStarted!: () => void; + const started = new Promise(resolve => { readinessStarted = resolve; }); + const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath, startupToken) => { + readinessStarted(); + return waitForReady(current, timeout, startupPath, startupToken); + }); + const timerSandbox = sinon.createSandbox(); + const clock = timerSandbox.useFakeTimers(); + const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); + + let pendingCommand: Promise | undefined; + let pendingCommandSettled: Promise> | undefined; + try { + assert.strictEqual(await rTerminal.createRTerm(), true); + const token = terminalStartupToken(fixture.create.firstCall.args[0] as vscode.TerminalOptions); + await fsp.writeFile(startupFile, startupRecord('pending', rPid, endpoint, token)); + let rejectionSettled = false; + pendingCommand = rTerminal.runTextInTerm('must not send').then(result => { + rejectionSettled = true; + return result; + }); + pendingCommandSettled = Promise.allSettled([pendingCommand]).then(results => results[0]); + await started; + assert.strictEqual(readyStub.firstCall.args[2], startupFile); + assert.strictEqual(readyStub.firstCall.args[3], token); + await waitForTimerDelay(timerSpy, 600000, startupFile); + await fsp.writeFile(startupFile, startupRecord('failed', rPid, endpoint, token)); + for (let attempt = 0; attempt < 100 && !rejectionSettled; attempt++) { + await clock.tickAsync(100); + await fsp.readFile(startupFile); + // Await the command state through a short fake-time turn after each poll. + } + assert.strictEqual(rejectionSettled, true, 'failed startup status should settle the pending input promptly'); + assert.strictEqual(await pendingCommand, false); + sinon.assert.notCalled(sendText); + sinon.assert.calledOnce(warning); + assert.strictEqual(clock.countTimers(), 0, 'failed startup clears its bounded wait and poll'); + + timerSandbox.restore(); + await connections.attach('startup-late-attach', rPid); + assert.strictEqual(sendText.called, false, 'late attach cannot release the already failed input'); + await fsp.writeFile(startupFile, startupRecord('ready', rPid, endpoint, token, 'attempt_111111111111')); + assert.strictEqual(await rTerminal.runTextInTerm('retry after attach'), true); + assert.strictEqual(readyStub.callCount, 2); + sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~retry after attach\x1b[201~', true); + } finally { + fixture.cleanup(); + timerSandbox.restore(); + if (pendingCommandSettled) { await pendingCommandSettled; } + await connections.dispose(); + await fsp.rm(directory, { recursive: true, force: true }); + } }); test('terminal readiness timeout does not declare the terminal ready', async () => { const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; - const clock = sandbox.useFakeTimers(); - const waiting = session.waitForTerminalReady(terminal, 100); - await clock.tickAsync(100); - assert.strictEqual(await waiting, false); - assert.strictEqual(clock.countTimers(), 0); + const timerSandbox = sinon.createSandbox(); + const clock = timerSandbox.useFakeTimers(); + let settled = false; + const waiting = session.waitForTerminalReady(terminal).then(result => { settled = true; return result; }); + try { + await clock.tickAsync(29999); + assert.strictEqual(settled, false, 'absence of startup state keeps the default 30 second timeout'); + await clock.tickAsync(1); + assert.strictEqual(await waiting, false); + assert.strictEqual(clock.countTimers(), 0); + } finally { + try { + await clock.tickAsync(30000); + await waiting; + } finally { + timerSandbox.restore(); + } + } + }); + + test('pending startup has a finite 10 minute deadline', async () => { + const { directory, file, token } = await makeStartupFile('pending'); + const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; + sandbox.stub(vscode.window, 'terminals').value([terminal]); + const timerSandbox = sinon.createSandbox(); + const clock = timerSandbox.useFakeTimers(); + const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); + const close = new vscode.EventEmitter(); + sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); + const waiting = session.waitForTerminalReady(terminal, 30000, file, token); + try { + const expire = await waitForTimerDelay(timerSpy, 600000, file); + expire(); + assert.strictEqual(await waiting, false); + assert.strictEqual(clock.countTimers(), 0); + } finally { + close.fire(terminal); + timerSandbox.restore(); + await waiting; + close.dispose(); + await fsp.rm(directory, { recursive: true, force: true }); + } }); }); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 15645ade4..620224989 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -10,6 +10,7 @@ import * as rTerminal from '../../rTerminal'; import * as util from '../../util'; import * as session from '../../session'; import * as executionTarget from '../../interactive/executionTarget'; +import * as extension from '../../extension'; const extension_root: string = path.join(__dirname, '..', '..', '..'); @@ -26,6 +27,7 @@ async function waitForDiscoveryRemoval(filePath: string): Promise { suite('R Terminal', () => { let sandbox: sinon.SinonSandbox; + let consentDirectoryStub: sinon.SinonStub; function configuration(values: Record = {}, defaults: Record = {}, workspaceFolderScope = false): vscode.WorkspaceConfiguration { return { @@ -59,6 +61,7 @@ suite('R Terminal', () => { setup(() => { sandbox = sinon.createSandbox(); mockExtensionContext(extension_root, sandbox); + consentDirectoryStub = sandbox.stub(session, 'getSessConsentDirectory').resolves('/unused-test-consent'); }); teardown(() => { @@ -95,8 +98,7 @@ suite('R Terminal', () => { sessionWatcher: watcher, consoleArgs: args, bracketedPaste: true, 'source.focus': 'none' })); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); - sandbox.stub(session, 'getGlobalPipePath').resolves('unused-test-endpoint'); + const pipe = sandbox.stub(session, 'getGlobalPipePath').resolves('unused-test-endpoint'); sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); const sent: string[] = []; const terminal = { @@ -109,37 +111,48 @@ suite('R Terminal', () => { sandbox.stub(vscode.window, 'activeTerminal').value(terminal); const first = rTerminal.runTextInTerm('first'); const second = rTerminal.runTextInTerm('second'); - // Let both commands reach the shared wait, then expose the terminal - // as VS Code would before a third Ctrl+Enter. - await waiting; - terminals.value([terminal]); - const third = rTerminal.runTextInTerm('third'); - for (let i = 0; i < 20; i++) { await Promise.resolve(); } - assert.deepStrictEqual(sent, []); - sinon.assert.calledOnce(create); - if (integrated) { - sinon.assert.calledOnceWithExactly(readyStub, terminal); - sinon.assert.notCalled(delayStub); - } else { - sinon.assert.notCalled(readyStub); - sinon.assert.calledOnceWithExactly(delayStub, 200); + const commands: Promise[] = [first, second]; + try { + // Let both commands reach the shared wait, then expose the terminal + // as VS Code would before a third Ctrl+Enter. + await waiting; + terminals.value([terminal]); + const third = rTerminal.runTextInTerm('third'); + commands.push(third); + for (let i = 0; i < 20; i++) { await Promise.resolve(); } + assert.deepStrictEqual(sent, []); + sinon.assert.calledOnce(create); + if (integrated) { + const startupToken = (create.firstCall.args[0] as vscode.TerminalOptions).env?.['VSCODE_R_SESS_STARTUP_TOKEN']; + if (typeof startupToken !== 'string') { throw new Error('managed terminal should receive a startup token'); } + sinon.assert.calledOnceWithExactly(readyStub, terminal, 30000, '/unused-test-discovery.startup', startupToken); + sinon.assert.notCalled(delayStub); + sinon.assert.calledOnce(pipe); + } else { + sinon.assert.notCalled(readyStub); + sinon.assert.calledOnceWithExactly(delayStub, 200); + sinon.assert.notCalled(pipe); + } + resolveReady(true); + await Promise.all(commands); + assert.deepStrictEqual(sent, ['\x1b[200~first\x1b[201~', '\x1b[200~second\x1b[201~', '\x1b[200~third\x1b[201~']); + assert.strictEqual(consentDirectoryStub.called, integrated); + await rTerminal.runTextInTerm('fourth'); + assert.strictEqual(sent.length, 4); + sinon.assert.calledOnce(create); + assert.strictEqual(target.thirdCall.args[3], false); + assert.strictEqual(target.lastCall.args[3], false); + } finally { + resolveReady(true); + await Promise.allSettled(commands); + rTerminal.deleteTerminal(terminal); } - resolveReady(true); - await Promise.all([first, second, third]); - assert.deepStrictEqual(sent, ['\x1b[200~first\x1b[201~', '\x1b[200~second\x1b[201~', '\x1b[200~third\x1b[201~']); - await rTerminal.runTextInTerm('fourth'); - assert.strictEqual(sent.length, 4); - sinon.assert.calledOnce(create); - assert.strictEqual(target.thirdCall.args[3], false); - assert.strictEqual(target.lastCall.args[3], false); - rTerminal.deleteTerminal(terminal); }); } test('failed readiness does not send code and allows retry', async () => { sandbox.stub(util, 'config').returns(configuration({ sessionWatcher: true, bracketedPaste: true, 'source.focus': 'none' })); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); sandbox.stub(session, 'getGlobalPipePath').resolves('unused-test-endpoint'); sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); const ready = sandbox.stub(session, 'waitForTerminalReady'); @@ -150,11 +163,13 @@ suite('R Terminal', () => { name: 'R Interactive', processId: Promise.resolve(undefined), show: () => undefined, sendText, } as unknown as vscode.Terminal; - sandbox.stub(vscode.window, 'createTerminal').returns(terminal); + const createTerminal = sandbox.stub(vscode.window, 'createTerminal').returns(terminal); sandbox.stub(vscode.window, 'terminals').value([terminal]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); await rTerminal.createRTerm(); + const startupToken = (createTerminal.firstCall.args[0] as vscode.TerminalOptions).env?.['VSCODE_R_SESS_STARTUP_TOKEN']; + if (typeof startupToken !== 'string') { throw new Error('managed terminal should receive a startup token'); } await rTerminal.runTextInTerm('first'); sinon.assert.notCalled(sendText); sinon.assert.calledOnce(warning); @@ -164,6 +179,8 @@ suite('R Terminal', () => { 'the warning should explain how to apply the setting to an existing terminal'); await rTerminal.runTextInTerm('retry'); sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~retry\x1b[201~', true); + assert.deepStrictEqual(ready.firstCall.args, [terminal, 30000, '/unused-test-discovery.startup', startupToken]); + assert.deepStrictEqual(ready.secondCall.args, [terminal, 30000, '/unused-test-discovery.startup', startupToken]); rTerminal.deleteTerminal(terminal); }); @@ -203,7 +220,6 @@ suite('R Terminal', () => { }; sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); const options = await rTerminal.makeTerminalOptions(); const discoveryFile = options.env?.['SESS_DISCOVERY_FILE']; @@ -231,13 +247,15 @@ suite('R Terminal', () => { test('deleteTerminal removes only its discovery file after an explicit terminal close', async () => { const endpoint = await session.getGlobalPipePath(); const discoveryFile = await session.createSessionDiscoveryFile(endpoint); + const startupFile = `${discoveryFile}.startup`; + await fsp.writeFile(startupFile, 'pending\n'); const unrelatedFile = await session.createSessionDiscoveryFile(endpoint); const terminal = { name: 'R Interactive', processId: Promise.resolve(45239), creationOptions: { name: 'R Interactive', - env: { SESS_DISCOVERY_FILE: discoveryFile }, + env: { SESS_DISCOVERY_FILE: discoveryFile, VSCODE_R_SESS_STARTUP_FILE: startupFile }, }, exitStatus: { code: undefined, reason: vscode.TerminalExitReason.User }, } as unknown as vscode.Terminal; @@ -245,9 +263,11 @@ suite('R Terminal', () => { try { rTerminal.deleteTerminal(terminal); await waitForDiscoveryRemoval(discoveryFile); + await waitForDiscoveryRemoval(startupFile); assert.strictEqual(await pathExists(unrelatedFile), true); } finally { await fsp.rm(discoveryFile, { recursive: true, force: true }); + await fsp.rm(startupFile, { recursive: true, force: true }); await fsp.rm(unrelatedFile, { recursive: true, force: true }); } }); @@ -256,16 +276,20 @@ suite('R Terminal', () => { const endpoint = await session.getGlobalPipePath(); const shutdownFile = await session.createSessionDiscoveryFile(endpoint); const unknownFile = await session.createSessionDiscoveryFile(endpoint); + const shutdownStartupFile = `${shutdownFile}.startup`; + const unknownStartupFile = `${unknownFile}.startup`; + await fsp.writeFile(shutdownStartupFile, 'pending\n'); + await fsp.writeFile(unknownStartupFile, 'pending\n'); const shutdownTerminal = { name: 'R Interactive', processId: Promise.resolve(45241), - creationOptions: { name: 'R Interactive', env: { SESS_DISCOVERY_FILE: shutdownFile } }, + creationOptions: { name: 'R Interactive', env: { SESS_DISCOVERY_FILE: shutdownFile, VSCODE_R_SESS_STARTUP_FILE: shutdownStartupFile } }, exitStatus: { code: undefined, reason: vscode.TerminalExitReason.Shutdown }, } as unknown as vscode.Terminal; const unknownTerminal = { name: 'R Interactive', processId: Promise.resolve(45243), - creationOptions: { name: 'R Interactive', env: { SESS_DISCOVERY_FILE: unknownFile } }, + creationOptions: { name: 'R Interactive', env: { SESS_DISCOVERY_FILE: unknownFile, VSCODE_R_SESS_STARTUP_FILE: unknownStartupFile } }, exitStatus: { code: undefined, reason: vscode.TerminalExitReason.Unknown }, } as unknown as vscode.Terminal; @@ -275,9 +299,13 @@ suite('R Terminal', () => { await new Promise(resolve => setTimeout(resolve, 10)); assert.strictEqual(await pathExists(shutdownFile), true); assert.strictEqual(await pathExists(unknownFile), true); + assert.strictEqual(await pathExists(shutdownStartupFile), true); + assert.strictEqual(await pathExists(unknownStartupFile), true); } finally { await fsp.rm(shutdownFile, { recursive: true, force: true }); await fsp.rm(unknownFile, { recursive: true, force: true }); + await fsp.rm(shutdownStartupFile, { recursive: true, force: true }); + await fsp.rm(unknownStartupFile, { recursive: true, force: true }); } }); @@ -318,7 +346,6 @@ suite('R Terminal', () => { }; sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); const options = await rTerminal.makeTerminalOptions(); @@ -356,7 +383,6 @@ suite('R Terminal', () => { }; sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); const options = await rTerminal.makeTerminalOptions(); @@ -369,7 +395,6 @@ suite('R Terminal', () => { 'rterm.option': ['--no-save'] })); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); const options = await rTerminal.makeTerminalOptions(); @@ -381,7 +406,6 @@ suite('R Terminal', () => { consoleArgs: ['--no-save', '--no-restore'] })); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); const options = await rTerminal.makeTerminalOptions(); @@ -403,7 +427,6 @@ suite('R Terminal', () => { test('makeTerminalOptions keeps existing console args defaults when neither setting is explicit', async () => { sandbox.stub(util, 'config').returns(configuration({}, { consoleArgs: ['--no-save', '--no-restore'] })); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); const options = await rTerminal.makeTerminalOptions(); @@ -475,7 +498,6 @@ suite('R Terminal', () => { get: (key: string) => key === 'sessionWatcher' ? false : settings[key] }; sandbox.stub(vscode.workspace, 'getConfiguration').returns(configStub as unknown as vscode.WorkspaceConfiguration); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); const errorStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); assert.strictEqual(await rTerminal.createRTerm(), false); @@ -522,7 +544,6 @@ suite('R Terminal', () => { assert.strictEqual(requestedResource, resource); return value.replace('${workspaceFolder}', resource.fsPath); }); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); const sent: string[] = []; const fakeTerminal = { name: 'R Interactive', @@ -585,69 +606,148 @@ suite('R Terminal', () => { }); } - test('createRTerm and restartRTerminal integration test', async () => { + test('createRTerm uses the configured arf/radian console R and waits for consent without background R work', async () => { + const backgroundRPath = '/background/R'; + const consoleRPath = '/target/radian'; const configStub = { - get: (key: string) => { - if (key === 'sessionWatcher') { - return true; - } - return undefined; - } + get: (key: string) => ({ + sessionWatcher: true, + executablePath: backgroundRPath, + consolePath: consoleRPath, + 'source.focus': 'none', + })[key], }; sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); - sandbox.stub(util, 'getRterm').resolves(process.execPath); - let finishSetup!: (result: boolean) => void; - const setupFinished = new Promise(resolve => { finishSetup = resolve; }); + const getConsoleR = sandbox.stub(util, 'getRterm').callsFake(() => { + assert.strictEqual(configStub.get('consolePath'), consoleRPath); + assert.notStrictEqual(configStub.get('consolePath'), configStub.get('executablePath')); + return Promise.resolve(process.execPath); + }); + const getRpath = sandbox.spy(util, 'getRpath'); + const queryR = sandbox.spy(util, 'executeRCommand'); + const installTask = sandbox.spy(util, 'executeAsTask'); + let finishSetup!: (result: string) => void; + const setupFinished = new Promise(resolve => { finishSetup = resolve; }); let setupStarted!: () => void; const started = new Promise(resolve => { setupStarted = resolve; }); - sandbox.stub(util, 'promptToInstallSessPackage').callsFake(() => { + consentDirectoryStub.callsFake(() => { setupStarted(); return setupFinished; }); - const createTerminal = sandbox.spy(vscode.window, 'createTerminal'); + sandbox.stub(session, 'getGlobalPipePath').resolves('unused-test-endpoint'); + sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); + const terminal = { + name: 'R Interactive', processId: Promise.resolve(undefined), + show: sandbox.stub(), dispose: sandbox.stub(), + } as unknown as vscode.Terminal; + const createTerminal = sandbox.stub(vscode.window, 'createTerminal').returns(terminal); const creation = rTerminal.createRTerm(true); await started; - assert.strictEqual(createTerminal.called, false, 'terminal must wait for sess setup'); - finishSetup(true); + assert.strictEqual(createTerminal.called, false, 'terminal must wait for consent setup'); + finishSetup('/target-R-consent'); const result = await creation; assert.ok(result, 'createRTerm should return true'); assert.ok(rTerminal.rTerm, 'rTerminal.rTerm should be defined'); + sinon.assert.calledOnce(getConsoleR); + sinon.assert.calledOnce(consentDirectoryStub); + const options = createTerminal.firstCall.args[0] as vscode.TerminalOptions; + assert.strictEqual(options.shellPath, process.execPath); + assert.strictEqual(options.env?.['VSCODE_R_SESS_CONSENT_DIRECTORY'], '/target-R-consent'); + sinon.assert.notCalled(getRpath); + sinon.assert.notCalled(queryR); + sinon.assert.notCalled(installTask); - // Clean up - rTerminal.rTerm?.dispose(); + rTerminal.deleteTerminal(terminal); }); - test('createRTerm removes its discovery file when the configured executable is invalid', async () => { - const createdDiscoveryFiles: string[] = []; - const createDiscoveryFile = session.createSessionDiscoveryFile; - sandbox.stub(session, 'createSessionDiscoveryFile').callsFake(async endpoint => { - const filePath = await createDiscoveryFile(endpoint); - createdDiscoveryFiles.push(filePath); - return filePath; + for (const failedResource of ['pipe', 'consent', 'discovery', 'helper-path'] as const) { + test(`session watcher ${failedResource} setup failure still starts an ordinary R terminal`, async () => { + const failure = new Error(`simulated ${failedResource} setup failure`); + const createdFiles: string[] = []; + const configStub = configuration({ sessionWatcher: true, 'source.focus': 'none' }); + sandbox.stub(util, 'config').returns(configStub); + sandbox.stub(util, 'getRterm').resolves(process.execPath); + const pipe = sandbox.stub(session, 'getGlobalPipePath'); + if (failedResource === 'pipe') { + pipe.rejects(failure); + } else { + pipe.resolves('unused-test-endpoint'); + } + if (failedResource === 'consent') { + consentDirectoryStub.rejects(failure); + } + const createFile = session.createSessionDiscoveryFile; + const createDiscovery = sandbox.stub(session, 'createSessionDiscoveryFile'); + if (failedResource === 'discovery') { + createDiscovery.rejects(failure); + } else if (failedResource === 'helper-path') { + createDiscovery.callsFake(async endpoint => { + const file = await createFile(endpoint); + createdFiles.push(file); + return file; + }); + const asAbsolutePath = extension.extensionContext.asAbsolutePath.bind(extension.extensionContext); + sandbox.stub(extension.extensionContext, 'asAbsolutePath').callsFake(relativePath => { + if (relativePath.startsWith(path.join('dist', 'resources', 'sess'))) { + throw failure; + } + return asAbsolutePath(relativePath); + }); + } else { + createDiscovery.resolves('/unused-test-discovery'); + } + const terminal = { + name: 'R Interactive', processId: Promise.resolve(undefined), + show: sandbox.stub(), dispose: sandbox.stub(), + } as unknown as vscode.Terminal; + const createTerminal = sandbox.stub(vscode.window, 'createTerminal').returns(terminal); + const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); + + try { + assert.strictEqual(await rTerminal.createRTerm(), true); + sinon.assert.calledOnce(createTerminal); + sinon.assert.calledOnce(warning); + const options = createTerminal.firstCall.args[0] as vscode.TerminalOptions; + assert.strictEqual(options.shellPath, process.execPath); + assert.strictEqual(options.env?.['SESS_DISCOVERY_FILE'], undefined); + assert.strictEqual(options.env?.['VSCODE_R_SESS_STARTUP_FILE'], undefined); + assert.strictEqual(options.env?.['VSCODE_R_SESS_STARTUP_TOKEN'], undefined); + assert.strictEqual(options.env?.['R_PROFILE_USER'], undefined); + for (const file of createdFiles) { + assert.strictEqual(await pathExists(file), false); + assert.strictEqual(await pathExists(`${file}.startup`), false); + } + } finally { + rTerminal.deleteTerminal(terminal); + await Promise.all(createdFiles.map(file => fsp.rm(file, { recursive: true, force: true }))); + await Promise.all(createdFiles.map(file => fsp.rm(`${file}.startup`, { recursive: true, force: true }))); + } }); + } + + test('createRTerm skips watcher setup when the configured executable is invalid', async () => { + const createDiscoveryFile = sandbox.stub(session, 'createSessionDiscoveryFile'); const configStub = { get: (key: string) => key === 'sessionWatcher' ? true : undefined, }; sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(`${process.execPath}.does-not-exist`); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); - try { - assert.strictEqual(await rTerminal.createRTerm(), false); - assert.strictEqual(createdDiscoveryFiles.length, 1); - assert.strictEqual(await pathExists(createdDiscoveryFiles[0]), false); - } finally { - await Promise.all(createdDiscoveryFiles.map(filePath => fsp.rm(filePath, { recursive: true, force: true }))); - } + assert.strictEqual(await rTerminal.createRTerm(), false); + sinon.assert.notCalled(createDiscoveryFile); }); test('createRTerm removes its discovery file when VS Code terminal creation throws', async () => { const createdDiscoveryFiles: string[] = []; + const createdStartupFiles: string[] = []; const createDiscoveryFile = session.createSessionDiscoveryFile; sandbox.stub(session, 'createSessionDiscoveryFile').callsFake(async endpoint => { const filePath = await createDiscoveryFile(endpoint); createdDiscoveryFiles.push(filePath); + const startupPath = `${filePath}.startup`; + createdStartupFiles.push(startupPath); + await fsp.writeFile(startupPath, 'pending\n'); return filePath; }); const configStub = { @@ -655,39 +755,16 @@ suite('R Terminal', () => { }; sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); sandbox.stub(vscode.window, 'createTerminal').throws(new Error('terminal creation failed')); try { await assert.rejects(rTerminal.createRTerm(), /terminal creation failed/); assert.strictEqual(createdDiscoveryFiles.length, 1); assert.strictEqual(await pathExists(createdDiscoveryFiles[0]), false); + assert.strictEqual(await pathExists(createdStartupFiles[0]), false); } finally { await Promise.all(createdDiscoveryFiles.map(filePath => fsp.rm(filePath, { recursive: true, force: true }))); - } - }); - - test('createRTerm removes its discovery file when sess installation fails', async () => { - const createdDiscoveryFiles: string[] = []; - const createDiscoveryFile = session.createSessionDiscoveryFile; - sandbox.stub(session, 'createSessionDiscoveryFile').callsFake(async endpoint => { - const filePath = await createDiscoveryFile(endpoint); - createdDiscoveryFiles.push(filePath); - return filePath; - }); - sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'sessionWatcher' ? true : undefined, - } as unknown as vscode.WorkspaceConfiguration); - sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(false); - const createTerminal = sandbox.stub(vscode.window, 'createTerminal'); - try { - assert.strictEqual(await rTerminal.createRTerm(), false); - assert.strictEqual(createTerminal.called, false); - assert.strictEqual(createdDiscoveryFiles.length, 1); - assert.strictEqual(await pathExists(createdDiscoveryFiles[0]), false); - } finally { - await Promise.all(createdDiscoveryFiles.map(filePath => fsp.rm(filePath, { recursive: true, force: true }))); + await Promise.all(createdStartupFiles.map(filePath => fsp.rm(filePath, { recursive: true, force: true }))); } }); @@ -733,7 +810,6 @@ suite('R Terminal', () => { }; sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); const createTerminalStub = sandbox.stub(vscode.window, 'createTerminal'); createTerminalStub.onFirstCall().returns(firstTerminal); createTerminalStub.onSecondCall().returns(secondTerminal); @@ -793,7 +869,6 @@ suite('R Terminal', () => { }; sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); - sandbox.stub(util, 'promptToInstallSessPackage').resolves(true); // We need to mock the terminal and its processId const fakeTerminal = { diff --git a/src/util.ts b/src/util.ts index 6ccd0deea..44264be74 100644 --- a/src/util.ts +++ b/src/util.ts @@ -310,22 +310,6 @@ export function readSessSourceRevision(description: string | undefined): string return revision && /^git-tree:(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(revision) ? revision : undefined; } -export async function getInstalledSessSourceRevision(cwd?: string | URL): Promise { - const helper = extensionContext.asAbsolutePath(path.join('R', 'sess_source.R')); - // Use single quotes so this -e expression also works with Windows R.exe. - const helperLiteral = `'${helper.replace(/\\/g, '/').replace(/'/g, '\\\'')}'`; - // Keep the -e argument on one line for the Windows R.exe launcher. - const command = [ - 'local({', - `source(${helperLiteral}, local = TRUE);`, - 'revision <- sess_installed_source_revision();', - 'if (!is.null(revision)) cat(revision)', - '})' - ].join(' '); - const result = await executeRCommand(command, cwd); - return result || undefined; -} - export function getRLibPaths(): string | undefined { return config().get('libPaths') ?.map(value => substituteVariables(value)) @@ -576,74 +560,6 @@ export async function promptToInstallRPackage(name: string, section: string, cwd }); } -/** - * Prompt to install bundled sess and wait for a requested installation. - * Return false if setup fails; declining installation still permits starting R. - */ -export async function promptToInstallSessPackage( - cwd?: string | vscode.Uri, - _config = config, - _getInstalledSessSourceRevision = getInstalledSessSourceRevision, - _readFileSyncSafe = readFileSyncSafe, - _executeAsTask: (name: string, process: string, args: string[], asProcess: true, cwd?: string) => Promise = executeAsTask -): Promise { - const resource = resourceFromCwd(cwd); - const workingDirectory = cwd instanceof vscode.Uri ? cwd.fsPath : cwd; - const activeConfig = _config(); - const sessionWatcher = activeConfig.get('sessionWatcher'); - if (!sessionWatcher) { - return true; - } - - const sessPath = extensionContext.asAbsolutePath(path.join('dist', 'resources', 'sess')).replace(/\\/g, '/'); - const descriptionPath = path.join(sessPath, 'DESCRIPTION'); - const descriptionContent = _readFileSyncSafe(descriptionPath); - const bundledRevision = readSessSourceRevision(descriptionContent); - if (!bundledRevision) { - void vscode.window.showErrorMessage('Bundled sess has no valid source revision. Rebuild or reinstall the vscode-R extension.'); - return false; - } - - const installedRevision = await _getInstalledSessSourceRevision(workingDirectory); - if (installedRevision === bundledRevision) { - return true; - } - - const installSessScript = extensionContext.asAbsolutePath(path.join('R', 'install_sess.R')).replace(/\\/g, '/'); - const installMsg = installedRevision - ? 'The installed "sess" package does not match this build of vscode-R. Install the bundled copy?' - : 'The R package "sess" bundled with this build of vscode-R is required for the session watcher to work. Do you want to install it?'; - - const select = await vscode.window.showWarningMessage(installMsg, 'Yes', 'No'); - if (select !== 'Yes') { - return true; - } - const rPath = await getRpath(false, resource); - if (!rPath) { - return false; - } - const repo = await getCranUrl('', workingDirectory); - const args = [ - '--silent', - '--no-echo', - '--no-save', - '--no-restore', - `--file=${installSessScript}`, - '--args', sessPath, repo - ]; - try { - await _executeAsTask('Install "sess" package', rPath, args, true, workingDirectory); - if (await _getInstalledSessSourceRevision(workingDirectory) !== bundledRevision) { - void vscode.window.showErrorMessage('The bundled "sess" package was not installed successfully. Check the installation task output and try starting R again.'); - return false; - } - return true; - } catch (error) { - void vscode.window.showErrorMessage(`Could not install the bundled "sess" package: ${catchAsError(error).message}`); - return false; - } -} - /** * Create temporary directory. Will avoid name clashes. Caller must delete directory after use. *