From 05ef11bd5b26cd1b99b5915cac9d1adf87329a12 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 01:13:40 +0000 Subject: [PATCH 01/21] fix: isolate sess installation in managed libraries --- CONTRIBUTING.md | 11 +- R/attach_sess.R | 89 +++++++ R/install_sess.R | 32 +-- R/profile.R | 47 +++- R/sess_source.R | 76 +++++- R/tests/attach_sess.R | 124 ++++++++++ R/tests/sess_source.R | 297 +++++++++++++---------- package.json | 2 +- sess/README.md | 18 +- src/rTerminal.ts | 3 + src/sessConsent.ts | 156 ++++++++++++ src/session.ts | 88 +++++-- src/test/integration/sessInstall.test.ts | 16 +- src/test/node/sessConsent.test.ts | 172 +++++++++++++ src/test/suite/sessInstall.test.ts | 276 +++++++++++++-------- src/test/suite/session.test.ts | 22 +- src/util.ts | 68 +++++- 17 files changed, 1188 insertions(+), 309 deletions(-) create mode 100644 R/attach_sess.R create mode 100644 R/tests/attach_sess.R create mode 100644 src/sessConsent.ts create mode 100644 src/test/node/sessConsent.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66eb4ba69..e0103479d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,10 +42,13 @@ 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. 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..3bc43b81f --- /dev/null +++ b/R/attach_sess.R @@ -0,0 +1,89 @@ +# Manual attach setup. Installation requires a single-use grant from the +# extension process; this helper never changes .libPaths(). +vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, + source_helper, installer_helper, plot_backend, + timeout_seconds = 180) { + 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 { + installed <- sess_find_source_library(expected, managed_library) + if (!is.null(installed)) { + ns <- sess_load_namespace(installed, expected) + } 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.") + } + id_parts <- vapply(seq_len(2L), function(unused) { + gsub("[^A-Za-z0-9_-]", "", + sub("^request-", "", basename(tempfile(pattern = "request-", tmpdir = consent_dir)))) + }, "") + id <- paste0(id_parts, collapse = "") + 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(invisible(FALSE)) + } + + 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) + } + } + } + + get("connect", envir = ns, inherits = FALSE)(endpoint = endpoint, plot_backend = plot_backend) + invisible(TRUE) +} 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..4f0e1ad35 100644 --- a/R/profile.R +++ b/R/profile.R @@ -24,12 +24,51 @@ 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(NULL)) + + helper <- Sys.getenv("VSCODE_R_SESS_SOURCE_HELPER", unset = "") + if (!nzchar(helper) || !file.exists(helper)) { + message("vscode-R could not locate its sess source verifier; the session watcher was not started.") + return(invisible(NULL)) + } + source(helper, local = TRUE) + expected <- sess_source_revision(file.path(bundled_path, "DESCRIPTION")) + if (is.null(expected)) { + message("Bundled sess has no valid source revision; the session watcher was not started.") + return(invisible(NULL)) + } + + loaded_revision <- sess_loaded_source_revision() + if ("sess" %in% loadedNamespaces()) { + if (!identical(loaded_revision, expected)) { + stop("A different sess namespace is already loaded. Restart R so vscode-R can attach the session watcher.") + } + library <- dirname(getNamespaceInfo(asNamespace("sess"), "path")) + } else { + library <- sess_find_source_library(expected, .libPaths()) + if (is.null(library)) { + managed_root <- Sys.getenv("VSCODE_R_SESS_ROOT", unset = "") + managed_library <- if (nzchar(managed_root)) sess_managed_library(managed_root, expected) else "" + if (!nzchar(managed_library) || + !identical(sess_installed_source_revision(managed_library), expected)) { + message("The bundled sess required by vscode-R is unavailable. The R session started without the session watcher.") + return(invisible(NULL)) + } + library <- managed_library + } + sess_load_namespace(library, expected) + } + plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") - sess::connect( + getExportedValue("sess", "connect")( use_rstudioapi = as.logical(Sys.getenv("SESS_RSTUDIOAPI", "TRUE")), plot_backend = plot_backend ) + } + tryCatch(initialize_sess(), error = function(error) { + message("vscode-R could not start the session watcher: ", conditionMessage(error)) }) -} +}) diff --git a/R/sess_source.R b/R/sess_source.R index a8202aed7..efdf4bac3 100644 --- a/R/sess_source.R +++ b/R/sess_source.R @@ -10,14 +10,78 @@ 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)) { - return(NULL) + 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) + 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.") } - sess_source_revision(file.path(pkg, "DESCRIPTION")) + ns } sess_install_required <- function(pkg_path) { @@ -25,5 +89,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/tests/attach_sess.R b/R/tests/attach_sess.R new file mode 100644 index 000000000..0023439d7 --- /dev/null +++ b/R/tests/attach_sess.R @@ -0,0 +1,124 @@ +# Base-R integration coverage for manual attach consent; packages are local fixtures. +root <- tempfile("attach-sess-") +dir.create(root) +on.exit(unlink(root, recursive = TRUE), add = TRUE) + +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, plot_backend) list(endpoint = endpoint, plot_backend = plot_backend)", + "notify_client <- function(...) NULL", "request_client <- function(...) NULL" +), 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]]", + "source(file.path(getwd(), 'R', 'sess_source.R'))", + "source(file.path(getwd(), 'R', 'attach_sess.R'))", + "normal <- file.path(root, 'normal library'); dir.create(normal, recursive=TRUE)", + "consent <- file.path(root, 'consent'); dir.create(consent, recursive=TRUE, showWarnings=FALSE)", + "managed_root <- file.path(root, 'vscode-R')", + "Sys.setenv(R_LIBS_USER=file.path(root, 'empty user library'), R_LIBS_SITE=.Library)", + "original <- .Library; .libPaths(c(normal, original)); ordinary <- .libPaths()", + "if (mode %in% c('exact', 'prepared')) {", + " library <- if (mode == 'exact') normal else sess_managed_library(managed_root, revision)", + " dir.create(library, recursive=TRUE, showWarnings=FALSE)", + " utils::install.packages(pkg, repos=NULL, type='source', lib=library, quiet=TRUE)", + "}", + "if (mode == 'twice') {", + " utils::install.packages(pkg, repos=NULL, type='source', lib=normal, quiet=TRUE)", + " desc <- read.dcf(file.path(normal, 'sess', 'DESCRIPTION'))", + " desc[1L, 'Config/vscode-R/source-revision'] <- mismatch", + " write.dcf(desc, file.path(normal, 'sess', 'DESCRIPTION'))", + " stopifnot(identical(sess_source_revision(file.path(normal, 'sess', 'DESCRIPTION')), mismatch))", + " for (i in 1:2) { set.seed(8841); seed <- .Random.seed; result <- vscode_r_attach_sess('endpoint', pkg, managed_root, consent, file.path(getwd(), 'R', 'sess_source.R'), file.path(getwd(), 'R', 'sess-package-install.R'), 'standard', timeout_seconds=8); if (!identical(result, FALSE)) stop(paste('unexpected result', mode, i, paste(capture.output(str(result)), collapse=' '), 'source', sess_installed_source_revision(ordinary))); stopifnot(identical(seed, .Random.seed), identical(.libPaths(), ordinary)) }", + " stopifnot(identical(read.dcf(file.path(normal, 'sess', 'DESCRIPTION')), desc), !dir.exists(sess_managed_library(managed_root, revision)))", + "} else if (mode %in% c('exact', 'prepared')) {", + " expected <- if (mode == 'exact') normal else sess_managed_library(managed_root, revision)", + " result <- vscode_r_attach_sess('endpoint', pkg, managed_root, consent, file.path(getwd(), 'R', 'sess_source.R'), file.path(getwd(), 'R', 'sess-package-install.R'), 'standard', timeout_seconds=0)", + " stopifnot(identical(result, TRUE), identical(.libPaths(), ordinary), identical(sess_loaded_source_revision(), revision))", + " stopifnot(identical(normalizePath(getNamespaceInfo(asNamespace('sess'), 'path')), normalizePath(file.path(expected, 'sess'))))", + " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + "} else if (mode == 'timeout') {", + " set.seed(8841); seed <- .Random.seed", + " result <- vscode_r_attach_sess('endpoint', pkg, managed_root, consent, file.path(getwd(), 'R', 'sess_source.R'), file.path(getwd(), 'R', 'sess-package-install.R'), 'standard', timeout_seconds=0)", + " stopifnot(identical(result, FALSE), identical(seed, .Random.seed), identical(.libPaths(), ordinary))", + " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)), length(list.files(consent, pattern='\\\\.request$')) == 0L)", + "} else if (mode == 'approve') {", + " wrong_library <- file.path(managed_root, 'fixture-other-platform', '99.99', strrep('a', 40), 'library')", + " dir.create(wrong_library, recursive=TRUE, showWarnings=FALSE)", + " utils::install.packages(pkg, repos=NULL, type='source', lib=wrong_library, quiet=TRUE)", + " set.seed(8841); seed <- .Random.seed", + " result <- vscode_r_attach_sess('endpoint', pkg, managed_root, consent, file.path(getwd(), 'R', 'sess_source.R'), file.path(getwd(), 'R', 'sess-package-install.R'), 'standard', timeout_seconds=20)", + " library <- sess_managed_library(managed_root, revision)", + " stopifnot(identical(result, TRUE), identical(.libPaths(), ordinary), identical(sess_loaded_source_revision(), revision))", + " stopifnot(identical(normalizePath(getNamespaceInfo(asNamespace('sess'), 'path')), normalizePath(file.path(library, 'sess'))))", + " stopifnot(file.exists(file.path(library, 'sess', 'DESCRIPTION')), !dir.exists(file.path(normal, 'sess')))", + " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')), identical(sess_source_revision(file.path(wrong_library, 'sess', 'DESCRIPTION')), revision))", + "} else stop('unknown case')", + "cat('manual attach case passed:', mode, '\\n')" +), runner) + +agent <- file.path(root, "consent-agent.R") +writeLines(c( + "args <- commandArgs(TRUE); directory <- args[[1L]]; count <- as.integer(args[[2L]]); answer <- args[[3L]]; log <- args[[4L]]", + "ids <- 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 consent request')", + " id <- lines[[2L]]; if (id %in% ids) next", + " ids <- c(ids, id); temporary <- tempfile(tmpdir=directory)", + " writeLines(answer, temporary); if (!file.rename(temporary, file.path(directory, paste0(id, '.response')))) stop('response rename failed')", + " }", + " Sys.sleep(0.025)", + "}", + "if (length(ids) != count) stop('timed out waiting for consent requests')", + "writeLines(ids, log)" +), agent) + +r_binary <- file.path(R.home("bin"), if (.Platform$OS.type == "windows") "R.exe" else "R") +run_case <- function(mode) { + case_root <- file.path(root, mode) + dir.create(case_root) + consent <- file.path(case_root, "consent") + dir.create(consent) + if (mode %in% c("approve", "twice")) { + log <- file.path(case_root, "consent-ids") + count <- if (mode == "twice") 2L else 1L + answer <- if (mode == "twice") "decline" else "approve" + system2(r_binary, shQuote(c("--vanilla", "--slave", paste0("--file=", agent), "--args", consent, + as.character(count), answer, log)), + wait = FALSE, stdout = FALSE, stderr = FALSE) + } + output <- suppressWarnings(system2(r_binary, shQuote(c( + "--vanilla", "--slave", "--no-save", "--no-restore", 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% c("approve", "twice")) { + log_path <- file.path(case_root, "consent-ids") + deadline <- Sys.time() + 5 + while (!file.exists(log_path) && Sys.time() < deadline) Sys.sleep(0.01) + stopifnot(file.exists(log_path)) + ids <- readLines(log_path, warn=FALSE) + stopifnot(length(ids) == if (mode == "twice") 2L else 1L, + !anyDuplicated(ids), all(grepl("^[A-Za-z0-9_-]{16,64}$", ids))) + } +} + +for (mode in c("twice", "exact", "prepared", "timeout", "approve")) run_case(mode) +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..b3b87fadd 100644 --- a/R/tests/sess_source.R +++ b/R/tests/sess_source.R @@ -1,111 +1,91 @@ -# 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") + 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(sess_find_source_library(stable), user_library)) +stopifnot(identical(.libPaths()[1:2], 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"))) -# 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 +93,102 @@ 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(.libPaths(), 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") +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))", + "Sys.setenv(VSCODE_R_SESS_PKG_PATH=args[6], VSCODE_R_SESS_SOURCE_HELPER=args[1], VSCODE_R_SESS_ROOT=args[7])", + "messages <- character()", + "withCallingHandlers(source(args[8]), message=function(m) { messages <<- c(messages, conditionMessage(m)); invokeRestart('muffleMessage') })", + "stopifnot(any(grepl('different sess namespace is already loaded', messages)), identical(sess_loaded_source_revision(), mismatch))", + "stopifnot(identical(.libPaths(), normal))" +), 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) +)), 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(.libPaths(), 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 e2c3c2dde..0b1cb1106 100644 --- a/package.json +++ b/package.json @@ -2535,7 +2535,7 @@ "test:extension": "vscode-test", "lint": "oxlint src", "typecheck": "tsc --noEmit -p ./", - "test:sess-identity": "Rscript --vanilla R/tests/sess_source.R", + "test:sess-identity": "Rscript --vanilla R/tests/sess_source.R && Rscript --vanilla R/tests/attach_sess.R", "test:sess-source": "node --test scripts/sess-source.test.js && pnpm run test:sess-identity", "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/README.md b/sess/README.md index 19d69e7fe..2b21dda6d 100644 --- a/sess/README.md +++ b/sess/README.md @@ -23,13 +23,17 @@ 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. Manual attach also +> asks before installation, using the R executable and libraries of the attached +> session. 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 diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 4919a458e..6181330ce 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -260,6 +260,9 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise boolean; + 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; + } + + let approved = false; + try { approved = await this.options.prompt(request); } + catch { approved = false; } + if (this.stopped) { return; } + const response = !this.stopped && this.options.isEnabled() && approved ? '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 c290f1e2f..308c0c554 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'; @@ -27,6 +27,7 @@ import { getListViewerScript, listViewerStyle, ListViewNavigation } from './list import { getDataViewerScript, getDataViewerStyle, getDataViewerToolbarHtml } from './dataViewer'; import { getDataViewerColumnPanelHtml, getDataViewerColumnPanelScript, getDataViewerColumnPanelStyle } from './dataViewerColumnPanel'; import { formatSessionLabel, getViewerSessionScript } from './viewerSession'; +import { SessConsentService } from './sessConsent'; export interface SessionInfo { version: string; @@ -432,6 +433,9 @@ 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; interface SessionDiscoveryFile { version: 1; @@ -837,39 +841,80 @@ 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, '/'))}`, + ` 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(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, + 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.' + : '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.'; + const choice = await window.showWarningMessage(message, 'Install bundled sess'); + return choice === 'Install bundled sess'; + }, + }); + 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 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 ensureAttachSessConsentService(); 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; @@ -887,6 +932,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); @@ -2502,6 +2556,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/test/integration/sessInstall.test.ts b/src/test/integration/sessInstall.test.ts index 96c758f53..3581c037f 100644 --- a/src/test/integration/sessInstall.test.ts +++ b/src/test/integration/sessInstall.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as util from '../../util'; +import { extensionContext } from '../../extension'; import { mockExtensionContext } from '../common/mockvscode'; const bundledRevision = 'git-tree:' + 'a'.repeat(40); @@ -26,6 +27,7 @@ suite('Sess installation with real R tasks', () => { let extensionRoot: string; let projectA: string; let projectB: string; + let managedLibrary: string; setup(async () => { sandbox = sinon.createSandbox(); @@ -56,6 +58,11 @@ suite('Sess installation with real R tasks', () => { await fsp.writeFile(path.join(project, '.Rprofile'), '.libPaths(c(file.path(getwd(), "library"), .libPaths()))\n'); } mockExtensionContext(extensionRoot, sandbox); + const identity = await util.getSessRuntimeIdentity(projectA); + assert.ok(identity); + managedLibrary = util.getSessManagedLibrary( + path.join(extensionContext.globalStorageUri.fsPath, 'sess'), + identity!, bundledRevision); sandbox.stub(vscode.window, 'showWarningMessage').resolves('Yes' as unknown as vscode.MessageItem); }); @@ -65,11 +72,12 @@ suite('Sess installation with real R tasks', () => { 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(managedLibrary, { 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 () => { + test(`installs into the vscode-R library and preserves project libraries (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); @@ -81,11 +89,13 @@ suite('Sess installation with real R tasks', () => { 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(projectB), oldRevision); 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 util.getInstalledSessSourceRevision(projectB, bundledRevision, managedLibrary), bundledRevision); + assert.strictEqual(await pathExists(path.join(managedLibrary, 'sess', 'Meta', 'package.rds')), true); + assert.strictEqual(await pathExists(path.join(projectB, 'library', 'sess', 'Meta')), false); assert.strictEqual(await pathExists(path.join(projectA, 'library', 'sess', 'Meta')), false); }).timeout(120000); } diff --git a/src/test/node/sessConsent.test.ts b/src/test/node/sessConsent.test.ts new file mode 100644 index 000000000..4f53dfa4d --- /dev/null +++ b/src/test/node/sessConsent.test.ts @@ -0,0 +1,172 @@ +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 { SessConsentService } from '../../sessConsent'; + +const revision = `git-tree:${'a'.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'): Promise { + await fs.writeFile(path.join(directory, `${id}.request`), + `vscode-r-sess-consent-v1\n${id}\n${revision}\nlinux-x86_64|4.5\n${reason}\n`); +} + +suite('sess install consent bridge', () => { + let directory: string; + const services: SessConsentService[] = []; + + setup(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-sess-consent-')); + }); + + 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, isEnabled = () => true, + expectedRevision = revision): SessConsentService { + const result = new SessConsentService({ directory, expectedRevision, prompt, isEnabled, intervalMs: 5 }); + 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(true); + }; + const broker = service(prompt); + await broker.start(); + await writeRequest(directory, id); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'approve\n'); + }); + + test('accepts the CRLF lines written by base R on Windows', async () => { + const id = '7'.repeat(32); + const broker = service(() => Promise.resolve(true)); + 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(false); }, () => 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('declines when the user dismisses the prompt', async () => { + let prompts = 0; + const broker = service(() => { prompts++; return Promise.resolve(false); }); + await broker.start(); + const id = '8'.repeat(32); + await writeRequest(directory, id); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + assert.strictEqual(prompts, 1); + }); + + test('shutdown while the prompt is pending cannot publish a late approval', async () => { + let resolvePrompt!: (approved: boolean) => void; + let prompts = 0; + const broker = service(() => { + prompts++; + return new Promise(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(true); + 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!: (approved: boolean) => void; + let prompts = 0; + const broker = service(() => { + prompts++; + return new Promise(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(true); + 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(true); }, () => 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!: (approved: boolean) => void; + let prompts = 0; + const broker = service(() => { + prompts++; + return prompts === 1 + ? new Promise(resolve => { resolvePrompt = resolve; }) + : Promise.resolve(false); + }); + 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(true); + 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(true); }); + 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/suite/sessInstall.test.ts b/src/test/suite/sessInstall.test.ts index 1ccade21c..40d81bd44 100644 --- a/src/test/suite/sessInstall.test.ts +++ b/src/test/suite/sessInstall.test.ts @@ -2,6 +2,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as path from 'path'; +import * as os from 'os'; import * as util from '../../util'; import { mockExtensionContext } from '../common/mockvscode'; @@ -9,7 +10,8 @@ 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 identity: util.SessRuntimeIdentity = { platform: 'x86_64-pc-linux-gnu', version: '4.4' }; +const identityProvider = () => Promise.resolve(identity); const extension_root: string = path.join(__dirname, '..', '..', '..'); suite('Sess Install Test Suite', () => { @@ -27,149 +29,229 @@ suite('Sess Install Test Suite', () => { 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 () => { + async function enableSessionWatcher(): Promise { 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')); - }); + function bundledDescription(revision = stableRevision) { + return sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', revision)); + } - 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); + function taskStub(): sinon.SinonStub<[string, string, string[], true, string?], Promise> { + return sinon.stub<[string, string, string[], true, string?], Promise>().resolves(); + } + + test('does nothing when sessionWatcher is disabled', async () => { + await vscode.workspace.getConfiguration('r').update('sessionWatcher', false, vscode.ConfigurationTarget.Global); + const installed = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(undefined); + const read = sandbox.stub(util, 'readFileSyncSafe'); + const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const task = taskStub(); + + assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); + assert.strictEqual(installed.called, false); + assert.strictEqual(read.called, false); + assert.strictEqual(prompt.called, false); + assert.strictEqual(task.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); + test('uses an exact source revision from a normal library without prompting or installing', async () => { + await enableSessionWatcher(); const installed = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(stableRevision); - const read = sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', preReleaseRevision)); + const read = bundledDescription(); const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - await util.promptToInstallSessPackage(undefined, undefined, installed, read); - assert.strictEqual(prompt.calledOnce, true); + const task = taskStub(); + + assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); + assert.strictEqual(installed.calledOnce, true); + assert.deepStrictEqual(installed.firstCall.args, [undefined, stableRevision]); + assert.strictEqual(prompt.called, false); + assert.strictEqual(task.called, false); }); - test('legacy packages without source metadata prompt once, then matching metadata skips', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); + test('uses an exact source revision already prepared in the managed library', async () => { + await enableSessionWatcher(); 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 read = bundledDescription(); const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - await util.promptToInstallSessPackage(undefined, undefined, installed, read); - await util.promptToInstallSessPackage(undefined, undefined, installed, read); + const task = taskStub(); + + assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); + assert.strictEqual(installed.calledTwice, true); + assert.deepStrictEqual(installed.firstCall.args, [undefined, stableRevision]); + assert.deepStrictEqual(installed.secondCall.args.slice(0, 2), [undefined, stableRevision]); + assert.strictEqual(installed.secondCall.args[2], util.getSessManagedLibrary( + path.join(os.tmpdir(), 'vscode-r-test-global-storage', String(process.pid), 'sess'), identity, stableRevision)); + assert.strictEqual(prompt.called, false); + assert.strictEqual(task.called, false); + }); + + for (const answer of [undefined, 'No' as unknown as vscode.MessageItem]) { + test(`declining installation (${answer === undefined ? 'dismiss' : 'No'}) leaves libraries untouched`, async () => { + await enableSessionWatcher(); + const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); + installed.onFirstCall().resolves(undefined); // No exact revision in normal libraries. + installed.onSecondCall().resolves(undefined); // No prepared managed copy. + installed.onThirdCall().resolves(preReleaseRevision); // Existing, mismatched sess remains intact. + const read = bundledDescription(); + const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(answer); + const task = taskStub(); + + assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); + assert.strictEqual(prompt.calledOnce, true); + assert.match(prompt.firstCall.args[0], /vscode-R-managed library/); + assert.match(prompt.firstCall.args[0], /existing installation will not be modified|existing sess installations will not be modified/); + assert.strictEqual(task.called, false); + assert.strictEqual(installed.callCount, 3); + }); + } + + test('asks before installing a missing sess and targets only the managed library', async () => { + await enableSessionWatcher(); + const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); + installed.onFirstCall().resolves(undefined); // Normal libraries have no exact revision. + installed.onSecondCall().resolves(undefined); // Managed library is not prepared yet. + installed.onThirdCall().resolves(undefined); // No sess exists in normal libraries. + installed.onCall(3).resolves(stableRevision); // Verify the installed managed copy by exact revision. + const read = bundledDescription(); + const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves('Yes' as unknown as vscode.MessageItem); + const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const task = taskStub(); + + assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); assert.strictEqual(prompt.calledOnce, true); + assert.match(prompt.firstCall.args[0], /vscode-R needs its bundled sess package/); + assert.match(prompt.firstCall.args[0], /vscode-R-managed library/); + assert.match(prompt.firstCall.args[0], /existing sess installations will not be modified/); + assert.strictEqual(task.calledOnce, true); + const args = task.firstCall.args; + assert.strictEqual(args[0], 'Install "sess" package'); + assert.ok(args[1]); + assert.ok(args[2].includes(`--file=${path.join(extension_root, 'R', 'install_sess.R').replace(/\\/g, '/')}`)); + const managedLibrary = installed.getCall(1).args[2]; + assert.ok(managedLibrary); + assert.strictEqual(args[2].at(-1), managedLibrary); + assert.ok(managedLibrary.includes(identity.platform)); + assert.ok(managedLibrary.includes(identity.version)); + assert.ok(managedLibrary.includes(stableRevision.slice('git-tree:'.length))); + assert.deepStrictEqual(installed.getCall(3).args, [undefined, stableRevision, managedLibrary]); + assert.strictEqual(errors.called, false); }); + for (const [installedRevision, bundledRevision] of [ + [preReleaseRevision, stableRevision], + [stableRevision, preReleaseRevision] + ]) { + test(`prompts for a ${installedRevision === stableRevision ? 'stable to pre-release' : 'pre-release to stable'} revision change`, async () => { + await enableSessionWatcher(); + const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); + installed.onFirstCall().resolves(undefined); + installed.onSecondCall().resolves(undefined); + installed.onThirdCall().resolves(installedRevision); + const read = bundledDescription(bundledRevision); + const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const task = taskStub(); + + assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); + assert.strictEqual(prompt.calledOnce, true); + assert.match(prompt.firstCall.args[0], /does not match this build/); + assert.match(prompt.firstCall.args[0], /vscode-R-managed library/); + assert.strictEqual(task.called, false); + }); + } + 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); + await enableSessionWatcher(); + const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); 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); + const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const task = taskStub(); + + assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), false); assert.strictEqual(installed.called, false); - assert.match(prompt.firstCall.args[0], /Rebuild or reinstall/); - assert.strictEqual(prompt.firstCall.args.length, 1); + assert.match(errors.firstCall.args[0], /Rebuild or reinstall/); + assert.strictEqual(prompt.called, false); + assert.strictEqual(task.called, false); }); - test('requested installation waits for the task and verifies the installed source', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); + test('waits for installation and verifies the exact managed source revision', async () => { + await enableSessionWatcher(); 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)); + installed.onFirstCall().resolves(undefined); + installed.onSecondCall().resolves(undefined); + installed.onThirdCall().resolves(preReleaseRevision); + installed.onCall(3).resolves(preReleaseRevision); + const read = bundledDescription(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(() => { + const task = sinon.stub<[string, string, string[], true, string?], Promise>().callsFake(() => { taskStarted(); return taskFinished; }); + let completed = false; - const setup = util.promptToInstallSessPackage(undefined, undefined, installed, read, task).then(result => { + const setup = util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider).then(result => { completed = true; return result; }); await started; assert.strictEqual(completed, false); - assert.strictEqual(installed.calledOnce, true); + assert.strictEqual(installed.calledThrice, true); finishTask(); assert.strictEqual(await setup, true); - assert.strictEqual(installed.calledTwice, true); + assert.strictEqual(installed.callCount, 4); + assert.strictEqual(installed.getCall(3).args[1], preReleaseRevision); + assert.strictEqual(installed.getCall(3).args[2], task.firstCall.args[2].at(-1)); 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)); + test('failed installation and ineffective managed installs prevent startup', async () => { + await enableSessionWatcher(); + const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); + installed.onFirstCall().resolves(undefined); + installed.onSecondCall().resolves(undefined); + installed.onThirdCall().resolves(preReleaseRevision); + installed.onCall(3).resolves(undefined); // Task ended, but exact managed revision is still absent. + const read = bundledDescription(); 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); + const task = taskStub(); + + assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), false); assert.match(errors.firstCall.args[0], /not installed successfully/); - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task), false); + assert.deepStrictEqual(installed.getCall(3).args.slice(0, 2), [undefined, stableRevision]); + + const failingTask = taskStub().rejects(new Error('Could not start installation task')); + installed.onCall(4).resolves(undefined); + installed.onCall(5).resolves(undefined); + installed.onCall(6).resolves(preReleaseRevision); + assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, failingTask, identityProvider), false); assert.match(errors.secondCall.args[0], /Could not start installation task/); }).timeout(15000); + test('managed library path layout matches the platform, R version, and revision on common platforms', () => { + const managedRoot = '/managed root/sess'; + const revisionHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const cases = [ + [{ platform: 'x86_64-w64-mingw32', version: '4.5' }, `x86_64-w64-mingw32/4.5/${revisionHash}/library`], + [{ platform: 'aarch64-apple-darwin20', version: '4.5' }, `aarch64-apple-darwin20/4.5/${revisionHash}/library`], + [{ platform: 'x86_64-pc-linux-gnu', version: '4.5' }, `x86_64-pc-linux-gnu/4.5/${revisionHash}/library`], + ] as const; + const normalize = (value: string) => value.replace(/\\/g, '/'); + + for (const [runtimeIdentity, expectedSuffix] of cases) { + const library = util.getSessManagedLibrary(managedRoot, runtimeIdentity, stableRevision); + assert.strictEqual(normalize(library), `${normalize(managedRoot)}/${expectedSuffix}`); + } + }); + 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)]) { diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 4fc0ad3ff..8f05c8e28 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -643,9 +643,10 @@ 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/); + 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/); @@ -680,6 +681,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) }; @@ -734,7 +748,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/util.ts b/src/util.ts index 6ccd0deea..3b0756752 100644 --- a/src/util.ts +++ b/src/util.ts @@ -310,22 +310,49 @@ 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 { +function rStringLiteral(value: string): string { + return `'${value.replace(/\\/g, '/').replace(/'/g, "\\'")}'`; +} + +async function querySessSource(revision: string | undefined, library: string | undefined, 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 helperLiteral = rStringLiteral(helper); + const libraryArgument = library ? `, lib.loc = ${rStringLiteral(library)}` : ''; const command = [ 'local({', `source(${helperLiteral}, local = TRUE);`, - 'revision <- sess_installed_source_revision();', - 'if (!is.null(revision)) cat(revision)', + revision + ? `if (sess_has_source_revision(${rStringLiteral(revision)}${libraryArgument})) cat(${rStringLiteral(revision)})` + : `revision <- sess_installed_source_revision(${library ? `lib.loc = ${rStringLiteral(library)}` : ''}); if (!is.null(revision)) cat(revision)`, '})' ].join(' '); const result = await executeRCommand(command, cwd); return result || undefined; } +export async function getInstalledSessSourceRevision(cwd?: string | URL, expectedRevision?: string, library?: string): Promise { + return querySessSource(expectedRevision, library, cwd); +} + +export interface SessRuntimeIdentity { + platform: string; + version: string; +} + +export async function getSessRuntimeIdentity(cwd?: string | URL): Promise { + const identity = await executeRCommand("cat(paste(R.version$platform, paste(R.version$major, strsplit(R.version$minor, '.', fixed=TRUE)[[1]][1], sep='.'), sep='|'))", cwd); + const [platform, version, ...rest] = (identity ?? '').split('|'); + if (!platform || !version || rest.length || !/^[A-Za-z0-9_.-]+$/.test(platform) || !/^\d+\.\d+$/.test(version)) { + return undefined; + } + return { platform, version }; +} + +export function getSessManagedLibrary(root: string, identity: SessRuntimeIdentity, revision: string): string { + const revisionHash = revision.replace(/^git-tree:/, ''); + return path.join(root, identity.platform, identity.version, revisionHash, 'library'); +} + export function getRLibPaths(): string | undefined { return config().get('libPaths') ?.map(value => substituteVariables(value)) @@ -585,7 +612,8 @@ export async function promptToInstallSessPackage( _config = config, _getInstalledSessSourceRevision = getInstalledSessSourceRevision, _readFileSyncSafe = readFileSyncSafe, - _executeAsTask: (name: string, process: string, args: string[], asProcess: true, cwd?: string) => Promise = executeAsTask + _executeAsTask: (name: string, process: string, args: string[], asProcess: true, cwd?: string) => Promise = executeAsTask, + _getRuntimeIdentity = getSessRuntimeIdentity ): Promise { const resource = resourceFromCwd(cwd); const workingDirectory = cwd instanceof vscode.Uri ? cwd.fsPath : cwd; @@ -604,20 +632,34 @@ export async function promptToInstallSessPackage( return false; } - const installedRevision = await _getInstalledSessSourceRevision(workingDirectory); - if (installedRevision === bundledRevision) { + if (await _getInstalledSessSourceRevision(workingDirectory, bundledRevision) === bundledRevision) { + return true; + } + + const identity = await _getRuntimeIdentity(workingDirectory); + if (!identity) { + void vscode.window.showErrorMessage('Could not determine the R platform and version. The session watcher will not start.'); + return true; + } + const managedRoot = path.join(extensionContext.globalStorageUri.fsPath, 'sess'); + const managedLibrary = getSessManagedLibrary(managedRoot, identity, bundledRevision); + if (await _getInstalledSessSourceRevision(workingDirectory, bundledRevision, managedLibrary) === bundledRevision) { return true; } const installSessScript = extensionContext.asAbsolutePath(path.join('R', 'install_sess.R')).replace(/\\/g, '/'); + const installedRevision = await _getInstalledSessSourceRevision(workingDirectory); 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?'; + ? 'The installed sess does not match this build of vscode-R. Install the bundled copy in a vscode-R-managed library? Your existing installation will not be modified.' + : 'vscode-R needs its bundled sess package for the session watcher. Install it in a vscode-R-managed library? Your existing sess installations will not be modified.'; const select = await vscode.window.showWarningMessage(installMsg, 'Yes', 'No'); if (select !== 'Yes') { return true; } + if (!_config().get('sessionWatcher')) { + return true; + } const rPath = await getRpath(false, resource); if (!rPath) { return false; @@ -629,11 +671,11 @@ export async function promptToInstallSessPackage( '--no-save', '--no-restore', `--file=${installSessScript}`, - '--args', sessPath, repo + '--args', sessPath, repo, managedLibrary ]; try { await _executeAsTask('Install "sess" package', rPath, args, true, workingDirectory); - if (await _getInstalledSessSourceRevision(workingDirectory) !== bundledRevision) { + if (await _getInstalledSessSourceRevision(workingDirectory, bundledRevision, managedLibrary) !== 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; } From 771ff7c7aa833f03df4b273eb6a433ec28f83794 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 01:39:19 +0000 Subject: [PATCH 02/21] fix: prepare sess in the console R process Share target-R preparation between terminal startup and manual attach so console wrappers can use a different R from the background executable. Normalize library path assertions and correct R lint formatting. --- CONTRIBUTING.md | 6 +- R/attach_sess.R | 71 +++-- R/profile.R | 46 +-- R/sess_source.R | 16 +- R/tests/attach_sess.R | 294 ++++++++++++++---- R/tests/sess_source.R | 30 +- sess/README.md | 8 +- src/rTerminal.ts | 11 +- src/session.ts | 6 +- src/test/integration/sessInstall.test.ts | 102 ------ src/test/suite/sessInstall.test.ts | 261 ---------------- src/test/suite/session.test.ts | 4 - .../suite/sessionTerminalLifecycle.test.ts | 2 +- src/test/suite/terminal.test.ts | 95 +++--- src/util.ts | 126 -------- 15 files changed, 404 insertions(+), 674 deletions(-) delete mode 100644 src/test/integration/sessInstall.test.ts delete mode 100644 src/test/suite/sessInstall.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0103479d..2ab3958c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,8 +45,10 @@ the bundled snapshot; missing Imports come from the configured repository. 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. R Interactive prepares its separate runtime as part of -starting an Interactive session. Run the source/bootstrap checks with +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`. diff --git a/R/attach_sess.R b/R/attach_sess.R index 3bc43b81f..42f62be91 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -1,11 +1,13 @@ -# Manual attach setup. Installation requires a single-use grant from the -# extension process; this helper never changes .libPaths(). -vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, - source_helper, installer_helper, plot_backend, - timeout_seconds = 180) { +# 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) { 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.") + if (is.null(expected)) { + stop("Bundled sess has no valid source revision.") + } runtime <- sess_runtime_identity() managed_library <- sess_managed_library(managed_root, expected) @@ -28,15 +30,19 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, existing <- any(vapply(.libPaths(), function(library) { file.exists(file.path(library, "sess", "DESCRIPTION")) }, FALSE)) - reason <- if (existing) "mismatch" else "missing" + 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.") } - id_parts <- vapply(seq_len(2L), function(unused) { - gsub("[^A-Za-z0-9_-]", "", - sub("^request-", "", basename(tempfile(pattern = "request-", tmpdir = consent_dir)))) - }, "") - id <- paste0(id_parts, collapse = "") + 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.") } @@ -47,7 +53,9 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, 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 (.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.") } @@ -56,19 +64,31 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, 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) + 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(invisible(FALSE)) + return(NULL) } configured <- getOption("repos") - repo <- if ("CRAN" %in% names(configured)) configured[["CRAN"]] else - if (length(configured)) configured[[1L]] else "https://cloud.r-project.org" + 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" } @@ -84,6 +104,17 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, } } + ns +} + +vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, + source_helper, installer_helper, plot_backend, + timeout_seconds = 180) { + ns <- vscode_r_prepare_sess( + pkg_path, managed_root, consent_dir, source_helper, installer_helper, timeout_seconds) + if (is.null(ns)) { + return(invisible(FALSE)) + } get("connect", envir = ns, inherits = FALSE)(endpoint = endpoint, plot_backend = plot_backend) invisible(TRUE) } diff --git a/R/profile.R b/R/profile.R index 4f0e1ad35..364c5c7c2 100644 --- a/R/profile.R +++ b/R/profile.R @@ -27,43 +27,31 @@ local({ local({ initialize_sess <- function() { bundled_path <- Sys.getenv("VSCODE_R_SESS_PKG_PATH", unset = "") - if (!nzchar(bundled_path)) return(invisible(NULL)) + if (!nzchar(bundled_path)) { + return(invisible(NULL)) + } helper <- Sys.getenv("VSCODE_R_SESS_SOURCE_HELPER", unset = "") - if (!nzchar(helper) || !file.exists(helper)) { - message("vscode-R could not locate its sess source verifier; the session watcher was not started.") + 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(NULL)) } - source(helper, local = TRUE) - expected <- sess_source_revision(file.path(bundled_path, "DESCRIPTION")) - if (is.null(expected)) { - message("Bundled sess has no valid source revision; the session watcher was not started.") + 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(NULL)) } - loaded_revision <- sess_loaded_source_revision() - if ("sess" %in% loadedNamespaces()) { - if (!identical(loaded_revision, expected)) { - stop("A different sess namespace is already loaded. Restart R so vscode-R can attach the session watcher.") - } - library <- dirname(getNamespaceInfo(asNamespace("sess"), "path")) - } else { - library <- sess_find_source_library(expected, .libPaths()) - if (is.null(library)) { - managed_root <- Sys.getenv("VSCODE_R_SESS_ROOT", unset = "") - managed_library <- if (nzchar(managed_root)) sess_managed_library(managed_root, expected) else "" - if (!nzchar(managed_library) || - !identical(sess_installed_source_revision(managed_library), expected)) { - message("The bundled sess required by vscode-R is unavailable. The R session started without the session watcher.") - return(invisible(NULL)) - } - library <- managed_library - } - sess_load_namespace(library, expected) - } - plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") - getExportedValue("sess", "connect")( + get("connect", envir = ns, inherits = FALSE)( use_rstudioapi = as.logical(Sys.getenv("SESS_RSTUDIOAPI", "TRUE")), plot_backend = plot_backend ) diff --git a/R/sess_source.R b/R/sess_source.R index efdf4bac3..499470d16 100644 --- a/R/sess_source.R +++ b/R/sess_source.R @@ -34,7 +34,9 @@ sess_installed_source_revision <- function(lib.loc = .libPaths()) { 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) + if (!is.null(revision)) { + return(revision) + } } } NULL @@ -56,7 +58,9 @@ sess_find_source_library <- function(revision, lib.loc = .libPaths()) { } sess_loaded_source_revision <- function() { - if (!("sess" %in% loadedNamespaces())) return(NULL) + if (!("sess" %in% loadedNamespaces())) { + return(NULL) + } path <- getNamespaceInfo(asNamespace("sess"), "path") sess_source_revision(file.path(path, "DESCRIPTION")) } @@ -67,10 +71,14 @@ sess_load_namespace <- function(library, revision, normal_libraries = .libPaths( description <- read.dcf(file.path(library, "sess", "DESCRIPTION")) imports <- if ("Imports" %in% colnames(description)) { trimws(gsub("\\s*\\(.*\\)", "", unlist(strsplit(description[1L, "Imports"], ",")))) - } else character() + } 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) + 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) } diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index 0023439d7..c31e94d81 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -1,4 +1,5 @@ -# Base-R integration coverage for manual attach consent; packages are local fixtures. +# 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) @@ -14,111 +15,298 @@ writeLines(c( paste0("Config/vscode-R/source-revision: ", revision) ), file.path(pkg, "DESCRIPTION")) writeLines(c( - "connect <- function(endpoint, plot_backend) list(endpoint = endpoint, plot_backend = plot_backend)", + "connect <- function(endpoint=NULL, plot_backend=NULL, ...) list(", + " endpoint=endpoint, plot_backend=plot_backend, ...)", "notify_client <- function(...) NULL", "request_client <- function(...) NULL" ), file.path(pkg, "R", "api.R")) -writeLines(c("export(connect)", "export(notify_client)", "export(request_client)"), file.path(pkg, "NAMESPACE")) +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]]", + "mode <- args[[1L]]; root <- args[[2L]]; pkg <- args[[3L]]", + "revision <- args[[4L]]; mismatch <- args[[5L]]", "source(file.path(getwd(), 'R', 'sess_source.R'))", "source(file.path(getwd(), 'R', 'attach_sess.R'))", - "normal <- file.path(root, 'normal library'); dir.create(normal, recursive=TRUE)", - "consent <- file.path(root, 'consent'); dir.create(consent, recursive=TRUE, showWarnings=FALSE)", + "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')", + "project_library <- file.path(root, 'project library')", "Sys.setenv(R_LIBS_USER=file.path(root, 'empty user library'), R_LIBS_SITE=.Library)", - "original <- .Library; .libPaths(c(normal, original)); ordinary <- .libPaths()", - "if (mode %in% c('exact', 'prepared')) {", - " library <- if (mode == 'exact') normal else sess_managed_library(managed_root, revision)", - " dir.create(library, recursive=TRUE, showWarnings=FALSE)", - " utils::install.packages(pkg, repos=NULL, type='source', lib=library, quiet=TRUE)", + "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='/'))", + "attach_sess <- function(timeout=180) {", + " vscode_r_attach_sess('endpoint', pkg, managed_root, consent,", + " file.path(getwd(), 'R', 'sess_source.R'),", + " file.path(getwd(), 'R', 'sess-package-install.R'),", + " 'standard', timeout_seconds=timeout)", "}", - "if (mode == 'twice') {", + "if (mode %in% c('exact', 'prepared')) {", + " 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)", + " 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')))", + " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + "} else if (mode == 'twice') {", + " for (iteration in 1:2) {", + " 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(!dir.exists(file.path(normal, 'sess')))", + " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", + "} else if (mode == 'mismatch') {", " utils::install.packages(pkg, repos=NULL, type='source', lib=normal, quiet=TRUE)", - " desc <- read.dcf(file.path(normal, 'sess', 'DESCRIPTION'))", + " desc_path <- file.path(normal, 'sess', 'DESCRIPTION')", + " desc <- read.dcf(desc_path)", " desc[1L, 'Config/vscode-R/source-revision'] <- mismatch", - " write.dcf(desc, file.path(normal, 'sess', 'DESCRIPTION'))", - " stopifnot(identical(sess_source_revision(file.path(normal, 'sess', 'DESCRIPTION')), mismatch))", - " for (i in 1:2) { set.seed(8841); seed <- .Random.seed; result <- vscode_r_attach_sess('endpoint', pkg, managed_root, consent, file.path(getwd(), 'R', 'sess_source.R'), file.path(getwd(), 'R', 'sess-package-install.R'), 'standard', timeout_seconds=8); if (!identical(result, FALSE)) stop(paste('unexpected result', mode, i, paste(capture.output(str(result)), collapse=' '), 'source', sess_installed_source_revision(ordinary))); stopifnot(identical(seed, .Random.seed), identical(.libPaths(), ordinary)) }", - " stopifnot(identical(read.dcf(file.path(normal, 'sess', 'DESCRIPTION')), desc), !dir.exists(sess_managed_library(managed_root, revision)))", - "} else if (mode %in% c('exact', 'prepared')) {", - " expected <- if (mode == 'exact') normal else sess_managed_library(managed_root, revision)", - " result <- vscode_r_attach_sess('endpoint', pkg, managed_root, consent, file.path(getwd(), 'R', 'sess_source.R'), file.path(getwd(), 'R', 'sess-package-install.R'), 'standard', timeout_seconds=0)", - " stopifnot(identical(result, TRUE), identical(.libPaths(), ordinary), identical(sess_loaded_source_revision(), revision))", - " stopifnot(identical(normalizePath(getNamespaceInfo(asNamespace('sess'), 'path')), normalizePath(file.path(expected, 'sess'))))", + " 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 %in% c('profile_exact', 'profile_prepared')) {", + " expected <- if (mode == 'profile_exact') normal else", + " sess_managed_library(managed_root, revision)", + " 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')))", + " baseline <- readRDS(file.path(root, 'profile-libraries.rds'))", + " if (!same_paths(.libPaths(), baseline)) stop(paste('library paths differ:',", + " paste(normalizePath(baseline), collapse=';'), 'vs',", + " paste(normalizePath(.libPaths()), collapse=';')))", + " stopifnot(file.exists(file.path(root, 'user-profile-ran')))", + " stopifnot(any(normalizePath(.libPaths()) == normalizePath(project_library)))", + " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + "} 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))", + " baseline <- readRDS(file.path(root, 'profile-libraries.rds'))", + " if (!same_paths(.libPaths(), baseline)) stop(paste('library paths differ:',", + " paste(normalizePath(baseline), collapse=';'), 'vs',", + " paste(normalizePath(.libPaths()), collapse=';')))", + " stopifnot(file.exists(file.path(root, 'user-profile-ran')))", + " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", + " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')))", " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + "} 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))", + " baseline <- readRDS(file.path(root, 'profile-libraries.rds'))", + " if (!same_paths(.libPaths(), baseline)) stop(paste('library paths differ:',", + " paste(normalizePath(baseline), collapse=';'), 'vs',", + " paste(normalizePath(.libPaths()), collapse=';')))", + " stopifnot(file.exists(file.path(root, 'user-profile-ran')))", + " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", + " stopifnot(same_paths(actual, file.path(library, 'sess')))", + " stopifnot(file.exists(file.path(library, 'sess', 'DESCRIPTION')))", + " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')))", "} else if (mode == 'timeout') {", " set.seed(8841); seed <- .Random.seed", - " result <- vscode_r_attach_sess('endpoint', pkg, managed_root, consent, file.path(getwd(), 'R', 'sess_source.R'), file.path(getwd(), 'R', 'sess-package-install.R'), 'standard', timeout_seconds=0)", - " stopifnot(identical(result, FALSE), identical(seed, .Random.seed), identical(.libPaths(), ordinary))", - " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)), length(list.files(consent, pattern='\\\\.request$')) == 0L)", + " result <- attach_sess(timeout=0)", + " stopifnot(identical(result, FALSE), identical(seed, .Random.seed))", + " stopifnot(same_paths(.libPaths(), ordinary))", + " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", + " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", "} else if (mode == 'approve') {", - " wrong_library <- file.path(managed_root, 'fixture-other-platform', '99.99', strrep('a', 40), 'library')", + " wrong_library <- file.path(managed_root, 'fixture-other-platform',", + " '99.99', strrep('a', 40), 'library')", " dir.create(wrong_library, recursive=TRUE, showWarnings=FALSE)", - " utils::install.packages(pkg, repos=NULL, type='source', lib=wrong_library, quiet=TRUE)", + " utils::install.packages(pkg, repos=NULL, type='source',", + " lib=wrong_library, quiet=TRUE)", " set.seed(8841); seed <- .Random.seed", - " result <- vscode_r_attach_sess('endpoint', pkg, managed_root, consent, file.path(getwd(), 'R', 'sess_source.R'), file.path(getwd(), 'R', 'sess-package-install.R'), 'standard', timeout_seconds=20)", + " result <- attach_sess(timeout=20)", " library <- sess_managed_library(managed_root, revision)", - " stopifnot(identical(result, TRUE), identical(.libPaths(), ordinary), identical(sess_loaded_source_revision(), revision))", - " stopifnot(identical(normalizePath(getNamespaceInfo(asNamespace('sess'), 'path')), normalizePath(file.path(library, 'sess'))))", - " stopifnot(file.exists(file.path(library, 'sess', 'DESCRIPTION')), !dir.exists(file.path(normal, 'sess')))", - " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')), identical(sess_source_revision(file.path(wrong_library, 'sess', 'DESCRIPTION')), revision))", + " stopifnot(identical(result, TRUE), identical(seed, .Random.seed))", + " stopifnot(same_paths(.libPaths(), ordinary))", + " stopifnot(identical(sess_loaded_source_revision(), revision))", + " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", + " stopifnot(same_paths(actual, file.path(library, 'sess')))", + " stopifnot(file.exists(file.path(library, 'sess', 'DESCRIPTION')))", + " stopifnot(!dir.exists(file.path(normal, 'sess')))", + " wrong_desc <- file.path(wrong_library, 'sess', 'DESCRIPTION')", + " stopifnot(file.exists(wrong_desc))", + " stopifnot(identical(sess_source_revision(wrong_desc), revision))", "} else stop('unknown case')", "cat('manual attach case passed:', mode, '\\n')" ), runner) agent <- file.path(root, "consent-agent.R") writeLines(c( - "args <- commandArgs(TRUE); directory <- args[[1L]]; count <- as.integer(args[[2L]]); answer <- args[[3L]]; log <- args[[4L]]", - "ids <- character(); deadline <- Sys.time() + 30", + "args <- commandArgs(TRUE)", + "directory <- args[[1L]]; count <- as.integer(args[[2L]])", + "answer <- args[[3L]]; log <- args[[4L]]; identity_log <- args[[5L]]", + "status_log <- args[[6L]]", + "tryCatch({", + "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 consent request')", + " if (length(lines) != 5L || lines[[1L]] != 'vscode-r-sess-consent-v1') stop('bad request')", " id <- lines[[2L]]; if (id %in% ids) next", " ids <- c(ids, id); temporary <- tempfile(tmpdir=directory)", - " writeLines(answer, temporary); if (!file.rename(temporary, file.path(directory, paste0(id, '.response')))) stop('response rename failed')", + " identities <- c(identities, lines[[4L]])", + " 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(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 = "\"") run_case <- function(mode) { case_root <- file.path(root, mode) dir.create(case_root) consent <- file.path(case_root, "consent") dir.create(consent) - if (mode %in% c("approve", "twice")) { + profile_modes <- c( + "profile_exact", "profile_prepared", "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") + 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 <- switch( + mode, + profile_exact = normal, + profile_prepared = managed_library, + 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 %in% c("profile_exact", "profile_prepared")) { + 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) + 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("approve", "twice", "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 == "twice") 2L else 1L - answer <- if (mode == "twice") "decline" else "approve" - system2(r_binary, shQuote(c("--vanilla", "--slave", paste0("--file=", agent), "--args", consent, - as.character(count), answer, log)), - wait = FALSE, stdout = FALSE, stderr = FALSE) + approve_modes <- c("approve", "profile_approve") + answer <- if (mode %in% approve_modes) "approve" else "decline" + system2(r_binary, shQuote(c( + "--vanilla", "--slave", paste0("--file=", agent), "--args", + consent, as.character(count), answer, log, identity_log, status_log + )), 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") } output <- suppressWarnings(system2(r_binary, shQuote(c( - "--vanilla", "--slave", "--no-save", "--no-restore", paste0("--file=", runner), "--args", - mode, case_root, pkg, revision, mismatch + 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% c("approve", "twice")) { - log_path <- file.path(case_root, "consent-ids") + 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(log_path) && Sys.time() < deadline) Sys.sleep(0.01) - stopifnot(file.exists(log_path)) - ids <- readLines(log_path, warn=FALSE) - stopifnot(length(ids) == if (mode == "twice") 2L else 1L, - !anyDuplicated(ids), all(grepl("^[A-Za-z0-9_-]{16,64}$", ids))) + 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 == "twice") 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("twice", "exact", "prepared", "timeout", "approve")) run_case(mode) +for (mode in c( + "twice", "mismatch", "exact", "prepared", "timeout", "approve", + "profile_exact", "profile_prepared", "profile_decline", "profile_approve" +)) run_case(mode) 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 b3b87fadd..944bf42ad 100644 --- a/R/tests/sess_source.R +++ b/R/tests/sess_source.R @@ -1,5 +1,6 @@ # 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)) @@ -31,8 +32,10 @@ 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(sess_find_source_library(stable), user_library)) -stopifnot(identical(.libPaths()[1:2], c(project_library, user_library))) +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. @@ -64,6 +67,7 @@ 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))) # Install a dependency-free fixture into the managed library, then verify that # explicit namespace loading selects it without changing ordinary .libPaths(). @@ -140,7 +144,7 @@ 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(.libPaths(), ordinary_before)) +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. @@ -148,6 +152,8 @@ 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])", @@ -157,11 +163,17 @@ writeLines(c( "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))", - "Sys.setenv(VSCODE_R_SESS_PKG_PATH=args[6], VSCODE_R_SESS_SOURCE_HELPER=args[1], VSCODE_R_SESS_ROOT=args[7])", + 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()", - "withCallingHandlers(source(args[8]), message=function(m) { messages <<- c(messages, conditionMessage(m)); invokeRestart('muffleMessage') })", - "stopifnot(any(grepl('different sess namespace is already loaded', messages)), identical(sess_loaded_source_revision(), mismatch))", - "stopifnot(identical(.libPaths(), normal))" + 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( @@ -169,7 +181,7 @@ child_output <- suppressWarnings(system2(r_binary, shQuote(c( 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) + 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")) @@ -185,7 +197,7 @@ 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(.libPaths(), ordinary_before)) +stopifnot(identical(normalized_paths(.libPaths()), normalized_paths(ordinary_before))) Sys.unsetenv(c("VSCODE_R_SESS_PKG_PATH", "VSCODE_R_SESS_REPO", "VSCODE_R_SESS_LIBRARY", "VSCODE_R_SESS_INTERACTIVE")) diff --git a/sess/README.md b/sess/README.md index 2b21dda6d..ae3edf102 100644 --- a/sess/README.md +++ b/sess/README.md @@ -27,9 +27,11 @@ socket on macOS/Linux, named pipe on Windows) using > 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. Manual attach also -> asks before installation, using the R executable and libraries of the attached -> session. If a different `sess` is already loaded, restart R before attaching. +> 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 diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 6181330ce..df79660db 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -15,6 +15,7 @@ import { createSessionDiscoveryFile, deferWorkspaceRefresh, getGlobalPipePath, + getSessConsentDirectory, isTerminalClosed, removeTerminalDiscoveryFile, updateTerminalSessionDiscoveryFile, @@ -46,7 +47,7 @@ async function prepareTerminalForInput(terminal: vscode.Terminal): Promise !isTerminalClosed(terminal) && !terminal.exitStatus); startup.ready = 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; })(); @@ -255,6 +256,7 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise('sessionWatcher')) { const pipePath = await getGlobalPipePath(); + const consentDirectory = await getSessConsentDirectory(); const discoveryFile = await createSessionDiscoveryFile(pipePath); const backend = resolveBackend(); termOptions.env = { @@ -263,6 +265,9 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise { } } +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 consentDirectory = await ensureAttachSessConsentService(); + const consentDirectory = await getSessConsentDirectory(); const scriptPath = getAttachSessionScriptPath(pipePath); await fsp.mkdir(path.dirname(scriptPath), { recursive: true }); await fsp.writeFile(scriptPath, buildAttachSessionScript(pipePath, sessPath, consentDirectory), { encoding: 'utf-8', mode: 0o600 }); diff --git a/src/test/integration/sessInstall.test.ts b/src/test/integration/sessInstall.test.ts deleted file mode 100644 index 3581c037f..000000000 --- a/src/test/integration/sessInstall.test.ts +++ /dev/null @@ -1,102 +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 { extensionContext } from '../../extension'; -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; - let managedLibrary: 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); - const identity = await util.getSessRuntimeIdentity(projectA); - assert.ok(identity); - managedLibrary = util.getSessManagedLibrary( - path.join(extensionContext.globalStorageUri.fsPath, 'sess'), - identity!, bundledRevision); - 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(managedLibrary, { recursive: true, force: true }); - await fsp.rm(extensionRoot, { recursive: true, force: true }); - }); - - for (const asUri of [false, true]) { - test(`installs into the vscode-R library and preserves project libraries (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), oldRevision); - 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 util.getInstalledSessSourceRevision(projectB, bundledRevision, managedLibrary), bundledRevision); - assert.strictEqual(await pathExists(path.join(managedLibrary, 'sess', 'Meta', 'package.rds')), true); - assert.strictEqual(await pathExists(path.join(projectB, 'library', 'sess', 'Meta')), false); - assert.strictEqual(await pathExists(path.join(projectA, 'library', 'sess', 'Meta')), false); - }).timeout(120000); - } -}); diff --git a/src/test/suite/sessInstall.test.ts b/src/test/suite/sessInstall.test.ts deleted file mode 100644 index 40d81bd44..000000000 --- a/src/test/suite/sessInstall.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as sinon from 'sinon'; -import * as path from 'path'; -import * as os from 'os'; -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 identity: util.SessRuntimeIdentity = { platform: 'x86_64-pc-linux-gnu', version: '4.4' }; -const identityProvider = () => Promise.resolve(identity); -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(); - }); - - async function enableSessionWatcher(): Promise { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', true, vscode.ConfigurationTarget.Global); - } - - function bundledDescription(revision = stableRevision) { - return sandbox.stub(util, 'readFileSyncSafe').returns(description('0.1.0', revision)); - } - - function taskStub(): sinon.SinonStub<[string, string, string[], true, string?], Promise> { - return sinon.stub<[string, string, string[], true, string?], Promise>().resolves(); - } - - test('does nothing when sessionWatcher is disabled', async () => { - await vscode.workspace.getConfiguration('r').update('sessionWatcher', false, vscode.ConfigurationTarget.Global); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(undefined); - const read = sandbox.stub(util, 'readFileSyncSafe'); - const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - const task = taskStub(); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); - assert.strictEqual(installed.called, false); - assert.strictEqual(read.called, false); - assert.strictEqual(prompt.called, false); - assert.strictEqual(task.called, false); - }); - - test('uses an exact source revision from a normal library without prompting or installing', async () => { - await enableSessionWatcher(); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision').resolves(stableRevision); - const read = bundledDescription(); - const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - const task = taskStub(); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); - assert.strictEqual(installed.calledOnce, true); - assert.deepStrictEqual(installed.firstCall.args, [undefined, stableRevision]); - assert.strictEqual(prompt.called, false); - assert.strictEqual(task.called, false); - }); - - test('uses an exact source revision already prepared in the managed library', async () => { - await enableSessionWatcher(); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); - installed.onFirstCall().resolves(undefined); - installed.onSecondCall().resolves(stableRevision); - const read = bundledDescription(); - const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - const task = taskStub(); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); - assert.strictEqual(installed.calledTwice, true); - assert.deepStrictEqual(installed.firstCall.args, [undefined, stableRevision]); - assert.deepStrictEqual(installed.secondCall.args.slice(0, 2), [undefined, stableRevision]); - assert.strictEqual(installed.secondCall.args[2], util.getSessManagedLibrary( - path.join(os.tmpdir(), 'vscode-r-test-global-storage', String(process.pid), 'sess'), identity, stableRevision)); - assert.strictEqual(prompt.called, false); - assert.strictEqual(task.called, false); - }); - - for (const answer of [undefined, 'No' as unknown as vscode.MessageItem]) { - test(`declining installation (${answer === undefined ? 'dismiss' : 'No'}) leaves libraries untouched`, async () => { - await enableSessionWatcher(); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); - installed.onFirstCall().resolves(undefined); // No exact revision in normal libraries. - installed.onSecondCall().resolves(undefined); // No prepared managed copy. - installed.onThirdCall().resolves(preReleaseRevision); // Existing, mismatched sess remains intact. - const read = bundledDescription(); - const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(answer); - const task = taskStub(); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); - assert.strictEqual(prompt.calledOnce, true); - assert.match(prompt.firstCall.args[0], /vscode-R-managed library/); - assert.match(prompt.firstCall.args[0], /existing installation will not be modified|existing sess installations will not be modified/); - assert.strictEqual(task.called, false); - assert.strictEqual(installed.callCount, 3); - }); - } - - test('asks before installing a missing sess and targets only the managed library', async () => { - await enableSessionWatcher(); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); - installed.onFirstCall().resolves(undefined); // Normal libraries have no exact revision. - installed.onSecondCall().resolves(undefined); // Managed library is not prepared yet. - installed.onThirdCall().resolves(undefined); // No sess exists in normal libraries. - installed.onCall(3).resolves(stableRevision); // Verify the installed managed copy by exact revision. - const read = bundledDescription(); - const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves('Yes' as unknown as vscode.MessageItem); - const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const task = taskStub(); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); - assert.strictEqual(prompt.calledOnce, true); - assert.match(prompt.firstCall.args[0], /vscode-R needs its bundled sess package/); - assert.match(prompt.firstCall.args[0], /vscode-R-managed library/); - assert.match(prompt.firstCall.args[0], /existing sess installations will not be modified/); - assert.strictEqual(task.calledOnce, true); - const args = task.firstCall.args; - assert.strictEqual(args[0], 'Install "sess" package'); - assert.ok(args[1]); - assert.ok(args[2].includes(`--file=${path.join(extension_root, 'R', 'install_sess.R').replace(/\\/g, '/')}`)); - const managedLibrary = installed.getCall(1).args[2]; - assert.ok(managedLibrary); - assert.strictEqual(args[2].at(-1), managedLibrary); - assert.ok(managedLibrary.includes(identity.platform)); - assert.ok(managedLibrary.includes(identity.version)); - assert.ok(managedLibrary.includes(stableRevision.slice('git-tree:'.length))); - assert.deepStrictEqual(installed.getCall(3).args, [undefined, stableRevision, managedLibrary]); - assert.strictEqual(errors.called, false); - }); - - for (const [installedRevision, bundledRevision] of [ - [preReleaseRevision, stableRevision], - [stableRevision, preReleaseRevision] - ]) { - test(`prompts for a ${installedRevision === stableRevision ? 'stable to pre-release' : 'pre-release to stable'} revision change`, async () => { - await enableSessionWatcher(); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); - installed.onFirstCall().resolves(undefined); - installed.onSecondCall().resolves(undefined); - installed.onThirdCall().resolves(installedRevision); - const read = bundledDescription(bundledRevision); - const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - const task = taskStub(); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), true); - assert.strictEqual(prompt.calledOnce, true); - assert.match(prompt.firstCall.args[0], /does not match this build/); - assert.match(prompt.firstCall.args[0], /vscode-R-managed library/); - assert.strictEqual(task.called, false); - }); - } - - test('invalid bundled metadata reports a build problem without offering installation', async () => { - await enableSessionWatcher(); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); - const read = sandbox.stub(util, 'readFileSyncSafe').returns('Package: sess\nVersion: 0.1.0\n'); - const prompt = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); - const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const task = taskStub(); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), false); - assert.strictEqual(installed.called, false); - assert.match(errors.firstCall.args[0], /Rebuild or reinstall/); - assert.strictEqual(prompt.called, false); - assert.strictEqual(task.called, false); - }); - - test('waits for installation and verifies the exact managed source revision', async () => { - await enableSessionWatcher(); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); - installed.onFirstCall().resolves(undefined); - installed.onSecondCall().resolves(undefined); - installed.onThirdCall().resolves(preReleaseRevision); - installed.onCall(3).resolves(preReleaseRevision); - const read = bundledDescription(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, string?], Promise>().callsFake(() => { - taskStarted(); - return taskFinished; - }); - - let completed = false; - const setup = util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider).then(result => { - completed = true; - return result; - }); - await started; - assert.strictEqual(completed, false); - assert.strictEqual(installed.calledThrice, true); - finishTask(); - assert.strictEqual(await setup, true); - assert.strictEqual(installed.callCount, 4); - assert.strictEqual(installed.getCall(3).args[1], preReleaseRevision); - assert.strictEqual(installed.getCall(3).args[2], task.firstCall.args[2].at(-1)); - assert.strictEqual(errors.called, false); - }); - - test('failed installation and ineffective managed installs prevent startup', async () => { - await enableSessionWatcher(); - const installed = sandbox.stub(util, 'getInstalledSessSourceRevision'); - installed.onFirstCall().resolves(undefined); - installed.onSecondCall().resolves(undefined); - installed.onThirdCall().resolves(preReleaseRevision); - installed.onCall(3).resolves(undefined); // Task ended, but exact managed revision is still absent. - const read = bundledDescription(); - sandbox.stub(vscode.window, 'showWarningMessage').resolves('Yes' as unknown as vscode.MessageItem); - const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const task = taskStub(); - - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, task, identityProvider), false); - assert.match(errors.firstCall.args[0], /not installed successfully/); - assert.deepStrictEqual(installed.getCall(3).args.slice(0, 2), [undefined, stableRevision]); - - const failingTask = taskStub().rejects(new Error('Could not start installation task')); - installed.onCall(4).resolves(undefined); - installed.onCall(5).resolves(undefined); - installed.onCall(6).resolves(preReleaseRevision); - assert.strictEqual(await util.promptToInstallSessPackage(undefined, undefined, installed, read, failingTask, identityProvider), false); - assert.match(errors.secondCall.args[0], /Could not start installation task/); - }).timeout(15000); - - test('managed library path layout matches the platform, R version, and revision on common platforms', () => { - const managedRoot = '/managed root/sess'; - const revisionHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - const cases = [ - [{ platform: 'x86_64-w64-mingw32', version: '4.5' }, `x86_64-w64-mingw32/4.5/${revisionHash}/library`], - [{ platform: 'aarch64-apple-darwin20', version: '4.5' }, `aarch64-apple-darwin20/4.5/${revisionHash}/library`], - [{ platform: 'x86_64-pc-linux-gnu', version: '4.5' }, `x86_64-pc-linux-gnu/4.5/${revisionHash}/library`], - ] as const; - const normalize = (value: string) => value.replace(/\\/g, '/'); - - for (const [runtimeIdentity, expectedSuffix] of cases) { - const library = util.getSessManagedLibrary(managedRoot, runtimeIdentity, stableRevision); - assert.strictEqual(normalize(library), `${normalize(managedRoot)}/${expectedSuffix}`); - } - }); - - 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 8f05c8e28..5bbfb5b39 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -408,8 +408,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'); @@ -511,8 +509,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); diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index d9147b737..2025bc026 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -154,7 +154,7 @@ suite('Session Terminal Lifecycle', () => { 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, 'getSessConsentDirectory').resolves('/unused-test-consent'); sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); sandbox.stub(session, 'updateTerminalSessionDiscoveryFile').resolves(); const waitUntilReady = session.waitForTerminalReady; diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 15645ade4..80b5e6aa4 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -26,6 +26,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 +60,7 @@ suite('R Terminal', () => { setup(() => { sandbox = sinon.createSandbox(); mockExtensionContext(extension_root, sandbox); + consentDirectoryStub = sandbox.stub(session, 'getSessConsentDirectory').resolves('/unused-test-consent'); }); teardown(() => { @@ -95,7 +97,6 @@ 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'); sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); const sent: string[] = []; @@ -127,6 +128,7 @@ suite('R 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~']); + assert.strictEqual(consentDirectoryStub.called, watcher); await rTerminal.runTextInTerm('fourth'); assert.strictEqual(sent.length, 4); sinon.assert.calledOnce(create); @@ -139,7 +141,6 @@ suite('R 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'); @@ -203,7 +204,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']; @@ -318,7 +318,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 +355,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 +367,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 +378,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 +399,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 +470,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 +516,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,37 +578,59 @@ 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 () => { @@ -631,7 +646,6 @@ suite('R Terminal', () => { }; 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); @@ -655,7 +669,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); sandbox.stub(vscode.window, 'createTerminal').throws(new Error('terminal creation failed')); try { @@ -667,30 +680,6 @@ suite('R Terminal', () => { } }); - 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 }))); - } - }); - test('createRTerm records process IDs for all managed terminals when they resolve out of order', async () => { function deferred() { let resolve!: (value: T) => void; @@ -733,7 +722,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 +781,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 3b0756752..44264be74 100644 --- a/src/util.ts +++ b/src/util.ts @@ -310,49 +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; } -function rStringLiteral(value: string): string { - return `'${value.replace(/\\/g, '/').replace(/'/g, "\\'")}'`; -} - -async function querySessSource(revision: string | undefined, library: string | undefined, cwd?: string | URL): Promise { - const helper = extensionContext.asAbsolutePath(path.join('R', 'sess_source.R')); - const helperLiteral = rStringLiteral(helper); - const libraryArgument = library ? `, lib.loc = ${rStringLiteral(library)}` : ''; - const command = [ - 'local({', - `source(${helperLiteral}, local = TRUE);`, - revision - ? `if (sess_has_source_revision(${rStringLiteral(revision)}${libraryArgument})) cat(${rStringLiteral(revision)})` - : `revision <- sess_installed_source_revision(${library ? `lib.loc = ${rStringLiteral(library)}` : ''}); if (!is.null(revision)) cat(revision)`, - '})' - ].join(' '); - const result = await executeRCommand(command, cwd); - return result || undefined; -} - -export async function getInstalledSessSourceRevision(cwd?: string | URL, expectedRevision?: string, library?: string): Promise { - return querySessSource(expectedRevision, library, cwd); -} - -export interface SessRuntimeIdentity { - platform: string; - version: string; -} - -export async function getSessRuntimeIdentity(cwd?: string | URL): Promise { - const identity = await executeRCommand("cat(paste(R.version$platform, paste(R.version$major, strsplit(R.version$minor, '.', fixed=TRUE)[[1]][1], sep='.'), sep='|'))", cwd); - const [platform, version, ...rest] = (identity ?? '').split('|'); - if (!platform || !version || rest.length || !/^[A-Za-z0-9_.-]+$/.test(platform) || !/^\d+\.\d+$/.test(version)) { - return undefined; - } - return { platform, version }; -} - -export function getSessManagedLibrary(root: string, identity: SessRuntimeIdentity, revision: string): string { - const revisionHash = revision.replace(/^git-tree:/, ''); - return path.join(root, identity.platform, identity.version, revisionHash, 'library'); -} - export function getRLibPaths(): string | undefined { return config().get('libPaths') ?.map(value => substituteVariables(value)) @@ -603,89 +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, - _getRuntimeIdentity = getSessRuntimeIdentity -): 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; - } - - if (await _getInstalledSessSourceRevision(workingDirectory, bundledRevision) === bundledRevision) { - return true; - } - - const identity = await _getRuntimeIdentity(workingDirectory); - if (!identity) { - void vscode.window.showErrorMessage('Could not determine the R platform and version. The session watcher will not start.'); - return true; - } - const managedRoot = path.join(extensionContext.globalStorageUri.fsPath, 'sess'); - const managedLibrary = getSessManagedLibrary(managedRoot, identity, bundledRevision); - if (await _getInstalledSessSourceRevision(workingDirectory, bundledRevision, managedLibrary) === bundledRevision) { - return true; - } - - const installSessScript = extensionContext.asAbsolutePath(path.join('R', 'install_sess.R')).replace(/\\/g, '/'); - const installedRevision = await _getInstalledSessSourceRevision(workingDirectory); - const installMsg = installedRevision - ? 'The installed sess does not match this build of vscode-R. Install the bundled copy in a vscode-R-managed library? Your existing installation will not be modified.' - : 'vscode-R needs its bundled sess package for the session watcher. Install it in a vscode-R-managed library? Your existing sess installations will not be modified.'; - - const select = await vscode.window.showWarningMessage(installMsg, 'Yes', 'No'); - if (select !== 'Yes') { - return true; - } - if (!_config().get('sessionWatcher')) { - 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, managedLibrary - ]; - try { - await _executeAsTask('Install "sess" package', rPath, args, true, workingDirectory); - if (await _getInstalledSessSourceRevision(workingDirectory, bundledRevision, managedLibrary) !== 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. * From 15e630f748ef1816e80ad95e6f7c4f3e80853d6f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 01:44:16 +0000 Subject: [PATCH 03/21] style: align sess setup continuation arguments --- R/attach_sess.R | 8 ++++---- R/profile.R | 2 +- R/tests/attach_sess.R | 31 ++++++++++++++++--------------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/R/attach_sess.R b/R/attach_sess.R index 42f62be91..a779844dd 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -47,7 +47,7 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, stop("Could not create a unique sess installation request.") } request <- paste( - "vscode-r-sess-consent-v1", id, expected, runtime, reason, sep = "\n") + "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) @@ -65,8 +65,8 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, 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()) + readLines(response_path, warn = FALSE, n = 2L), + error = function(e) character()) if (length(lines) == 1L && lines %in% c("approve", "decline")) { response <- lines } else { @@ -111,7 +111,7 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, source_helper, installer_helper, plot_backend, timeout_seconds = 180) { ns <- vscode_r_prepare_sess( - pkg_path, managed_root, consent_dir, source_helper, installer_helper, timeout_seconds) + pkg_path, managed_root, consent_dir, source_helper, installer_helper, timeout_seconds) if (is.null(ns)) { return(invisible(FALSE)) } diff --git a/R/profile.R b/R/profile.R index 364c5c7c2..efdd4542e 100644 --- a/R/profile.R +++ b/R/profile.R @@ -45,7 +45,7 @@ local({ } source(attach_helper, local = TRUE) ns <- vscode_r_prepare_sess( - bundled_path, managed_root, consent_directory, helper, installer_helper) + bundled_path, managed_root, consent_directory, helper, installer_helper) if (is.null(ns)) { return(invisible(NULL)) } diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index c31e94d81..5bf0efb1a 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -192,7 +192,7 @@ run_case <- function(mode) { consent <- file.path(case_root, "consent") dir.create(consent) profile_modes <- c( - "profile_exact", "profile_prepared", "profile_decline", "profile_approve") + "profile_exact", "profile_prepared", "profile_decline", "profile_approve") startup <- mode %in% profile_modes if (startup) { normal <- file.path(case_root, "normal library") @@ -217,10 +217,11 @@ run_case <- function(mode) { ), startup_profile) managed_library <- sess_managed_library(managed_root, revision) install_library <- switch( - mode, - profile_exact = normal, - profile_prepared = managed_library, - file.path(managed_root, "fixture-other-platform", "99.99", strrep("a", 40), "library")) + mode, + profile_exact = normal, + profile_prepared = managed_library, + 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") @@ -236,16 +237,16 @@ run_case <- function(mode) { 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) + 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) profile_keys <- names(profile_environment) previous_environment <- Sys.getenv(profile_keys, unset = NA_character_) on.exit({ From 5b6a415bd2decaff036e7347fd7fc9e5ad7b46d0 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 01:50:56 +0000 Subject: [PATCH 04/21] feat: allow suppressing sess prompts for the current revision --- src/sessConsent.ts | 36 ++++++++-- src/session.ts | 24 +++++-- src/test/common/mockvscode.ts | 2 +- src/test/node/sessConsent.test.ts | 114 ++++++++++++++++++++++++------ 4 files changed, 144 insertions(+), 32 deletions(-) diff --git a/src/sessConsent.ts b/src/sessConsent.ts index c40d91dc3..d3e557ef3 100644 --- a/src/sessConsent.ts +++ b/src/sessConsent.ts @@ -12,11 +12,15 @@ export interface SessConsentRequest { reason: SessConsentReason; } +export type SessConsentChoice = 'install' | 'notNow' | 'dontAskAgain' | 'dismiss'; + export interface SessConsentServiceOptions { directory: string; expectedRevision: string; isEnabled: () => boolean; - prompt: (request: SessConsentRequest) => Promise; + getDismissedRevision: () => string | undefined; + rememberDismissedRevision: (revision: string) => Promise; + prompt: (request: SessConsentRequest) => Promise; intervalMs?: number; } @@ -88,12 +92,34 @@ export class SessConsentService { 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 approved = false; - try { approved = await this.options.prompt(request); } - catch { approved = false; } + let choice: SessConsentChoice = 'dismiss'; + try { choice = await this.options.prompt(request); } + catch { choice = 'dismiss'; } if (this.stopped) { return; } - const response = !this.stopped && this.options.isEnabled() && approved ? 'approve' : 'decline'; + 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); } diff --git a/src/session.ts b/src/session.ts index 0462bf84f..d0694f005 100644 --- a/src/session.ts +++ b/src/session.ts @@ -27,7 +27,7 @@ import { getListViewerScript, listViewerStyle, ListViewNavigation } from './list import { getDataViewerScript, getDataViewerStyle, getDataViewerToolbarHtml } from './dataViewer'; import { getDataViewerColumnPanelHtml, getDataViewerColumnPanelScript, getDataViewerColumnPanelStyle } from './dataViewerColumnPanel'; import { formatSessionLabel, getViewerSessionScript } from './viewerSession'; -import { SessConsentService } from './sessConsent'; +import { SessConsentChoice, SessConsentService } from './sessConsent'; export interface SessionInfo { version: string; @@ -436,6 +436,7 @@ 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; @@ -879,13 +880,26 @@ async function ensureAttachSessConsentService(): Promise { 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.' - : '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.'; - const choice = await window.showWarningMessage(message, 'Install bundled sess'); - return choice === 'Install bundled sess'; + ? '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 { 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/node/sessConsent.test.ts b/src/test/node/sessConsent.test.ts index 4f53dfa4d..1c6cbd01d 100644 --- a/src/test/node/sessConsent.test.ts +++ b/src/test/node/sessConsent.test.ts @@ -2,9 +2,10 @@ 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 { SessConsentService } from '../../sessConsent'; +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; @@ -15,17 +16,22 @@ async function waitForFile(file: string): Promise { throw new Error(`Timed out waiting for ${file}`); } -async function writeRequest(directory: string, id: string, reason: 'missing' | 'mismatch' = 'missing'): Promise { +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${revision}\nlinux-x86_64|4.5\n${reason}\n`); + `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 () => { @@ -33,9 +39,18 @@ suite('sess install consent bridge', () => { await fs.rm(directory, { recursive: true, force: true }); }); - function service(prompt: (request: { reason: 'missing' | 'mismatch' }) => Promise, isEnabled = () => 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 }); + 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; } @@ -44,17 +59,18 @@ suite('sess install consent bridge', () => { const id = '1'.repeat(32); const prompt = (request: { reason: 'missing' | 'mismatch' }) => { assert.strictEqual(request.reason, 'missing'); - return Promise.resolve(true); + 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(true)); + 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`); @@ -63,7 +79,7 @@ suite('sess install consent bridge', () => { test('does not prompt when the watcher is disabled before the request arrives', async () => { let prompts = 0; - const broker = service(() => { prompts++; return Promise.resolve(false); }, () => false); + const broker = service(() => { prompts++; return Promise.resolve('install'); }, () => false); await broker.start(); const id = '2'.repeat(32); await writeRequest(directory, id, 'mismatch'); @@ -73,7 +89,7 @@ suite('sess install consent bridge', () => { test('declines when the user dismisses the prompt', async () => { let prompts = 0; - const broker = service(() => { prompts++; return Promise.resolve(false); }); + const broker = service(() => { prompts++; return Promise.resolve('dismiss'); }); await broker.start(); const id = '8'.repeat(32); await writeRequest(directory, id); @@ -81,12 +97,68 @@ suite('sess install consent bridge', () => { assert.strictEqual(prompts, 1); }); + 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!: (approved: boolean) => void; + let resolvePrompt!: (choice: 'install' | 'notNow' | 'dontAskAgain' | 'dismiss') => void; let prompts = 0; const broker = service(() => { prompts++; - return new Promise(resolve => { resolvePrompt = resolve; }); + return new Promise<'install' | 'notNow' | 'dontAskAgain' | 'dismiss'>(resolve => { resolvePrompt = resolve; }); }); await broker.start(); const id = '9'.repeat(32); @@ -97,18 +169,18 @@ suite('sess install consent bridge', () => { await broker.stop(); const response = path.join(directory, `${id}.response`); assert.strictEqual(await waitForFile(response), 'decline\n'); - resolvePrompt(true); + 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!: (approved: boolean) => void; + let resolvePrompt!: (choice: 'install' | 'notNow' | 'dontAskAgain' | 'dismiss') => void; let prompts = 0; const broker = service(() => { prompts++; - return new Promise(resolve => { resolvePrompt = resolve; }); + return new Promise<'install' | 'notNow' | 'dontAskAgain' | 'dismiss'>(resolve => { resolvePrompt = resolve; }); }, () => enabled); await broker.start(); const id = 'a'.repeat(32); @@ -117,13 +189,13 @@ suite('sess install consent bridge', () => { while (prompts === 0 && Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 5)); } assert.strictEqual(prompts, 1); enabled = false; - resolvePrompt(true); + 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(true); }, () => true, + const broker = service(() => { prompts++; return Promise.resolve('install'); }, () => true, `git-tree:${'b'.repeat(40)}`); await broker.start(); const id = '6'.repeat(32); @@ -133,13 +205,13 @@ suite('sess install consent bridge', () => { }); test('deduplicates a pending request and a later execution needs a new grant', async () => { - let resolvePrompt!: (approved: boolean) => void; + let resolvePrompt!: (choice: 'install' | 'notNow' | 'dontAskAgain' | 'dismiss') => void; let prompts = 0; const broker = service(() => { prompts++; return prompts === 1 - ? new Promise(resolve => { resolvePrompt = resolve; }) - : Promise.resolve(false); + ? new Promise<'install' | 'notNow' | 'dontAskAgain' | 'dismiss'>(resolve => { resolvePrompt = resolve; }) + : Promise.resolve('notNow'); }); await broker.start(); const firstId = '3'.repeat(32); @@ -149,7 +221,7 @@ suite('sess install consent bridge', () => { 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(true); + resolvePrompt('install'); assert.strictEqual(await waitForFile(firstResponse), 'approve\n'); const nextId = '4'.repeat(32); @@ -162,7 +234,7 @@ suite('sess install consent bridge', () => { test('malformed bounded requests are declined without showing UI', async () => { let prompts = 0; - const broker = service(() => { prompts++; return Promise.resolve(true); }); + 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`); From 38b5b4d27fc3ca008bc4285374f3ccaa71b5b7b6 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 02:11:19 +0000 Subject: [PATCH 05/21] fix: serialize managed sess setup --- R/attach_sess.R | 53 +++- R/tests/attach_sess.R | 306 +++++++++++++++++++- src/interactive/backends/sessPreparation.ts | 4 + 3 files changed, 352 insertions(+), 11 deletions(-) diff --git a/R/attach_sess.R b/R/attach_sess.R index a779844dd..21c3defa9 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -2,7 +2,8 @@ # 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) { + 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)) { @@ -23,9 +24,53 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, 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. + # Scope differs: this revision lock covers consent, install, and load; an empty follower does not prompt. + 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) + } + installed <- sess_find_source_library(expected, managed_library) if (!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")) @@ -109,9 +154,11 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, source_helper, installer_helper, plot_backend, - timeout_seconds = 180) { + timeout_seconds = 180, + setup_timeout_seconds = 300) { ns <- vscode_r_prepare_sess( - pkg_path, managed_root, consent_dir, source_helper, installer_helper, timeout_seconds) + pkg_path, managed_root, consent_dir, source_helper, installer_helper, + timeout_seconds, setup_timeout_seconds) if (is.null(ns)) { return(invisible(FALSE)) } diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index 5bf0efb1a..78729e819 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -17,7 +17,19 @@ writeLines(c( writeLines(c( "connect <- function(endpoint=NULL, plot_backend=NULL, ...) list(", " endpoint=endpoint, plot_backend=plot_backend, ...)", - "notify_client <- function(...) NULL", "request_client <- function(...) NULL" + "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")) @@ -27,13 +39,52 @@ 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', 'attach_sess.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)", + "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_')", @@ -41,11 +92,12 @@ writeLines(c( "ordinary <- .libPaths()", "same_paths <- function(x, y) identical(normalizePath(x, winslash='/'),", " normalizePath(y, winslash='/'))", - "attach_sess <- function(timeout=180) {", + "attach_sess <- function(timeout=180, setup_timeout=8) {", " vscode_r_attach_sess('endpoint', pkg, managed_root, consent,", " file.path(getwd(), 'R', 'sess_source.R'),", - " file.path(getwd(), 'R', 'sess-package-install.R'),", - " 'standard', timeout_seconds=timeout)", + " if (nzchar(install_helper)) install_helper else", + " file.path(getwd(), 'R', 'sess-package-install.R'),", + " 'standard', timeout_seconds=timeout, setup_timeout_seconds=setup_timeout)", "}", "if (mode %in% c('exact', 'prepared')) {", " expected <- if (mode == 'exact') normal else", @@ -149,8 +201,32 @@ writeLines(c( " wrong_desc <- file.path(wrong_library, 'sess', 'DESCRIPTION')", " stopifnot(file.exists(wrong_desc))", " stopifnot(identical(sess_source_revision(wrong_desc), revision))", + "} else if (mode %in% c('parallel_approve', 'parallel_decline', 'parallel_third')) {", + " timeout <- 10", + " result <- attach_sess(timeout=timeout, setup_timeout=20)", + " if (mode == 'parallel_approve') {", + " 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 {", + " stopifnot(identical(result, FALSE), !('sess' %in% loadedNamespaces()))", + " stopifnot(!dir.exists(file.path(managed_library, 'sess')))", + " 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)", "} else stop('unknown case')", - "cat('manual attach case passed:', mode, '\\n')" + "cat('manual attach case passed:', mode, '\\n')", + "if (nzchar(result_file)) writeLines('ok', result_file)" ), runner) agent <- file.path(root, "consent-agent.R") @@ -158,7 +234,7 @@ writeLines(c( "args <- commandArgs(TRUE)", "directory <- args[[1L]]; count <- as.integer(args[[2L]])", "answer <- args[[3L]]; log <- args[[4L]]; identity_log <- args[[5L]]", - "status_log <- args[[6L]]", + "status_log <- args[[6L]]; wait_marker <- args[[7L]]", "tryCatch({", "ids <- character(); identities <- character(); deadline <- Sys.time() + 30", "while (length(ids) < count && Sys.time() < deadline) {", @@ -167,6 +243,7 @@ writeLines(c( " 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]])", " writeLines(answer, temporary)", @@ -267,7 +344,7 @@ run_case <- function(mode) { answer <- if (mode %in% approve_modes) "approve" else "decline" system2(r_binary, shQuote(c( "--vanilla", "--slave", paste0("--file=", agent), "--args", - consent, as.character(count), answer, log, identity_log, status_log + consent, as.character(count), answer, log, identity_log, status_log, "" )), wait = FALSE, stdout = FALSE, stderr = FALSE) } flags <- if (startup) { @@ -309,5 +386,218 @@ for (mode in c( "twice", "mismatch", "exact", "prepared", "timeout", "approve", "profile_exact", "profile_prepared", "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) { + wait_for_file(paths$result, 30, description) + result <- readLines(paths$result, warn = FALSE) + if (!identical(result, "ok")) stop(paste(description, "failed")) + 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") + 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.create(install_release) + wait_for_file(install_complete, 10, "installer verification completion") + } + wait_for_child(owner, "setup-lock owner") + wait_for_child(follower, "setup-lock 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)) + 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)) + + 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, "independent setup 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)) + } +} + +run_parallel_setup(TRUE) +run_parallel_setup(FALSE) + +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")) unlink(root, recursive = TRUE) cat("manual attach consent bridge tests passed\n") diff --git a/src/interactive/backends/sessPreparation.ts b/src/interactive/backends/sessPreparation.ts index 9ae25e094..4ab9908d2 100644 --- a/src/interactive/backends/sessPreparation.ts +++ b/src/interactive/backends/sessPreparation.ts @@ -27,6 +27,10 @@ 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. + // This TS lock spans resource setup through ready; the R revision lock spans consent, + // package setup, and namespace load, with followers never prompting after contention. const deadline = Date.now() + 120000; for (;;) { try { fs.mkdirSync(lock, { mode: 0o700 }); break; } From ee36b35418ac8af0f6c94392916adab893261200 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 02:34:09 +0000 Subject: [PATCH 06/21] fix: require a ready marker for managed sess --- R/attach_sess.R | 27 +++- R/tests/attach_sess.R | 145 +++++++++++++++++--- src/interactive/backends/sessPreparation.ts | 5 +- 3 files changed, 156 insertions(+), 21 deletions(-) diff --git a/R/attach_sess.R b/R/attach_sess.R index 21c3defa9..2a86734aa 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -32,7 +32,8 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, 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. - # Scope differs: this revision lock covers consent, install, and load; an empty follower does not prompt. + # 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;", @@ -66,8 +67,15 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, 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 (!is.null(installed)) { + if (length(ready_revision) == 1L && identical(ready_revision, expected) && + !is.null(installed)) { ns <- sess_load_namespace(installed, expected) } else if (followed_setup) { return(NULL) @@ -126,6 +134,12 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, 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"]] @@ -145,6 +159,15 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, 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.") + } } } } diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index 78729e819..a0d51e30c 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -48,6 +48,7 @@ writeLines(c( "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')", "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')", @@ -99,17 +100,32 @@ writeLines(c( " file.path(getwd(), 'R', 'sess-package-install.R'),", " 'standard', timeout_seconds=timeout, setup_timeout_seconds=setup_timeout)", "}", - "if (mode %in% c('exact', 'prepared')) {", + "if (mode %in% c('exact', 'prepared', 'unready', 'wrong_ready')) {", " 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)", - " 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')))", - " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + " if (!mode %in% c('unready', 'wrong_ready')) {", + " 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 == 'wrong_ready') writeLines(mismatch, 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)", + " } else {", + " 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))", + " if (mode == 'wrong_ready') stopifnot(identical(readLines(ready_path), mismatch))", + " else stopifnot(!file.exists(ready_path))", + " }", "} else if (mode == 'twice') {", " for (iteration in 1:2) {", " set.seed(8841); seed <- .Random.seed", @@ -146,6 +162,7 @@ writeLines(c( " stopifnot(file.exists(file.path(root, 'user-profile-ran')))", " stopifnot(any(normalizePath(.libPaths()) == normalizePath(project_library)))", " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", + " if (mode == 'profile_prepared') stopifnot(identical(readLines(ready_path), revision))", "} else if (mode == 'profile_decline') {", " wrong_library <- file.path(managed_root, 'fixture-other-platform',", " '99.99', strrep('a', 40), 'library')", @@ -157,6 +174,7 @@ writeLines(c( " paste(normalizePath(.libPaths()), collapse=';')))", " stopifnot(file.exists(file.path(root, 'user-profile-ran')))", " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", + " stopifnot(!file.exists(ready_path))", " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')))", " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", "} else if (mode == 'profile_approve') {", @@ -174,6 +192,7 @@ writeLines(c( " 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(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')))", "} else if (mode == 'timeout') {", " set.seed(8841); seed <- .Random.seed", @@ -201,18 +220,29 @@ writeLines(c( " wrong_desc <- file.path(wrong_library, 'sess', 'DESCRIPTION')", " stopifnot(file.exists(wrong_desc))", " stopifnot(identical(sess_source_revision(wrong_desc), revision))", - "} else if (mode %in% c('parallel_approve', 'parallel_decline', 'parallel_third')) {", + " stopifnot(identical(readLines(ready_path), revision))", + "} else if (mode %in% c('parallel_approve', 'parallel_decline', 'parallel_third',", + " 'parallel_failure', 'parallel_retry')) {", " timeout <- 10", - " result <- attach_sess(timeout=timeout, setup_timeout=20)", " 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()))", - " stopifnot(!dir.exists(file.path(managed_library, 'sess')))", + " 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') {", @@ -224,6 +254,7 @@ writeLines(c( " 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)) writeLines('ok', result_file)" @@ -309,6 +340,9 @@ run_case <- function(mode) { } else { utils::install.packages(pkg, repos = NULL, type = "source", lib = install_library, quiet = TRUE) + if (mode == "profile_prepared") { + writeLines(revision, file.path(dirname(install_library), ".ready")) + } } if (mode %in% c("profile_exact", "profile_prepared")) { unlink(consent, recursive = TRUE) @@ -334,7 +368,14 @@ run_case <- function(mode) { }, add = TRUE) do.call(Sys.setenv, as.list(profile_environment)) } - consent_modes <- c("approve", "twice", "mismatch", "profile_decline", "profile_approve") + consent_modes <- c("approve", "twice", "mismatch", "profile_decline", "profile_approve", + "unready", "wrong_ready") + if (mode %in% c("unready", "wrong_ready")) { + managed_root <- file.path(case_root, "vscode-R") + managed_library <- sess_managed_library(managed_root, revision) + dir.create(managed_library, recursive = TRUE) + utils::install.packages(pkg, repos = NULL, type = "source", lib = managed_library, quiet = TRUE) + } if (mode %in% consent_modes) { log <- file.path(case_root, "consent-ids") identity_log <- file.path(case_root, "runtime-identities") @@ -383,7 +424,7 @@ run_case <- function(mode) { } for (mode in c( - "twice", "mismatch", "exact", "prepared", "timeout", "approve", + "twice", "mismatch", "exact", "prepared", "unready", "wrong_ready", "timeout", "approve", "profile_exact", "profile_prepared", "profile_decline", "profile_approve" )) run_case(mode) @@ -489,6 +530,7 @@ run_parallel_setup <- function(approve) { 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") @@ -534,7 +576,7 @@ run_parallel_setup <- function(approve) { 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)) + stopifnot(!file.exists(load_log), !file.exists(ready_path)) file.create(install_release) wait_for_file(install_complete, 10, "installer verification completion") } @@ -548,13 +590,14 @@ run_parallel_setup <- function(approve) { 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)) + 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") @@ -566,13 +609,80 @@ run_parallel_setup <- function(approve) { 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)) + 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, "failed sess setup") + 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, "retry after failed sess 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( @@ -599,5 +709,6 @@ if (!is.null(attr(timeout_output, "status")) && attr(timeout_output, "status") ! } 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/src/interactive/backends/sessPreparation.ts b/src/interactive/backends/sessPreparation.ts index 4ab9908d2..ed914cee1 100644 --- a/src/interactive/backends/sessPreparation.ts +++ b/src/interactive/backends/sessPreparation.ts @@ -29,8 +29,9 @@ export async function installSessRuntime(extensionPath: string, root: string, rP 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. - // This TS lock spans resource setup through ready; the R revision lock spans consent, - // package setup, and namespace load, with followers never prompting after contention. + // 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; } From 0bc28da058d20afaf01ddf306244ae5a708c98b8 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 05:58:23 +0000 Subject: [PATCH 07/21] docs: note managed sess dependency limitations --- sess/README.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/sess/README.md b/sess/README.md index ae3edf102..c99f92bec 100644 --- a/sess/README.md +++ b/sess/README.md @@ -39,9 +39,27 @@ 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 From c66278736b4a3a43e7904ad9abbb794133e112a5 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 08:22:24 +0000 Subject: [PATCH 08/21] fix: keep terminal input pending during sess setup --- R/profile.R | 28 ++- R/tests/attach_sess.R | 85 ++++++- src/rTerminal.ts | 16 +- src/session.ts | 110 ++++++++- .../suite/sessionTerminalLifecycle.test.ts | 233 +++++++++++++++++- src/test/suite/terminal.test.ts | 30 ++- 6 files changed, 464 insertions(+), 38 deletions(-) diff --git a/R/profile.R b/R/profile.R index efdd4542e..46a122056 100644 --- a/R/profile.R +++ b/R/profile.R @@ -26,6 +26,28 @@ local({ local({ initialize_sess <- function() { + startup_file <- Sys.getenv("VSCODE_R_SESS_STARTUP_FILE", unset = "") + write_startup_status <- function(status) { + if (!nzchar(startup_file)) { + return(invisible(FALSE)) + } + temporary <- "" + on.exit(if (nzchar(temporary)) suppressWarnings(unlink(temporary)), add = TRUE) + tryCatch(suppressWarnings({ + temporary <- tempfile(pattern = ".startup-", tmpdir = dirname(startup_file)) + writeLines(status, temporary, useBytes = TRUE) + if (.Platform$OS.type == "unix") { + Sys.chmod(temporary, "0600") + } + isTRUE(file.rename(temporary, startup_file)) + }), error = function(error) FALSE) + } + completed <- FALSE + if (nzchar(startup_file)) { + write_startup_status("pending") + on.exit(write_startup_status(if (completed) "ready" else "failed"), add = TRUE) + } + bundled_path <- Sys.getenv("VSCODE_R_SESS_PKG_PATH", unset = "") if (!nzchar(bundled_path)) { return(invisible(NULL)) @@ -51,12 +73,16 @@ local({ } plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") - get("connect", envir = ns, inherits = FALSE)( + result <- get("connect", envir = ns, inherits = FALSE)( use_rstudioapi = as.logical(Sys.getenv("SESS_RSTUDIOAPI", "TRUE")), plot_backend = plot_backend ) + completed <- TRUE + result } tryCatch(initialize_sess(), error = function(error) { message("vscode-R could not start the session watcher: ", conditionMessage(error)) + }, interrupt = function(error) { + message("vscode-R session watcher startup was interrupted: ", conditionMessage(error)) }) }) diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index a0d51e30c..a93731c1e 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -3,6 +3,7 @@ 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)) @@ -49,6 +50,7 @@ writeLines(c( "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')", @@ -147,8 +149,8 @@ writeLines(c( " stopifnot(same_paths(.libPaths(), ordinary))", " stopifnot(identical(read.dcf(desc_path), desc))", " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", - "} else if (mode %in% c('profile_exact', 'profile_prepared')) {", - " expected <- if (mode == 'profile_exact') normal else", + "} else if (mode %in% c('profile_exact', 'profile_prepared', 'profile_no_status')) {", + " expected <- if (mode %in% c('profile_exact', 'profile_no_status')) normal else", " sess_managed_library(managed_root, revision)", " stopifnot('sess' %in% loadedNamespaces())", " stopifnot(identical(sess_loaded_source_revision(), revision))", @@ -163,6 +165,8 @@ writeLines(c( " stopifnot(any(normalizePath(.libPaths()) == normalizePath(project_library)))", " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", " if (mode == 'profile_prepared') stopifnot(identical(readLines(ready_path), revision))", + " if (mode == 'profile_no_status') stopifnot(!file.exists(startup_status))", + " else stopifnot(identical(readLines(startup_status), 'ready'))", "} else if (mode == 'profile_decline') {", " wrong_library <- file.path(managed_root, 'fixture-other-platform',", " '99.99', strrep('a', 40), 'library')", @@ -175,6 +179,7 @@ writeLines(c( " stopifnot(file.exists(file.path(root, 'user-profile-ran')))", " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", " stopifnot(!file.exists(ready_path))", + " stopifnot(identical(readLines(startup_status), 'failed'))", " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')))", " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", "} else if (mode == 'profile_approve') {", @@ -193,6 +198,7 @@ writeLines(c( " stopifnot(same_paths(actual, file.path(library, 'sess')))", " stopifnot(file.exists(file.path(library, 'sess', 'DESCRIPTION')))", " stopifnot(identical(readLines(ready_path), revision))", + " stopifnot(identical(readLines(startup_status), 'ready'))", " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')))", "} else if (mode == 'timeout') {", " set.seed(8841); seed <- .Random.seed", @@ -297,10 +303,12 @@ r_literal <- function(value) encodeString(value, quote = "\"") 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_prepared", "profile_decline", "profile_approve") + "profile_exact", "profile_prepared", "profile_decline", "profile_approve", + "profile_no_status") startup <- mode %in% profile_modes if (startup) { normal <- file.path(case_root, "normal library") @@ -317,6 +325,7 @@ run_case <- function(mode) { 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'))", @@ -328,6 +337,7 @@ run_case <- function(mode) { mode, profile_exact = normal, profile_prepared = managed_library, + profile_no_status = normal, file.path(managed_root, "fixture-other-platform", "99.99", strrep("a", 40), "library")) dir.create(install_library, recursive = TRUE, showWarnings = FALSE) @@ -344,7 +354,7 @@ run_case <- function(mode) { writeLines(revision, file.path(dirname(install_library), ".ready")) } } - if (mode %in% c("profile_exact", "profile_prepared")) { + if (mode %in% c("profile_exact", "profile_prepared", "profile_no_status")) { unlink(consent, recursive = TRUE) } profile_environment <- c( @@ -357,7 +367,10 @@ run_case <- function(mode) { 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_CONSENT_DIRECTORY = consent, + if (mode != "profile_no_status") { + c(VSCODE_R_SESS_STARTUP_FILE = startup_status) + }) profile_keys <- names(profile_environment) previous_environment <- Sys.getenv(profile_keys, unset = NA_character_) on.exit({ @@ -383,9 +396,14 @@ run_case <- function(mode) { count <- if (mode == "twice") 2L else 1L approve_modes <- c("approve", "profile_approve") answer <- if (mode %in% approve_modes) "approve" else "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, "" + consent, as.character(count), answer, log, identity_log, status_log, release_marker )), wait = FALSE, stdout = FALSE, stderr = FALSE) } flags <- if (startup) { @@ -393,12 +411,53 @@ run_case <- function(mode) { } else { c("--vanilla", "--slave", "--no-save", "--no-restore") } - 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% 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") + stopifnot(identical(readLines(startup_status, warn = FALSE), "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 <- c(readLines(stdout_path, warn = FALSE), readLines(stderr_path, warn = FALSE)) + stop(paste("profile startup child did not complete:", paste(child_output, collapse = "\n"))) + } + stopifnot(identical(readLines(result_file, warn = FALSE), "ok")) + } 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 @@ -425,7 +484,7 @@ run_case <- function(mode) { for (mode in c( "twice", "mismatch", "exact", "prepared", "unready", "wrong_ready", "timeout", "approve", - "profile_exact", "profile_prepared", "profile_decline", "profile_approve" + "profile_exact", "profile_prepared", "profile_decline", "profile_approve", "profile_no_status" )) run_case(mode) wait_for_file <- function(path, seconds, description) { diff --git a/src/rTerminal.ts b/src/rTerminal.ts index df79660db..3b18235f0 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -32,6 +32,7 @@ let rTermResource: vscode.Uri | undefined; const terminalStartup = new WeakMap; }>(); @@ -43,7 +44,7 @@ async function prepareTerminalForInput(terminal: vscode.Terminal): Promise { const ready = startup.integrated - ? await waitForTerminalReady(terminal) + ? await waitForTerminalReady(terminal, 30000, startup.startupFilePath) : await delay(200).then(() => !isTerminalClosed(terminal) && !terminal.exitStatus); startup.ready = ready; if (!ready && !isTerminalClosed(terminal) && !terminal.exitStatus) { @@ -258,6 +259,7 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise { - 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); } } }; @@ -322,6 +327,7 @@ export async function createRTerm(preserveshow?: boolean, resource?: vscode.Uri) terminalStartup.set(createdTerminal, { integrated: typeof discoveryFile === 'string' && !skipsProfile, ready: false, + startupFilePath: typeof startupFile === 'string' ? startupFile : undefined, }); createdTerminal.show(preserveshow); diff --git a/src/session.ts b/src/session.ts index d0694f005..e81e9f4c4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -213,26 +213,117 @@ 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 { +type TerminalStartupState = 'pending' | 'ready' | 'failed'; +const SESS_SETUP_TIMEOUT = 600000; + +/** Read the bounded, one-line status sidecar written by R/profile.R. */ +async function readTerminalStartupState(filePath: string): Promise { + let file: fs.promises.FileHandle | undefined; + try { + file = await fsp.open(filePath, 'r'); + const buffer = Buffer.alloc(33); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > 32) { return undefined; } + let value = buffer.toString('utf8', 0, bytesRead); + if (value.endsWith('\r\n')) { value = value.slice(0, -2); } + else if (value.endsWith('\n')) { value = value.slice(0, -1); } + if (value.includes('\n') || value.includes('\r')) { return undefined; } + return value === 'pending' || value === 'ready' || value === 'failed' ? value : undefined; + } catch { + return undefined; + } finally { + await file?.close().catch(() => undefined); + } +} + +/** + * Wait for the same connected owner used by execution and terminal selection. + * Profile setup may take up to ten minutes after reporting pending; ready still + * requires this terminal to attach, and failed always aborts this wait. + */ +export function waitForTerminalReady(terminal: vscode.Terminal, timeout = 30000, startupFilePath?: string): Promise { return new Promise(resolve => { let settled = false; + let observedState: TerminalStartupState | undefined; + let startupTimer: NodeJS.Timeout | undefined; + let pendingTimer: NodeJS.Timeout | undefined; + let pollTimer: NodeJS.Timeout | undefined; + let checkingOwner = false; + const finish = (ready: boolean) => { if (settled) { return; } settled = true; - clearTimeout(timer); + if (startupTimer) { clearTimeout(startupTimer); } + if (pendingTimer) { clearTimeout(pendingTimer); } + if (pollTimer) { clearTimeout(pollTimer); } attached.dispose(); closed.dispose(); resolve(ready); }; + const isClosed = () => terminalRegistry.isClosed(terminal) || terminal.exitStatus !== undefined; + const hasOwner = () => Boolean(terminalRegistry.ownerOf(terminal)); + const checkOwner = () => { + if (settled) { return; } + if (isClosed()) { finish(false); } + else if (observedState === 'failed') { finish(false); } + else if (observedState !== 'pending' && hasOwner()) { finish(true); } + }; + const setState = (state: TerminalStartupState) => { + if (settled || observedState === 'failed') { return; } + if (state === 'failed') { + observedState = 'failed'; + finish(false); + } else if (state === 'pending') { + if (observedState === undefined) { + observedState = 'pending'; + if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; } + pendingTimer = setTimeout(() => finish(false), SESS_SETUP_TIMEOUT); + } + } else if (state === 'ready') { + if (observedState !== 'ready') { + observedState = 'ready'; + if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; } + if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = undefined; } + startupTimer = setTimeout(() => finish(false), timeout); + } + checkOwner(); + } + }; 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 (!hasOwner()) { return; } + if (!startupFilePath || observedState !== undefined) { checkOwner(); return; } + if (checkingOwner) { return; } + checkingOwner = true; + void readTerminalStartupState(startupFilePath).then(state => { + checkingOwner = false; + if (settled) { return; } + if (state) { setState(state); } + if (!settled) { checkOwner(); } + }).catch(() => { + checkingOwner = false; + if (!settled) { checkOwner(); } + }); }; 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); + + // A missing or invalid state keeps the ordinary attachment timeout. + startupTimer = setTimeout(() => finish(false), timeout); + // A fresh retry on an already-owned terminal is ready even if an earlier + // startup attempt left a failed marker behind. + if (hasOwner()) { finish(true); return; } + if (startupFilePath) { + const poll = async () => { + if (settled) { return; } + const state = await readTerminalStartupState(startupFilePath); + if (settled) { return; } + if (state) { setState(state); } + if (!settled) { pollTimer = setTimeout(() => { void poll(); }, 100); } + }; + void poll().catch(() => undefined); + } check(); }); } @@ -495,7 +586,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 }), + ]); } }); } diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index 2025bc026..4a527a58e 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,41 @@ 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; + async function makeStartupFile(state: 'pending' | 'ready' | 'failed'): Promise<{ directory: string; file: string }> { + const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'vscode-r-terminal-startup-')); + const file = path.join(directory, 'session.json.startup'); + await fsp.writeFile(file, `${state}\n`); + return { directory, file }; + } + + 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`); + } + setup(() => { sandbox = sinon.createSandbox(); connections = new SessionConnections(); @@ -160,7 +192,7 @@ suite('Session Terminal Lifecycle', () => { 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)); + .callsFake((terminal, _timeout, startupPath) => waitUntilReady(terminal, 1000, startupPath)); const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); const sendText = sandbox.stub(); const terminal = { @@ -182,7 +214,7 @@ 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, '/unused-test-discovery.startup'); sinon.assert.calledOnceWithExactly(sendText, 'source("example.R")'); sinon.assert.notCalled(warning); } finally { @@ -347,22 +379,207 @@ suite('Session Terminal Lifecycle', () => { }); test('terminal readiness aborts on close even before processId resolves', async () => { + const { directory, file } = await makeStartupFile('pending'); const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.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(); + const clock = sandbox.useFakeTimers(); + const timerSpy = sinon.spy(globalThis, 'setTimeout'); + const waiting = session.waitForTerminalReady(terminal, 30000, file); + 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.dispose(); + timerSpy.restore(); + clock.restore(); + 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 { directory, file: startupFile } = await makeStartupFile('pending'); + const discoveryFile = startupFile.slice(0, -'.startup'.length); + const rPid = 46291; + const otherPid = 46292; + 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: true, '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); + sandbox.stub(vscode.window, 'createTerminal').returns(terminal); + const waitForReady = session.waitForTerminalReady; + let readinessStarted!: () => void; + const started = new Promise(resolve => { readinessStarted = resolve; }); + const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath) => { + readinessStarted(); + return waitForReady(current, timeout, startupPath); + }); + const clock = sandbox.useFakeTimers(); + const timerSpy = sinon.spy(globalThis, 'setTimeout'); + let fakeClockRestored = false; + const restoreFakeClock = () => { + if (!fakeClockRestored) { + timerSpy.restore(); + clock.restore(); + fakeClockRestored = true; + } + }; + + try { + assert.strictEqual(await rTerminal.createRTerm(), true); + const first = rTerminal.runTextInTerm('first'); + const second = rTerminal.runTextInTerm('second'); + let firstSettled = false; + const firstResult = first.then(value => { firstSettled = true; return value; }); + await started; + assert.strictEqual(readyStub.firstCall.args[2], startupFile); + 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, 'ready\n'); + 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 { + rTerminal.deleteTerminal(terminal); + await connections.dispose(); + restoreFakeClock(); + 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 { directory, file: startupFile } = await makeStartupFile('pending'); + const discoveryFile = startupFile.slice(0, -'.startup'.length); + const rPid = 46293; + 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: true, '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); + sandbox.stub(vscode.window, 'createTerminal').returns(terminal); + const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const waitForReady = session.waitForTerminalReady; + let readinessStarted!: () => void; + const started = new Promise(resolve => { readinessStarted = resolve; }); + const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath) => { + readinessStarted(); + return waitForReady(current, timeout, startupPath); + }); + const clock = sandbox.useFakeTimers(); + const timerSpy = sinon.spy(globalThis, 'setTimeout'); + let fakeClockRestored = false; + const restoreFakeClock = () => { + if (!fakeClockRestored) { + timerSpy.restore(); + clock.restore(); + fakeClockRestored = true; + } + }; + + try { + assert.strictEqual(await rTerminal.createRTerm(), true); + let rejectionSettled = false; + const rejected = rTerminal.runTextInTerm('must not send').then(result => { + rejectionSettled = true; + return result; + }); + await started; + assert.strictEqual(readyStub.firstCall.args[2], startupFile); + await waitForTimerDelay(timerSpy, 600000, startupFile); + await fsp.writeFile(startupFile, 'failed\n'); + 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 rejected, false); + sinon.assert.notCalled(sendText); + sinon.assert.calledOnce(warning); + assert.strictEqual(clock.countTimers(), 0, 'failed startup clears its bounded wait and poll'); + + restoreFakeClock(); + await connections.attach('startup-late-attach', rPid); + assert.strictEqual(sendText.called, false, 'late attach cannot release the already failed input'); + assert.strictEqual(await session.waitForTerminalReady(terminal, 1, startupFile), true, + 'a new wait on an attached terminal permits manual recovery despite the stale failed state'); + assert.strictEqual(await rTerminal.runTextInTerm('retry after attach'), true); + sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~retry after attach\x1b[201~', true); + } finally { + rTerminal.deleteTerminal(terminal); + await connections.dispose(); + restoreFakeClock(); + 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); + let settled = false; + const waiting = session.waitForTerminalReady(terminal).then(result => { settled = true; return result; }); + 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); }); + test('pending startup has a finite 10 minute deadline', async () => { + const { directory, file } = await makeStartupFile('pending'); + const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; + const clock = sandbox.useFakeTimers(); + const timerSpy = sinon.spy(globalThis, 'setTimeout'); + const waiting = session.waitForTerminalReady(terminal, 30000, file); + try { + const expire = await waitForTimerDelay(timerSpy, 600000, file); + expire(); + assert.strictEqual(await waiting, false); + assert.strictEqual(clock.countTimers(), 0); + } finally { + timerSpy.restore(); + clock.restore(); + 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 80b5e6aa4..a2b8d92fc 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -231,13 +231,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 +247,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 +260,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 +283,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 }); } }); @@ -635,10 +647,14 @@ suite('R Terminal', () => { test('createRTerm removes its discovery file when the configured executable is invalid', 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 = { @@ -651,17 +667,23 @@ suite('R Terminal', () => { assert.strictEqual(await rTerminal.createRTerm(), false); 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 }))); + await Promise.all(createdStartupFiles.map(filePath => fsp.rm(filePath, { recursive: true, force: true }))); } }); 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 = { @@ -675,8 +697,10 @@ suite('R Terminal', () => { 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 }))); + await Promise.all(createdStartupFiles.map(filePath => fsp.rm(filePath, { recursive: true, force: true }))); } }); From 8e2e43307372df096fc0451e072b53bac31f2bf1 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 08:43:00 +0000 Subject: [PATCH 09/21] test: isolate terminal startup timers and cleanup --- .../suite/sessionTerminalLifecycle.test.ts | 113 ++++++++++++------ src/test/suite/terminal.test.ts | 55 +++++---- 2 files changed, 105 insertions(+), 63 deletions(-) diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index 4a527a58e..c9f95fab6 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -14,6 +14,8 @@ import * as util from '../../util'; import { mockExtensionContext } from '../common/mockvscode'; import { deferred, SessionConnections, waitForValue as waitFor } from '../common/sessionConnections'; +const originalSetTimeout = globalThis.setTimeout; + async function settleWithFakeTime(promise: Promise, clock: sinon.SinonFakeTimers): Promise { let settled = false; void promise.then(() => { settled = true; }, () => { settled = true; }); @@ -383,8 +385,9 @@ suite('Session Terminal Lifecycle', () => { const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; const close = new vscode.EventEmitter(); sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); - const clock = sandbox.useFakeTimers(); - const timerSpy = sinon.spy(globalThis, 'setTimeout'); + const timerSandbox = sinon.createSandbox(); + const clock = timerSandbox.useFakeTimers(); + const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); const waiting = session.waitForTerminalReady(terminal, 30000, file); try { await waitForTimerDelay(timerSpy, 600000, file); @@ -392,9 +395,10 @@ suite('Session Terminal Lifecycle', () => { 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(); - timerSpy.restore(); - clock.restore(); await fsp.rm(directory, { recursive: true, force: true }); } }); @@ -427,21 +431,19 @@ suite('Session Terminal Lifecycle', () => { readinessStarted(); return waitForReady(current, timeout, startupPath); }); - const clock = sandbox.useFakeTimers(); - const timerSpy = sinon.spy(globalThis, 'setTimeout'); - let fakeClockRestored = false; - const restoreFakeClock = () => { - if (!fakeClockRestored) { - timerSpy.restore(); - clock.restore(); - fakeClockRestored = true; - } - }; + 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'); + let commandsSettled: Promise[]> | undefined; try { assert.strictEqual(await rTerminal.createRTerm(), true); 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; @@ -469,9 +471,12 @@ suite('Session Terminal Lifecycle', () => { ['\x1b[200~second\x1b[201~', true], ]); } finally { + close.fire(terminal); rTerminal.deleteTerminal(terminal); + timerSandbox.restore(); + if (commandsSettled) { await commandsSettled; } await connections.dispose(); - restoreFakeClock(); + close.dispose(); await fsp.rm(directory, { recursive: true, force: true }); } }); @@ -504,24 +509,22 @@ suite('Session Terminal Lifecycle', () => { readinessStarted(); return waitForReady(current, timeout, startupPath); }); - const clock = sandbox.useFakeTimers(); - const timerSpy = sinon.spy(globalThis, 'setTimeout'); - let fakeClockRestored = false; - const restoreFakeClock = () => { - if (!fakeClockRestored) { - timerSpy.restore(); - clock.restore(); - fakeClockRestored = true; - } - }; + 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'); + let pendingCommand: Promise | undefined; + let pendingCommandSettled: Promise> | undefined; try { assert.strictEqual(await rTerminal.createRTerm(), true); let rejectionSettled = false; - const rejected = rTerminal.runTextInTerm('must not send').then(result => { + 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); await waitForTimerDelay(timerSpy, 600000, startupFile); @@ -532,12 +535,12 @@ suite('Session Terminal Lifecycle', () => { // 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 rejected, false); + 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'); - restoreFakeClock(); + timerSandbox.restore(); await connections.attach('startup-late-attach', rPid); assert.strictEqual(sendText.called, false, 'late attach cannot release the already failed input'); assert.strictEqual(await session.waitForTerminalReady(terminal, 1, startupFile), true, @@ -545,30 +548,46 @@ suite('Session Terminal Lifecycle', () => { assert.strictEqual(await rTerminal.runTextInTerm('retry after attach'), true); sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~retry after attach\x1b[201~', true); } finally { + close.fire(terminal); rTerminal.deleteTerminal(terminal); + timerSandbox.restore(); + if (pendingCommandSettled) { await pendingCommandSettled; } await connections.dispose(); - restoreFakeClock(); + close.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 timerSandbox = sinon.createSandbox(); + const clock = timerSandbox.useFakeTimers(); let settled = false; const waiting = session.waitForTerminalReady(terminal).then(result => { settled = true; return result; }); - 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); + 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 } = await makeStartupFile('pending'); const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; - const clock = sandbox.useFakeTimers(); - const timerSpy = sinon.spy(globalThis, 'setTimeout'); + 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); try { const expire = await waitForTimerDelay(timerSpy, 600000, file); @@ -576,10 +595,26 @@ suite('Session Terminal Lifecycle', () => { assert.strictEqual(await waiting, false); assert.strictEqual(clock.countTimers(), 0); } finally { - timerSpy.restore(); - clock.restore(); + close.fire(terminal); + timerSandbox.restore(); + await waiting; + close.dispose(); await fsp.rm(directory, { recursive: true, force: true }); } }); + test('global Sinon restore leaves startup timers usable', async () => { + try { + // Other suites call the default Sinon sandbox restore after this suite. + // It must not reinstate a fake clock or spy from a completed test. + sinon.restore(); + assert.strictEqual(globalThis.setTimeout, originalSetTimeout); + await new Promise(resolve => globalThis.setTimeout(resolve, 1)); + } finally { + if (globalThis.setTimeout !== originalSetTimeout) { + globalThis.setTimeout = originalSetTimeout; + } + } + }); + }); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index a2b8d92fc..9b40d6904 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -110,31 +110,38 @@ 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) { + sinon.assert.calledOnceWithExactly(readyStub, terminal, 30000, '/unused-test-discovery.startup'); + sinon.assert.notCalled(delayStub); + } else { + sinon.assert.notCalled(readyStub); + sinon.assert.calledOnceWithExactly(delayStub, 200); + } + 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, watcher); + 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~']); - assert.strictEqual(consentDirectoryStub.called, watcher); - 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); }); } From 827c1a5716acb278f6bf0e58ec27e15d4e7954cd Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 08:43:05 +0000 Subject: [PATCH 10/21] test: separate sess attach integration checks --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0b1cb1106..5d05350d8 100644 --- a/package.json +++ b/package.json @@ -2535,8 +2535,9 @@ "test:extension": "vscode-test", "lint": "oxlint src", "typecheck": "tsc --noEmit -p ./", - "test:sess-identity": "Rscript --vanilla R/tests/sess_source.R && Rscript --vanilla R/tests/attach_sess.R", - "test:sess-source": "node --test scripts/sess-source.test.js && pnpm run test:sess-identity", + "test:sess-identity": "Rscript --vanilla R/tests/sess_source.R", + "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" }, From 8b0310d7f92056df04df59b3e4d82df2eaa2b9d5 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 09:09:21 +0000 Subject: [PATCH 11/21] test: wait for R evaluation before interrupting --- src/test/node/interactiveRuntime.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/node/interactiveRuntime.test.ts b/src/test/node/interactiveRuntime.test.ts index 782bfad45..49a9af48e 100644 --- a/src/test/node/interactiveRuntime.test.ts +++ b/src/test/node/interactiveRuntime.test.ts @@ -234,8 +234,9 @@ 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()}`; + 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'); From 06489f3edce93f0fd3c6a30a28d2700954eab0f3 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 09:37:20 +0000 Subject: [PATCH 12/21] fix: honor sess startup state before sending terminal input --- src/rTerminal.ts | 6 +- src/session.ts | 17 ++- .../suite/sessionTerminalLifecycle.test.ts | 126 +++++++++++++++++- src/test/suite/terminal.test.ts | 4 +- 4 files changed, 137 insertions(+), 16 deletions(-) diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 3b18235f0..58f9dec8a 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -32,6 +32,7 @@ let rTermResource: vscode.Uri | undefined; const terminalStartup = new WeakMap; }>(); @@ -44,9 +45,10 @@ async function prepareTerminalForInput(terminal: vscode.Terminal): Promise { const ready = startup.integrated - ? await waitForTerminalReady(terminal, 30000, startup.startupFilePath) + ? await waitForTerminalReady(terminal, 30000, startup.startupFilePath, startup.failed ?? false) : await delay(200).then(() => !isTerminalClosed(terminal) && !terminal.exitStatus); startup.ready = ready; + startup.failed = !ready; if (!ready && !isTerminalClosed(terminal) && !terminal.exitStatus) { 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.'); } @@ -339,7 +341,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/session.ts b/src/session.ts index e81e9f4c4..68bd5b2c0 100644 --- a/src/session.ts +++ b/src/session.ts @@ -239,9 +239,11 @@ async function readTerminalStartupState(filePath: string): Promise { +export function waitForTerminalReady(terminal: vscode.Terminal, timeout = 30000, startupFilePath?: string, + allowAttachedRecovery = false): Promise { return new Promise(resolve => { let settled = false; let observedState: TerminalStartupState | undefined; @@ -266,10 +268,13 @@ export function waitForTerminalReady(terminal: vscode.Terminal, timeout = 30000, if (settled) { return; } if (isClosed()) { finish(false); } else if (observedState === 'failed') { finish(false); } - else if (observedState !== 'pending' && hasOwner()) { finish(true); } + else if ((!startupFilePath || observedState === 'ready') && hasOwner()) { finish(true); } }; const setState = (state: TerminalStartupState) => { if (settled || observedState === 'failed') { return; } + if (state === 'failed' && allowAttachedRecovery && observedState === undefined && hasOwner()) { + state = 'ready'; + } if (state === 'failed') { observedState = 'failed'; finish(false); @@ -311,9 +316,9 @@ export function waitForTerminalReady(terminal: vscode.Terminal, timeout = 30000, // A missing or invalid state keeps the ordinary attachment timeout. startupTimer = setTimeout(() => finish(false), timeout); - // A fresh retry on an already-owned terminal is ready even if an earlier - // startup attempt left a failed marker behind. - if (hasOwner()) { finish(true); return; } + // Keep the historical immediate path for terminals without profile setup. + // Managed terminals must observe their sidecar before using an existing owner. + if (!startupFilePath && hasOwner()) { finish(true); return; } if (startupFilePath) { const poll = async () => { if (settled) { return; } diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index c9f95fab6..54edcdf9e 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -194,7 +194,8 @@ suite('Session Terminal Lifecycle', () => { const waitUntilReady = session.waitForTerminalReady; // Exercise the real readiness logic, with a bounded failure timeout. const readiness = sandbox.stub(session, 'waitForTerminalReady') - .callsFake((terminal, _timeout, startupPath) => waitUntilReady(terminal, 1000, startupPath)); + .callsFake((terminal, _timeout, startupPath, allowRecovery) => + waitUntilReady(terminal, 1000, startupPath, allowRecovery)); const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); const sendText = sandbox.stub(); const terminal = { @@ -216,7 +217,7 @@ 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, 30000, '/unused-test-discovery.startup'); + sinon.assert.calledOnceWithExactly(readiness, terminal, 30000, '/unused-test-discovery.startup', false); sinon.assert.calledOnceWithExactly(sendText, 'source("example.R")'); sinon.assert.notCalled(warning); } finally { @@ -427,9 +428,9 @@ suite('Session Terminal Lifecycle', () => { const waitForReady = session.waitForTerminalReady; let readinessStarted!: () => void; const started = new Promise(resolve => { readinessStarted = resolve; }); - const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath) => { + const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath, allowRecovery) => { readinessStarted(); - return waitForReady(current, timeout, startupPath); + return waitForReady(current, timeout, startupPath, allowRecovery); }); const close = new vscode.EventEmitter(); sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); @@ -448,6 +449,7 @@ suite('Session Terminal Lifecycle', () => { const firstResult = first.then(value => { firstSettled = true; return value; }); await started; assert.strictEqual(readyStub.firstCall.args[2], startupFile); + assert.strictEqual(readyStub.firstCall.args[3], false); await waitForTimerDelay(timerSpy, 600000, startupFile); await clock.tickAsync(35000); assert.strictEqual(firstSettled, false, 'pending setup suppresses the ordinary 30 second timeout'); @@ -481,6 +483,113 @@ suite('Session Terminal Lifecycle', () => { } }); + for (const state of ['ready', 'failed'] as const) { + test(`an already attached managed terminal waits for pending startup to become ${state}`, async () => { + const { directory, file: startupFile } = await makeStartupFile('pending'); + const discoveryFile = startupFile.slice(0, -'.startup'.length); + const rPid = state === 'ready' ? 46294 : 46295; + 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: true, '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); + 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); + const timerSandbox = sinon.createSandbox(); + let commandSettled: Promise> | undefined; + + try { + assert.strictEqual(await rTerminal.createRTerm(), true); + 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, `${state}\n`); + 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 { + close.fire(terminal); + rTerminal.deleteTerminal(terminal); + timerSandbox.restore(); + if (commandSettled) { await commandSettled; } + await connections.dispose(); + close.dispose(); + await fsp.rm(directory, { recursive: true, force: true }); + } + }); + } + + test('attached-terminal recovery still declines a failure after observing pending', async () => { + const { directory, file } = await makeStartupFile('pending'); + const rPid = 46296; + const terminal = { processId: Promise.resolve(rPid) } 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 timerSandbox = sinon.createSandbox(); + let waiting: Promise | undefined; + + try { + await connections.attach('startup-recovery-pending', rPid); + await waitFor(() => session.activeSession?.pid === String(rPid) ? session.activeSession : undefined); + + const clock = timerSandbox.useFakeTimers(); + const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); + waiting = session.waitForTerminalReady(terminal, 30000, file, true); + await waitForTimerDelay(timerSpy, 600000, file); + await fsp.writeFile(file, 'failed\n'); + let settled = false; + void waiting.then(() => { settled = true; }); + for (let attempt = 0; attempt < 100 && !settled; attempt++) { + await clock.tickAsync(100); + await fsp.readFile(file); + } + assert.strictEqual(settled, true); + assert.strictEqual(await waiting, false, + 'only a failure that is stale at the start of recovery can be bypassed'); + assert.strictEqual(clock.countTimers(), 0); + } finally { + close.fire(terminal); + timerSandbox.restore(); + if (waiting) { await waiting; } + await connections.dispose(); + close.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 { directory, file: startupFile } = await makeStartupFile('pending'); const discoveryFile = startupFile.slice(0, -'.startup'.length); @@ -505,9 +614,9 @@ suite('Session Terminal Lifecycle', () => { const waitForReady = session.waitForTerminalReady; let readinessStarted!: () => void; const started = new Promise(resolve => { readinessStarted = resolve; }); - const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath) => { + const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath, allowRecovery) => { readinessStarted(); - return waitForReady(current, timeout, startupPath); + return waitForReady(current, timeout, startupPath, allowRecovery); }); const close = new vscode.EventEmitter(); sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); @@ -527,6 +636,7 @@ suite('Session Terminal Lifecycle', () => { pendingCommandSettled = Promise.allSettled([pendingCommand]).then(results => results[0]); await started; assert.strictEqual(readyStub.firstCall.args[2], startupFile); + assert.strictEqual(readyStub.firstCall.args[3], false); await waitForTimerDelay(timerSpy, 600000, startupFile); await fsp.writeFile(startupFile, 'failed\n'); for (let attempt = 0; attempt < 100 && !rejectionSettled; attempt++) { @@ -543,9 +653,11 @@ suite('Session Terminal Lifecycle', () => { timerSandbox.restore(); await connections.attach('startup-late-attach', rPid); assert.strictEqual(sendText.called, false, 'late attach cannot release the already failed input'); - assert.strictEqual(await session.waitForTerminalReady(terminal, 1, startupFile), true, + assert.strictEqual(await session.waitForTerminalReady(terminal, 1000, startupFile, true), true, 'a new wait on an attached terminal permits manual recovery despite the stale failed state'); assert.strictEqual(await rTerminal.runTextInTerm('retry after attach'), true); + assert.strictEqual(readyStub.lastCall.args[3], true, + 'a fresh terminal run enables recovery from the earlier failed startup'); sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~retry after attach\x1b[201~', true); } finally { close.fire(terminal); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 9b40d6904..beeed5411 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -122,7 +122,7 @@ suite('R Terminal', () => { assert.deepStrictEqual(sent, []); sinon.assert.calledOnce(create); if (integrated) { - sinon.assert.calledOnceWithExactly(readyStub, terminal, 30000, '/unused-test-discovery.startup'); + sinon.assert.calledOnceWithExactly(readyStub, terminal, 30000, '/unused-test-discovery.startup', false); sinon.assert.notCalled(delayStub); } else { sinon.assert.notCalled(readyStub); @@ -172,6 +172,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', false]); + assert.deepStrictEqual(ready.secondCall.args, [terminal, 30000, '/unused-test-discovery.startup', true]); rTerminal.deleteTerminal(terminal); }); From 9986ef1610f05275a80bfbd261362a5f07bd3757 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 09:51:01 +0000 Subject: [PATCH 13/21] test: provide ready status for managed terminal fixtures --- src/test/suite/sessionTerminalLifecycle.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index 54edcdf9e..de8257380 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -183,13 +183,15 @@ 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 () => { + const { directory, file: startupPath } = await makeStartupFile('ready'); + const discoveryPath = startupPath.slice(0, -'.startup'.length); 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(session, 'getSessConsentDirectory').resolves('/unused-test-consent'); - sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); + sandbox.stub(session, 'createSessionDiscoveryFile').resolves(discoveryPath); sandbox.stub(session, 'updateTerminalSessionDiscoveryFile').resolves(); const waitUntilReady = session.waitForTerminalReady; // Exercise the real readiness logic, with a bounded failure timeout. @@ -205,6 +207,8 @@ suite('Session Terminal Lifecycle', () => { sandbox.stub(vscode.window, 'terminals').value([terminal]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); sandbox.stub(vscode.window, 'createTerminal').returns(terminal); + const close = new vscode.EventEmitter(); + sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); const sessionId = `source-readiness-${String(explicitBinding)}`; try { assert.strictEqual(await rTerminal.createRTerm(), true); @@ -217,12 +221,18 @@ 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, 30000, '/unused-test-discovery.startup', false); + sinon.assert.calledOnceWithExactly(readiness, terminal, 30000, startupPath, false); sinon.assert.calledOnceWithExactly(sendText, 'source("example.R")'); sinon.assert.notCalled(warning); } finally { + close.fire(terminal); rTerminal.deleteTerminal(terminal); - await session.cleanupSession(sessionId); + try { + await session.cleanupSession(sessionId); + } finally { + close.dispose(); + await fsp.rm(directory, { recursive: true, force: true }); + } } }); } From 4f9d24fbc278b33e337815732659d3892223651e Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 09:54:50 +0000 Subject: [PATCH 14/21] test: keep interrupt synchronization within one R expression --- src/test/node/interactiveRuntime.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/node/interactiveRuntime.test.ts b/src/test/node/interactiveRuntime.test.ts index 49a9af48e..169cc111a 100644 --- a/src/test/node/interactiveRuntime.test.ts +++ b/src/test/node/interactiveRuntime.test.ts @@ -235,7 +235,8 @@ sess::display("display-${cell}", "text/plain"); cat("stderr-${cell}\\n", file=st test('interrupts evaluation without losing R and cancels queued code', async () => { const marker = `interrupt-ready-${randomUUID()}`; - const id = await submit(`kept <- 17; cat(${JSON.stringify(marker)}, "\\n"); Sys.sleep(30)`); + // 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'); From 67b085afa8de7b8207a6c1ac935aabe9565e3512 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 10:14:19 +0000 Subject: [PATCH 15/21] test: simplify managed sess regression coverage --- R/tests/attach_sess.R | 183 +++++++----------- src/test/node/sessConsent.test.ts | 10 - .../suite/sessionTerminalLifecycle.test.ts | 165 +++++----------- 3 files changed, 126 insertions(+), 232 deletions(-) diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index a93731c1e..0b4e06be0 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -95,6 +95,12 @@ writeLines(c( "ordinary <- .libPaths()", "same_paths <- function(x, y) identical(normalizePath(x, winslash='/'),", " normalizePath(y, winslash='/'))", + "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(timeout=180, setup_timeout=8) {", " vscode_r_attach_sess('endpoint', pkg, managed_root, consent,", " file.path(getwd(), 'R', 'sess_source.R'),", @@ -102,15 +108,19 @@ writeLines(c( " file.path(getwd(), 'R', 'sess-package-install.R'),", " 'standard', timeout_seconds=timeout, setup_timeout_seconds=setup_timeout)", "}", - "if (mode %in% c('exact', 'prepared', 'unready', 'wrong_ready')) {", + "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)", - " if (!mode %in% c('unready', 'wrong_ready')) {", - " dir.create(expected, recursive=TRUE, showWarnings=FALSE)", - " utils::install.packages(pkg, repos=NULL, type='source', lib=expected, quiet=TRUE)", - " }", + " 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 == 'wrong_ready') writeLines(mismatch, ready_path)", " if (mode %in% c('exact', 'prepared')) {", " result <- attach_sess(timeout=0)", " stopifnot(identical(result, TRUE), same_paths(.libPaths(), ordinary))", @@ -119,24 +129,20 @@ writeLines(c( " 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)", - " } else {", + " } 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))", - " if (mode == 'wrong_ready') stopifnot(identical(readLines(ready_path), mismatch))", - " else stopifnot(!file.exists(ready_path))", - " }", - "} else if (mode == 'twice') {", - " for (iteration in 1:2) {", - " set.seed(8841); seed <- .Random.seed", + " stopifnot(!file.exists(ready_path))", + " writeLines(mismatch, ready_path)", " result <- attach_sess(timeout=8)", - " stopifnot(identical(result, FALSE), identical(seed, .Random.seed))", - " stopifnot(same_paths(.libPaths(), ordinary))", + " 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)", " }", - " stopifnot(!dir.exists(file.path(normal, 'sess')))", - " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", "} else if (mode == 'mismatch') {", " utils::install.packages(pkg, repos=NULL, type='source', lib=normal, quiet=TRUE)", " desc_path <- file.path(normal, 'sess', 'DESCRIPTION')", @@ -149,34 +155,22 @@ writeLines(c( " stopifnot(same_paths(.libPaths(), ordinary))", " stopifnot(identical(read.dcf(desc_path), desc))", " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", - "} else if (mode %in% c('profile_exact', 'profile_prepared', 'profile_no_status')) {", - " expected <- if (mode %in% c('profile_exact', 'profile_no_status')) normal else", - " 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')))", - " baseline <- readRDS(file.path(root, 'profile-libraries.rds'))", - " if (!same_paths(.libPaths(), baseline)) stop(paste('library paths differ:',", - " paste(normalizePath(baseline), collapse=';'), 'vs',", - " paste(normalizePath(.libPaths()), collapse=';')))", - " stopifnot(file.exists(file.path(root, 'user-profile-ran')))", - " stopifnot(any(normalizePath(.libPaths()) == normalizePath(project_library)))", + " check_user_profile()", " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", - " if (mode == 'profile_prepared') stopifnot(identical(readLines(ready_path), revision))", - " if (mode == 'profile_no_status') stopifnot(!file.exists(startup_status))", - " else stopifnot(identical(readLines(startup_status), 'ready'))", + " stopifnot(identical(readLines(startup_status), 'ready'))", "} 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))", - " baseline <- readRDS(file.path(root, 'profile-libraries.rds'))", - " if (!same_paths(.libPaths(), baseline)) stop(paste('library paths differ:',", - " paste(normalizePath(baseline), collapse=';'), 'vs',", - " paste(normalizePath(.libPaths()), collapse=';')))", - " stopifnot(file.exists(file.path(root, 'user-profile-ran')))", + " check_user_profile()", " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", " stopifnot(!file.exists(ready_path))", " stopifnot(identical(readLines(startup_status), 'failed'))", @@ -189,44 +183,13 @@ writeLines(c( " stopifnot('sess' %in% loadedNamespaces())", " stopifnot(identical(sess_loaded_source_revision(), revision))", " stopifnot(same_paths(.libPaths(), ordinary))", - " baseline <- readRDS(file.path(root, 'profile-libraries.rds'))", - " if (!same_paths(.libPaths(), baseline)) stop(paste('library paths differ:',", - " paste(normalizePath(baseline), collapse=';'), 'vs',", - " paste(normalizePath(.libPaths()), collapse=';')))", - " stopifnot(file.exists(file.path(root, 'user-profile-ran')))", + " 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(readLines(startup_status), 'ready'))", " stopifnot(file.exists(file.path(wrong_library, 'sess', 'DESCRIPTION')))", - "} else if (mode == 'timeout') {", - " set.seed(8841); seed <- .Random.seed", - " result <- attach_sess(timeout=0)", - " stopifnot(identical(result, FALSE), identical(seed, .Random.seed))", - " stopifnot(same_paths(.libPaths(), ordinary))", - " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", - " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", - "} else if (mode == 'approve') {", - " wrong_library <- file.path(managed_root, 'fixture-other-platform',", - " '99.99', strrep('a', 40), 'library')", - " dir.create(wrong_library, recursive=TRUE, showWarnings=FALSE)", - " utils::install.packages(pkg, repos=NULL, type='source',", - " lib=wrong_library, quiet=TRUE)", - " set.seed(8841); seed <- .Random.seed", - " result <- attach_sess(timeout=20)", - " library <- sess_managed_library(managed_root, revision)", - " stopifnot(identical(result, TRUE), identical(seed, .Random.seed))", - " stopifnot(same_paths(.libPaths(), ordinary))", - " stopifnot(identical(sess_loaded_source_revision(), revision))", - " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", - " stopifnot(same_paths(actual, file.path(library, 'sess')))", - " stopifnot(file.exists(file.path(library, 'sess', 'DESCRIPTION')))", - " stopifnot(!dir.exists(file.path(normal, 'sess')))", - " wrong_desc <- file.path(wrong_library, 'sess', 'DESCRIPTION')", - " stopifnot(file.exists(wrong_desc))", - " stopifnot(identical(sess_source_revision(wrong_desc), revision))", - " stopifnot(identical(readLines(ready_path), revision))", "} else if (mode %in% c('parallel_approve', 'parallel_decline', 'parallel_third',", " 'parallel_failure', 'parallel_retry')) {", " timeout <- 10", @@ -263,7 +226,11 @@ writeLines(c( " stopifnot(!file.exists(ready_path))", "} else stop('unknown case')", "cat('manual attach case passed:', mode, '\\n')", - "if (nzchar(result_file)) writeLines('ok', result_file)" + "}", + "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") @@ -300,15 +267,19 @@ writeLines(c( 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_prepared", "profile_decline", "profile_approve", - "profile_no_status") + profile_modes <- c("profile_exact", "profile_decline", "profile_approve") startup <- mode %in% profile_modes if (startup) { normal <- file.path(case_root, "normal library") @@ -333,13 +304,10 @@ run_case <- function(mode) { "source(Sys.getenv('VSCODE_R_TEST_PROFILE'))" ), startup_profile) managed_library <- sess_managed_library(managed_root, revision) - install_library <- switch( - mode, - profile_exact = normal, - profile_prepared = managed_library, - profile_no_status = normal, - file.path(managed_root, - "fixture-other-platform", "99.99", strrep("a", 40), "library")) + 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") @@ -350,11 +318,8 @@ run_case <- function(mode) { } else { utils::install.packages(pkg, repos = NULL, type = "source", lib = install_library, quiet = TRUE) - if (mode == "profile_prepared") { - writeLines(revision, file.path(dirname(install_library), ".ready")) - } } - if (mode %in% c("profile_exact", "profile_prepared", "profile_no_status")) { + if (mode == "profile_exact") { unlink(consent, recursive = TRUE) } profile_environment <- c( @@ -368,9 +333,7 @@ run_case <- function(mode) { 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, - if (mode != "profile_no_status") { - c(VSCODE_R_SESS_STARTUP_FILE = startup_status) - }) + VSCODE_R_SESS_STARTUP_FILE = startup_status) profile_keys <- names(profile_environment) previous_environment <- Sys.getenv(profile_keys, unset = NA_character_) on.exit({ @@ -381,21 +344,13 @@ run_case <- function(mode) { }, add = TRUE) do.call(Sys.setenv, as.list(profile_environment)) } - consent_modes <- c("approve", "twice", "mismatch", "profile_decline", "profile_approve", - "unready", "wrong_ready") - if (mode %in% c("unready", "wrong_ready")) { - managed_root <- file.path(case_root, "vscode-R") - managed_library <- sess_managed_library(managed_root, revision) - dir.create(managed_library, recursive = TRUE) - utils::install.packages(pkg, repos = NULL, type = "source", lib = managed_library, quiet = TRUE) - } + 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 == "twice") 2L else 1L - approve_modes <- c("approve", "profile_approve") - answer <- if (mode %in% approve_modes) "approve" else "decline" + count <- if (mode == "markers") 2L else 1L + answer <- if (mode == "profile_approve") "approve" else "decline" release_marker <- if (mode %in% c("profile_decline", "profile_approve")) { file.path(case_root, "release-profile-consent") } else { @@ -438,10 +393,15 @@ run_case <- function(mode) { deadline <- Sys.time() + 30 while (!file.exists(result_file) && Sys.time() < deadline) Sys.sleep(0.01) if (!file.exists(result_file)) { - child_output <- c(readLines(stdout_path, warn = FALSE), readLines(stderr_path, warn = FALSE)) - stop(paste("profile startup child did not complete:", paste(child_output, collapse = "\n"))) + 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"))) } - stopifnot(identical(readLines(result_file, warn = FALSE), "ok")) } else { output <- suppressWarnings( system2( @@ -469,7 +429,7 @@ run_case <- function(mode) { } log_path <- file.path(case_root, "consent-ids") ids <- readLines(log_path, warn = FALSE) - expected_count <- if (mode == "twice") 2L else 1L + expected_count <- if (mode == "markers") 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") @@ -483,8 +443,7 @@ run_case <- function(mode) { } for (mode in c( - "twice", "mismatch", "exact", "prepared", "unready", "wrong_ready", "timeout", "approve", - "profile_exact", "profile_prepared", "profile_decline", "profile_approve", "profile_no_status" + "mismatch", "exact", "prepared", "markers", "profile_exact", "profile_decline", "profile_approve" )) run_case(mode) wait_for_file <- function(path, seconds, description) { @@ -574,9 +533,17 @@ start_parallel_child <- function(case_root, mode, role, result_file, install_hel } wait_for_child <- function(paths, description) { - wait_for_file(paths$result, 30, 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")) + if (!identical(result, "ok")) { + stop(paste(description, "failed:", paste(result, collapse = " "), + "child output:", paste(child_output, collapse = "\n"))) + } invisible(TRUE) } @@ -639,8 +606,8 @@ run_parallel_setup <- function(approve) { file.create(install_release) wait_for_file(install_complete, 10, "installer verification completion") } - wait_for_child(owner, "setup-lock owner") - wait_for_child(follower, "setup-lock follower") + 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) @@ -661,7 +628,7 @@ run_parallel_setup <- function(approve) { 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, "independent setup after decline") + 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) @@ -716,7 +683,7 @@ run_failed_install <- function() { first_result, failure_helper ) - wait_for_child(first, "failed sess setup") + 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)) @@ -731,7 +698,7 @@ run_failed_install <- function() { "retry", retry_result ) - wait_for_child(retry, "retry after failed sess setup") + 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) diff --git a/src/test/node/sessConsent.test.ts b/src/test/node/sessConsent.test.ts index 1c6cbd01d..9ba703e47 100644 --- a/src/test/node/sessConsent.test.ts +++ b/src/test/node/sessConsent.test.ts @@ -87,16 +87,6 @@ suite('sess install consent bridge', () => { assert.strictEqual(prompts, 0); }); - test('declines when the user dismisses the prompt', async () => { - let prompts = 0; - const broker = service(() => { prompts++; return Promise.resolve('dismiss'); }); - await broker.start(); - const id = '8'.repeat(32); - await writeRequest(directory, id); - assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); - assert.strictEqual(prompts, 1); - }); - test('Not now and dismissal do not suppress later prompts', async () => { let prompts = 0; const choices = ['notNow', 'dismiss'] as const; diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index de8257380..b71031bc6 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -14,8 +14,6 @@ import * as util from '../../util'; import { mockExtensionContext } from '../common/mockvscode'; import { deferred, SessionConnections, waitForValue as waitFor } from '../common/sessionConnections'; -const originalSetTimeout = globalThis.setTimeout; - async function settleWithFakeTime(promise: Promise, clock: sinon.SinonFakeTimers): Promise { let settled = false; void promise.then(() => { settled = true; }, () => { settled = true; }); @@ -31,11 +29,13 @@ suite('Session Terminal Lifecycle', () => { let ancestors: sinon.SinonStub; let connections: SessionConnections; - async function makeStartupFile(state: 'pending' | 'ready' | 'failed'): Promise<{ directory: string; file: string }> { + async function makeStartupFile(state: 'pending' | 'ready' | 'failed'): + Promise<{ directory: string; file: string; discoveryFile: string }> { const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'vscode-r-terminal-startup-')); - const file = path.join(directory, 'session.json.startup'); + const discoveryFile = path.join(directory, 'session.json'); + const file = `${discoveryFile}.startup`; await fsp.writeFile(file, `${state}\n`); - return { directory, file }; + return { directory, file, discoveryFile }; } async function waitForTimerDelay(timerSpy: sinon.SinonSpy, delay: number, startupFile: string): Promise<() => void> { @@ -51,6 +51,36 @@ suite('Session Terminal Lifecycle', () => { 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); + 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, + cleanup() { + close.fire(terminal); + rTerminal.deleteTerminal(terminal); + close.dispose(); + }, + }; + } + setup(() => { sandbox = sinon.createSandbox(); connections = new SessionConnections(); @@ -183,32 +213,14 @@ 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 () => { - const { directory, file: startupPath } = await makeStartupFile('ready'); - const discoveryPath = startupPath.slice(0, -'.startup'.length); - 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(session, 'getSessConsentDirectory').resolves('/unused-test-consent'); - sandbox.stub(session, 'createSessionDiscoveryFile').resolves(discoveryPath); - sandbox.stub(session, 'updateTerminalSessionDiscoveryFile').resolves(); + const { directory, discoveryFile, file: startupPath } = await makeStartupFile('ready'); + 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, _timeout, startupPath, allowRecovery) => waitUntilReady(terminal, 1000, startupPath, allowRecovery)); - 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); - const close = new vscode.EventEmitter(); - sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); + const { terminal, sendText, warning } = fixture; const sessionId = `source-readiness-${String(explicitBinding)}`; try { assert.strictEqual(await rTerminal.createRTerm(), true); @@ -225,12 +237,10 @@ suite('Session Terminal Lifecycle', () => { sinon.assert.calledOnceWithExactly(sendText, 'source("example.R")'); sinon.assert.notCalled(warning); } finally { - close.fire(terminal); - rTerminal.deleteTerminal(terminal); + fixture.cleanup(); try { await session.cleanupSession(sessionId); } finally { - close.dispose(); await fsp.rm(directory, { recursive: true, force: true }); } } @@ -415,26 +425,11 @@ suite('Session Terminal Lifecycle', () => { }); test('pending startup holds queued terminal input beyond 30 seconds until ready and this terminal attaches', async () => { - const { directory, file: startupFile } = await makeStartupFile('pending'); - const discoveryFile = startupFile.slice(0, -'.startup'.length); + const { directory, file: startupFile, discoveryFile } = await makeStartupFile('pending'); const rPid = 46291; const otherPid = 46292; - 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: true, '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); - sandbox.stub(vscode.window, 'createTerminal').returns(terminal); + const fixture = makeManagedTerminal(rPid, discoveryFile); + const { sendText } = fixture; const waitForReady = session.waitForTerminalReady; let readinessStarted!: () => void; const started = new Promise(resolve => { readinessStarted = resolve; }); @@ -442,8 +437,6 @@ suite('Session Terminal Lifecycle', () => { readinessStarted(); return waitForReady(current, timeout, startupPath, allowRecovery); }); - 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'); @@ -483,40 +476,20 @@ suite('Session Terminal Lifecycle', () => { ['\x1b[200~second\x1b[201~', true], ]); } finally { - close.fire(terminal); - rTerminal.deleteTerminal(terminal); + fixture.cleanup(); timerSandbox.restore(); if (commandsSettled) { await commandsSettled; } 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 { directory, file: startupFile } = await makeStartupFile('pending'); - const discoveryFile = startupFile.slice(0, -'.startup'.length); + const { directory, file: startupFile, discoveryFile } = await makeStartupFile('pending'); const rPid = state === 'ready' ? 46294 : 46295; - 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: true, '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); - 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); + const fixture = makeManagedTerminal(rPid, discoveryFile); + const { sendText, warning } = fixture; const timerSandbox = sinon.createSandbox(); let commandSettled: Promise> | undefined; @@ -549,12 +522,10 @@ suite('Session Terminal Lifecycle', () => { sinon.assert.calledOnce(warning); } } finally { - close.fire(terminal); - rTerminal.deleteTerminal(terminal); + fixture.cleanup(); timerSandbox.restore(); if (commandSettled) { await commandSettled; } await connections.dispose(); - close.dispose(); await fsp.rm(directory, { recursive: true, force: true }); } }); @@ -601,26 +572,10 @@ suite('Session Terminal Lifecycle', () => { }); test('failed startup declines input before late attach, while a fresh attached-terminal retry can proceed', async () => { - const { directory, file: startupFile } = await makeStartupFile('pending'); - const discoveryFile = startupFile.slice(0, -'.startup'.length); + const { directory, file: startupFile, discoveryFile } = await makeStartupFile('pending'); const rPid = 46293; - 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: true, '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); - sandbox.stub(vscode.window, 'createTerminal').returns(terminal); - const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const fixture = makeManagedTerminal(rPid, discoveryFile); + const { terminal, sendText, warning } = fixture; const waitForReady = session.waitForTerminalReady; let readinessStarted!: () => void; const started = new Promise(resolve => { readinessStarted = resolve; }); @@ -628,8 +583,6 @@ suite('Session Terminal Lifecycle', () => { readinessStarted(); return waitForReady(current, timeout, startupPath, allowRecovery); }); - 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'); @@ -670,12 +623,10 @@ suite('Session Terminal Lifecycle', () => { 'a fresh terminal run enables recovery from the earlier failed startup'); sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~retry after attach\x1b[201~', true); } finally { - close.fire(terminal); - rTerminal.deleteTerminal(terminal); + fixture.cleanup(); timerSandbox.restore(); if (pendingCommandSettled) { await pendingCommandSettled; } await connections.dispose(); - close.dispose(); await fsp.rm(directory, { recursive: true, force: true }); } }); @@ -725,18 +676,4 @@ suite('Session Terminal Lifecycle', () => { } }); - test('global Sinon restore leaves startup timers usable', async () => { - try { - // Other suites call the default Sinon sandbox restore after this suite. - // It must not reinstate a fake clock or spy from a completed test. - sinon.restore(); - assert.strictEqual(globalThis.setTimeout, originalSetTimeout); - await new Promise(resolve => globalThis.setTimeout(resolve, 1)); - } finally { - if (globalThis.setTimeout !== originalSetTimeout) { - globalThis.setTimeout = originalSetTimeout; - } - } - }); - }); From 521c33d660ba7e513d7248a9a66937b293dedfb4 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 10:18:28 +0000 Subject: [PATCH 16/21] style: use consistent braces in sess test fixture --- R/tests/attach_sess.R | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index 0b4e06be0..6386c40fe 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -304,7 +304,9 @@ run_case <- function(mode) { "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 { + install_library <- if (mode == "profile_exact") { + normal + } else { file.path(managed_root, "fixture-other-platform", "99.99", strrep("a", 40), "library") } From 88d8e09320a1c5ec791575c17bec97c750d67f0e Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 12:31:21 +0000 Subject: [PATCH 17/21] fix: separate terminal startup from sess setup --- R/attach_sess.R | 47 ++++- R/profile.R | 86 ++++---- R/terminal-startup.R | 145 +++++++++++++ R/tests/attach_sess.R | 88 ++++++-- sess/DESCRIPTION | 2 +- sess/R/server.R | 6 +- sess/inst/tinytest/test-connect-failure.R | 5 + sess/man/connect.Rd | 4 + src/rTerminal.ts | 106 ++++++---- src/session.ts | 164 +++++++++------ src/terminalStartup.ts | 57 ++++++ src/test/node/terminalStartup.test.ts | 58 ++++++ src/test/suite/session.test.ts | 4 + .../suite/sessionTerminalLifecycle.test.ts | 192 ++++++++++++------ src/test/suite/terminal.test.ts | 107 +++++++--- 15 files changed, 834 insertions(+), 237 deletions(-) create mode 100644 R/terminal-startup.R create mode 100644 src/terminalStartup.ts create mode 100644 src/test/node/terminalStartup.test.ts diff --git a/R/attach_sess.R b/R/attach_sess.R index 2a86734aa..e5b0c33d7 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -179,12 +179,45 @@ 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) { - 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)) + 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)) } - get("connect", envir = ns, inherits = FALSE)(endpoint = endpoint, plot_backend = plot_backend) - invisible(TRUE) + attach() } diff --git a/R/profile.R b/R/profile.R index 46a122056..cec75ef30 100644 --- a/R/profile.R +++ b/R/profile.R @@ -26,31 +26,9 @@ local({ local({ initialize_sess <- function() { - startup_file <- Sys.getenv("VSCODE_R_SESS_STARTUP_FILE", unset = "") - write_startup_status <- function(status) { - if (!nzchar(startup_file)) { - return(invisible(FALSE)) - } - temporary <- "" - on.exit(if (nzchar(temporary)) suppressWarnings(unlink(temporary)), add = TRUE) - tryCatch(suppressWarnings({ - temporary <- tempfile(pattern = ".startup-", tmpdir = dirname(startup_file)) - writeLines(status, temporary, useBytes = TRUE) - if (.Platform$OS.type == "unix") { - Sys.chmod(temporary, "0600") - } - isTRUE(file.rename(temporary, startup_file)) - }), error = function(error) FALSE) - } - completed <- FALSE - if (nzchar(startup_file)) { - write_startup_status("pending") - on.exit(write_startup_status(if (completed) "ready" else "failed"), add = TRUE) - } - bundled_path <- Sys.getenv("VSCODE_R_SESS_PKG_PATH", unset = "") if (!nzchar(bundled_path)) { - return(invisible(NULL)) + return(invisible(FALSE)) } helper <- Sys.getenv("VSCODE_R_SESS_SOURCE_HELPER", unset = "") @@ -63,26 +41,58 @@ local({ 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(NULL)) + return(invisible(FALSE)) } source(attach_helper, local = TRUE) - ns <- vscode_r_prepare_sess( - bundled_path, managed_root, consent_directory, helper, installer_helper) + ns <- vscode_r_prepare_sess(bundled_path, managed_root, consent_directory, helper, installer_helper) if (is.null(ns)) { - return(invisible(NULL)) + return(invisible(FALSE)) } plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") - result <- get("connect", envir = ns, inherits = FALSE)( - use_rstudioapi = as.logical(Sys.getenv("SESS_RSTUDIOAPI", "TRUE")), - plot_backend = plot_backend - ) - completed <- TRUE - result + 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) + }) } - tryCatch(initialize_sess(), error = function(error) { - message("vscode-R could not start the session watcher: ", conditionMessage(error)) - }, interrupt = function(error) { - message("vscode-R session watcher startup was interrupted: ", conditionMessage(error)) - }) }) diff --git a/R/terminal-startup.R b/R/terminal-startup.R new file mode 100644 index 000000000..73161d1c5 --- /dev/null +++ b/R/terminal-startup.R @@ -0,0 +1,145 @@ +# 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_r_terminal_startup_option <- "vscodeR.terminalStartup" +.vscode_r_terminal_startup_protocol <- "vscode-r-terminal-startup-v1" + +.vscode_r_terminal_startup_valid <- function(file, token) { + length(file) == 1L && !is.na(file) && nzchar(file) && + length(token) == 1L && !is.na(token) && grepl("^[[:xdigit:]]{32}$", token) +} + +.vscode_r_terminal_startup_valid_endpoint <- function(endpoint) { + length(endpoint) == 1L && !is.na(endpoint) && nzchar(endpoint) && + !grepl("[\r\n]", endpoint) +} + +.vscode_r_terminal_startup_registered <- function(context) { + registered <- getOption(.vscode_r_terminal_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()) && + .vscode_r_terminal_startup_valid_endpoint(context$endpoint) +} + +.vscode_r_terminal_startup_write <- function(context, attempt, state) { + lines <- c(.vscode_r_terminal_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_r_terminal_startup_valid(file, token) || !.vscode_r_terminal_startup_valid_endpoint(endpoint)) { + return(NULL) + } + context <- list( + file = file, + token = tolower(token), + endpoint = endpoint, + pid = Sys.getpid(), + attempt = NULL + ) + options(structure(list(context), names = .vscode_r_terminal_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_r_terminal_startup_valid_endpoint(endpoint)) { + return(NULL) + } + registered <- getOption(.vscode_r_terminal_startup_option) + if (!is.list(registered)) { + return(NULL) + } + if (!.vscode_r_terminal_startup_valid(registered$file, registered$token)) { + return(NULL) + } + if (!identical(registered$pid, Sys.getpid())) { + 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_r_terminal_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_r_terminal_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_r_terminal_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_r_terminal_startup_registered(context)) { + return(invisible(FALSE)) + } + registered <- getOption(.vscode_r_terminal_startup_option) + if (!identical(registered$attempt, attempt)) { + return(invisible(FALSE)) + } + if (!identical(registered$endpoint, context$endpoint)) { + return(invisible(FALSE)) + } + if (!.vscode_r_terminal_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 index 6386c40fe..dcfee3ffe 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -16,8 +16,7 @@ writeLines(c( paste0("Config/vscode-R/source-revision: ", revision) ), file.path(pkg, "DESCRIPTION")) writeLines(c( - "connect <- function(endpoint=NULL, plot_backend=NULL, ...) list(", - " endpoint=endpoint, plot_backend=plot_backend, ...)", + "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', '')", @@ -43,6 +42,7 @@ writeLines(c( "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')", @@ -95,14 +95,26 @@ writeLines(c( "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(timeout=180, setup_timeout=8) {", - " vscode_r_attach_sess('endpoint', pkg, managed_root, consent,", + "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'),", @@ -129,6 +141,13 @@ writeLines(c( " 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))", + " }", " } else if (mode == 'markers') {", " description <- file.path(expected, 'sess', 'DESCRIPTION')", " before <- readLines(description, warn=FALSE)", @@ -164,7 +183,7 @@ writeLines(c( " stopifnot(same_paths(actual, file.path(expected, 'sess')))", " check_user_profile()", " stopifnot(length(list.files(consent, pattern='\\\\.request$')) == 0L)", - " stopifnot(identical(readLines(startup_status), 'ready'))", + " 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')", @@ -173,9 +192,33 @@ writeLines(c( " check_user_profile()", " stopifnot(!dir.exists(sess_managed_library(managed_root, revision)))", " stopifnot(!file.exists(ready_path))", - " stopifnot(identical(readLines(startup_status), 'failed'))", + " 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')", @@ -188,7 +231,7 @@ writeLines(c( " stopifnot(same_paths(actual, file.path(library, 'sess')))", " stopifnot(file.exists(file.path(library, 'sess', 'DESCRIPTION')))", " stopifnot(identical(readLines(ready_path), revision))", - " stopifnot(identical(readLines(startup_status), 'ready'))", + " 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')) {", @@ -237,9 +280,16 @@ agent <- file.path(root, "consent-agent.R") writeLines(c( "args <- commandArgs(TRUE)", "directory <- args[[1L]]; count <- as.integer(args[[2L]])", - "answer <- args[[3L]]; log <- args[[4L]]; identity_log <- args[[5L]]", + "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)", @@ -250,6 +300,7 @@ writeLines(c( " 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')", @@ -335,7 +386,10 @@ run_case <- function(mode) { 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_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({ @@ -351,8 +405,9 @@ run_case <- function(mode) { 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 == "markers") 2L else 1L - answer <- if (mode == "profile_approve") "approve" else "decline" + 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 { @@ -390,7 +445,14 @@ run_case <- function(mode) { if (!length(requests)) Sys.sleep(0.01) } if (!length(requests)) stop("profile startup did not publish a consent request") - stopifnot(identical(readLines(startup_status, warn = FALSE), "pending")) + 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) @@ -431,7 +493,7 @@ run_case <- function(mode) { } log_path <- file.path(case_root, "consent-ids") ids <- readLines(log_path, warn = FALSE) - expected_count <- if (mode == "markers") 2L else 1L + 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") 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..92a9d1c0c 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. 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/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/rTerminal.ts b/src/rTerminal.ts index 58f9dec8a..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'; @@ -31,24 +32,35 @@ 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, 30000, startup.startupFilePath, startup.failed ?? false) + ? await waitForTerminalReady(terminal, 30000, startup.startupFilePath, startup.startupToken) : await delay(200).then(() => !isTerminalClosed(terminal) && !terminal.exitStatus); - startup.ready = ready; - startup.failed = !ready; + startup.gateComplete = ready; if (!ready && !isTerminalClosed(terminal) && !terminal.exitStatus) { 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.'); } @@ -256,33 +268,55 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise('sessionWatcher')) { - const pipePath = await getGlobalPipePath(); - const consentDirectory = await getSessConsentDirectory(); - const discoveryFile = await createSessionDiscoveryFile(pipePath); - const startupFile = `${discoveryFile}.startup`; - const backend = resolveBackend(); - termOptions.env = { - 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, - // 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; @@ -328,8 +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); diff --git a/src/session.ts b/src/session.ts index 68bd5b2c0..0cbb51b4f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -28,6 +28,12 @@ import { getDataViewerScript, getDataViewerStyle, getDataViewerToolbarHtml } fro import { getDataViewerColumnPanelHtml, getDataViewerColumnPanelScript, getDataViewerColumnPanelStyle } from './dataViewerColumnPanel'; import { formatSessionLabel, getViewerSessionScript } from './viewerSession'; import { SessConsentChoice, SessConsentService } from './sessConsent'; +import { + assessTerminalStartup, + parseTerminalStartupRecord, + TerminalStartupRecord, + TerminalStartupStatus, +} from './terminalStartup'; export interface SessionInfo { version: string; @@ -213,22 +219,18 @@ export function registerSessionTransport(id: string, host: string, directory: st sessions.set(id, target); return target; } -type TerminalStartupState = 'pending' | 'ready' | 'failed'; const SESS_SETUP_TIMEOUT = 600000; +const TERMINAL_ATTACH_TIMEOUT = 30000; -/** Read the bounded, one-line status sidecar written by R/profile.R. */ -async function readTerminalStartupState(filePath: string): Promise { +/** 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(33); + const buffer = Buffer.alloc(513); const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); - if (bytesRead > 32) { return undefined; } - let value = buffer.toString('utf8', 0, bytesRead); - if (value.endsWith('\r\n')) { value = value.slice(0, -2); } - else if (value.endsWith('\n')) { value = value.slice(0, -1); } - if (value.includes('\n') || value.includes('\r')) { return undefined; } - return value === 'pending' || value === 'ready' || value === 'failed' ? value : undefined; + if (bytesRead > 512) { return undefined; } + return parseTerminalStartupRecord(buffer.toString('utf8', 0, bytesRead), token); } catch { return undefined; } finally { @@ -237,97 +239,136 @@ async function readTerminalStartupState(filePath: string): Promise { + startupToken?: string): Promise { return new Promise(resolve => { let settled = false; - let observedState: TerminalStartupState | undefined; + 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 checkingOwner = false; + let checkingRecord = false; const finish = (ready: boolean) => { if (settled) { return; } settled = true; 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 hasOwner = () => Boolean(terminalRegistry.ownerOf(terminal)); const checkOwner = () => { if (settled) { return; } if (isClosed()) { finish(false); } else if (observedState === 'failed') { finish(false); } - else if ((!startupFilePath || observedState === 'ready') && hasOwner()) { finish(true); } + 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 setState = (state: TerminalStartupState) => { - if (settled || observedState === 'failed') { return; } - if (state === 'failed' && allowAttachedRecovery && observedState === undefined && hasOwner()) { - state = 'ready'; + 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 (state === 'failed') { + if (!record) { return; } + latestRecord = record; + if (observedAttemptId === undefined) { observedAttemptId = decision.attemptId; } + if (observation === 'failed') { observedState = 'failed'; finish(false); - } else if (state === 'pending') { - if (observedState === undefined) { + } else if (observation === 'pending') { + if (observedState !== 'pending') { observedState = 'pending'; if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; } pendingTimer = setTimeout(() => finish(false), SESS_SETUP_TIMEOUT); } - } else if (state === 'ready') { + } else if (observation === 'ready') { if (observedState !== 'ready') { observedState = 'ready'; if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; } if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = undefined; } - startupTimer = setTimeout(() => finish(false), timeout); + attachTimer = setTimeout(() => finish(false), Math.min(timeout, TERMINAL_ATTACH_TIMEOUT)); } - checkOwner(); + 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 (settled) { return; } if (isClosed()) { finish(false); return; } - if (!hasOwner()) { return; } - if (!startupFilePath || observedState !== undefined) { checkOwner(); return; } - if (checkingOwner) { return; } - checkingOwner = true; - void readTerminalStartupState(startupFilePath).then(state => { - checkingOwner = false; - if (settled) { return; } - if (state) { setState(state); } - if (!settled) { checkOwner(); } - }).catch(() => { - checkingOwner = false; - if (!settled) { checkOwner(); } - }); + 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 missing or invalid state keeps the ordinary attachment timeout. + // Missing or invalid state keeps the ordinary attachment timeout. startupTimer = setTimeout(() => finish(false), timeout); - // Keep the historical immediate path for terminals without profile setup. - // Managed terminals must observe their sidecar before using an existing owner. - if (!startupFilePath && hasOwner()) { finish(true); return; } - if (startupFilePath) { - const poll = async () => { - if (settled) { return; } - const state = await readTerminalStartupState(startupFilePath); - if (settled) { return; } - if (state) { setState(state); } - if (!settled) { pollTimer = setTimeout(() => { void poll(); }, 100); } - }; - void poll().catch(() => undefined); + // Preserve the historical immediate path for terminals without profile setup. + if (!startupFilePath) { checkOwner(); } + if (startupFilePath && startupToken) { + beginRead(); } check(); }); @@ -603,8 +644,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 { @@ -952,10 +1000,12 @@ function buildAttachSessionScript(pipePath: string, sessPath: string, consentDir ` 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(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)})`, '})', 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/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/session.test.ts b/src/test/suite/session.test.ts index 5bbfb5b39..26b03b720 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -639,6 +639,10 @@ suite('Session Communication', () => { const scriptPath = JSON.parse(commandMatch[1]) as string; const scriptContent = await fsp.readFile(scriptPath, 'utf8'); + 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/); diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index b71031bc6..d45707283 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -29,13 +29,35 @@ suite('Session Terminal Lifecycle', () => { let ancestors: sinon.SinonStub; let connections: SessionConnections; - async function makeStartupFile(state: 'pending' | 'ready' | 'failed'): - Promise<{ directory: string; file: string; discoveryFile: string }> { + 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`; - await fsp.writeFile(file, `${state}\n`); - return { directory, file, discoveryFile }; + 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> { @@ -67,12 +89,12 @@ suite('Session Terminal Lifecycle', () => { sandbox.stub(session, 'updateTerminalSessionDiscoveryFile').resolves(); sandbox.stub(vscode.window, 'terminals').value([terminal]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); - sandbox.stub(vscode.window, 'createTerminal').returns(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, + terminal, sendText, warning, close, create, cleanup() { close.fire(terminal); rTerminal.deleteTerminal(terminal); @@ -213,17 +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 () => { - const { directory, discoveryFile, file: startupPath } = await makeStartupFile('ready'); + 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, _timeout, startupPath, allowRecovery) => - waitUntilReady(terminal, 1000, startupPath, allowRecovery)); + .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); @@ -233,7 +257,7 @@ 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, 30000, startupPath, false); + sinon.assert.calledOnceWithExactly(readiness, terminal, 30000, startupPath, token); sinon.assert.calledOnceWithExactly(sendText, 'source("example.R")'); sinon.assert.notCalled(warning); } finally { @@ -402,14 +426,14 @@ suite('Session Terminal Lifecycle', () => { }); test('terminal readiness aborts on close even before processId resolves', async () => { - const { directory, file } = await makeStartupFile('pending'); + const { directory, file, token } = await makeStartupFile('pending'); const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.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); + const waiting = session.waitForTerminalReady(terminal, 30000, file, token); try { await waitForTimerDelay(timerSpy, 600000, file); close.fire(terminal); @@ -425,17 +449,17 @@ suite('Session Terminal Lifecycle', () => { }); test('pending startup holds queued terminal input beyond 30 seconds until ready and this terminal attaches', async () => { - const { directory, file: startupFile, discoveryFile } = await makeStartupFile('pending'); 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, allowRecovery) => { + const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath, startupToken) => { readinessStarted(); - return waitForReady(current, timeout, startupPath, allowRecovery); + return waitForReady(current, timeout, startupPath, startupToken); }); const timerSandbox = sinon.createSandbox(); const clock = timerSandbox.useFakeTimers(); @@ -444,6 +468,8 @@ suite('Session Terminal Lifecycle', () => { 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]; @@ -452,13 +478,13 @@ suite('Session Terminal Lifecycle', () => { const firstResult = first.then(value => { firstSettled = true; return value; }); await started; assert.strictEqual(readyStub.firstCall.args[2], startupFile); - assert.strictEqual(readyStub.firstCall.args[3], false); + 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, 'ready\n'); + 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'); @@ -484,10 +510,59 @@ suite('Session Terminal Lifecycle', () => { } }); + 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); + 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 { directory, file: startupFile, discoveryFile } = await makeStartupFile('pending'); 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(); @@ -495,6 +570,8 @@ suite('Session Terminal Lifecycle', () => { 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); @@ -505,7 +582,7 @@ suite('Session Terminal Lifecycle', () => { await waitForTimerDelay(timerSpy, 600000, startupFile); sinon.assert.notCalled(sendText); - await fsp.writeFile(startupFile, `${state}\n`); + 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++) { @@ -531,57 +608,56 @@ suite('Session Terminal Lifecycle', () => { }); } - test('attached-terminal recovery still declines a failure after observing pending', async () => { - const { directory, file } = await makeStartupFile('pending'); + test('a fresh attached attempt recovers before the first input while the same failed attempt stays failed', async () => { const rPid = 46296; - const terminal = { processId: Promise.resolve(rPid) } as unknown as vscode.Terminal; + 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); - const close = new vscode.EventEmitter(); - sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); - const timerSandbox = sinon.createSandbox(); - let waiting: Promise | undefined; - + 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); - const clock = timerSandbox.useFakeTimers(); - const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); - waiting = session.waitForTerminalReady(terminal, 30000, file, true); - await waitForTimerDelay(timerSpy, 600000, file); - await fsp.writeFile(file, 'failed\n'); - let settled = false; - void waiting.then(() => { settled = true; }); - for (let attempt = 0; attempt < 100 && !settled; attempt++) { - await clock.tickAsync(100); - await fsp.readFile(file); - } - assert.strictEqual(settled, true); - assert.strictEqual(await waiting, false, - 'only a failure that is stale at the start of recovery can be bypassed'); - assert.strictEqual(clock.countTimers(), 0); + 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 { - close.fire(terminal); - timerSandbox.restore(); - if (waiting) { await waiting; } await connections.dispose(); - close.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 { directory, file: startupFile, discoveryFile } = await makeStartupFile('pending'); const rPid = 46293; + const { directory, file: startupFile, discoveryFile, endpoint } = await makeStartupFile('pending', rPid); const fixture = makeManagedTerminal(rPid, discoveryFile); - const { terminal, sendText, warning } = fixture; + 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, allowRecovery) => { + const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath, startupToken) => { readinessStarted(); - return waitForReady(current, timeout, startupPath, allowRecovery); + return waitForReady(current, timeout, startupPath, startupToken); }); const timerSandbox = sinon.createSandbox(); const clock = timerSandbox.useFakeTimers(); @@ -591,6 +667,8 @@ suite('Session Terminal Lifecycle', () => { 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; @@ -599,9 +677,9 @@ suite('Session Terminal Lifecycle', () => { pendingCommandSettled = Promise.allSettled([pendingCommand]).then(results => results[0]); await started; assert.strictEqual(readyStub.firstCall.args[2], startupFile); - assert.strictEqual(readyStub.firstCall.args[3], false); + assert.strictEqual(readyStub.firstCall.args[3], token); await waitForTimerDelay(timerSpy, 600000, startupFile); - await fsp.writeFile(startupFile, 'failed\n'); + 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); @@ -616,11 +694,9 @@ suite('Session Terminal Lifecycle', () => { timerSandbox.restore(); await connections.attach('startup-late-attach', rPid); assert.strictEqual(sendText.called, false, 'late attach cannot release the already failed input'); - assert.strictEqual(await session.waitForTerminalReady(terminal, 1000, startupFile, true), true, - 'a new wait on an attached terminal permits manual recovery despite the stale failed state'); + await fsp.writeFile(startupFile, startupRecord('ready', rPid, endpoint, token, 'attempt_111111111111')); assert.strictEqual(await rTerminal.runTextInTerm('retry after attach'), true); - assert.strictEqual(readyStub.lastCall.args[3], true, - 'a fresh terminal run enables recovery from the earlier failed startup'); + assert.strictEqual(readyStub.callCount, 2); sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~retry after attach\x1b[201~', true); } finally { fixture.cleanup(); @@ -654,14 +730,14 @@ suite('Session Terminal Lifecycle', () => { }); test('pending startup has a finite 10 minute deadline', async () => { - const { directory, file } = await makeStartupFile('pending'); + const { directory, file, token } = await makeStartupFile('pending'); const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.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); + const waiting = session.waitForTerminalReady(terminal, 30000, file, token); try { const expire = await waitForTimerDelay(timerSpy, 600000, file); expire(); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index beeed5411..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, '..', '..', '..'); @@ -97,7 +98,7 @@ suite('R Terminal', () => { sessionWatcher: watcher, consoleArgs: args, bracketedPaste: true, 'source.focus': 'none' })); sandbox.stub(util, 'getRterm').resolves(process.execPath); - 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 = { @@ -122,16 +123,20 @@ suite('R Terminal', () => { assert.deepStrictEqual(sent, []); sinon.assert.calledOnce(create); if (integrated) { - sinon.assert.calledOnceWithExactly(readyStub, terminal, 30000, '/unused-test-discovery.startup', false); + 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, watcher); + assert.strictEqual(consentDirectoryStub.called, integrated); await rTerminal.runTextInTerm('fourth'); assert.strictEqual(sent.length, 4); sinon.assert.calledOnce(create); @@ -158,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); @@ -172,8 +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', false]); - assert.deepStrictEqual(ready.secondCall.args, [terminal, 30000, '/unused-test-discovery.startup', 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); }); @@ -654,33 +661,81 @@ suite('R Terminal', () => { rTerminal.deleteTerminal(terminal); }); - test('createRTerm removes its discovery file when the configured executable is invalid', 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; + 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`); - try { - assert.strictEqual(await rTerminal.createRTerm(), false); - 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 }))); - await Promise.all(createdStartupFiles.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 () => { From 3aa6dc4a876cd3277e2e13ffc6b130cba022729c Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 12:33:58 +0000 Subject: [PATCH 18/21] style: fix terminal startup R lint warnings --- R/attach_sess.R | 2 +- R/terminal-startup.R | 42 +++++++++++++++++++++--------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/R/attach_sess.R b/R/attach_sess.R index e5b0c33d7..2f23f844c 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -208,7 +208,7 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, attach <- function() { ns <- vscode_r_prepare_sess(pkg_path, managed_root, consent_dir, source_helper, installer_helper, - timeout_seconds, setup_timeout_seconds) + timeout_seconds, setup_timeout_seconds) if (is.null(ns)) { return(invisible(FALSE)) } diff --git a/R/terminal-startup.R b/R/terminal-startup.R index 73161d1c5..09ccf012a 100644 --- a/R/terminal-startup.R +++ b/R/terminal-startup.R @@ -1,32 +1,32 @@ # 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_r_terminal_startup_option <- "vscodeR.terminalStartup" -.vscode_r_terminal_startup_protocol <- "vscode-r-terminal-startup-v1" +.vscode_startup_option <- "vscodeR.terminalStartup" +.vscode_startup_protocol <- "vscode-r-terminal-startup-v1" -.vscode_r_terminal_startup_valid <- function(file, token) { +.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_r_terminal_startup_valid_endpoint <- function(endpoint) { +.vscode_startup_valid_endpoint <- function(endpoint) { length(endpoint) == 1L && !is.na(endpoint) && nzchar(endpoint) && !grepl("[\r\n]", endpoint) } -.vscode_r_terminal_startup_registered <- function(context) { - registered <- getOption(.vscode_r_terminal_startup_option) +.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()) && - .vscode_r_terminal_startup_valid_endpoint(context$endpoint) + .vscode_startup_valid_endpoint(context$endpoint) } -.vscode_r_terminal_startup_write <- function(context, attempt, state) { - lines <- c(.vscode_r_terminal_startup_protocol, context$token, attempt, - as.character(context$pid), context$endpoint, state) +.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({ @@ -46,7 +46,7 @@ # 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_r_terminal_startup_valid(file, token) || !.vscode_r_terminal_startup_valid_endpoint(endpoint)) { + if (!.vscode_startup_valid(file, token) || !.vscode_startup_valid_endpoint(endpoint)) { return(NULL) } context <- list( @@ -56,21 +56,21 @@ vscode_r_startup_register <- function(file, token, endpoint) { pid = Sys.getpid(), attempt = NULL ) - options(structure(list(context), names = .vscode_r_terminal_startup_option)) + 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_r_terminal_startup_valid_endpoint(endpoint)) { + if (!.vscode_startup_valid_endpoint(endpoint)) { return(NULL) } - registered <- getOption(.vscode_r_terminal_startup_option) + registered <- getOption(.vscode_startup_option) if (!is.list(registered)) { return(NULL) } - if (!.vscode_r_terminal_startup_valid(registered$file, registered$token)) { + if (!.vscode_startup_valid(registered$file, registered$token)) { return(NULL) } if (!identical(registered$pid, Sys.getpid())) { @@ -83,7 +83,7 @@ vscode_r_startup_existing <- function(endpoint) { # 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_r_terminal_startup_registered(context)) { + if (!.vscode_startup_registered(context)) { return(NULL) } candidate <- gsub("[^A-Za-z0-9_-]", "", basename(tempfile(pattern = "attempt-"))) @@ -93,12 +93,12 @@ vscode_r_startup_begin <- function(context) { message("vscode-R could not create a terminal startup attempt ID.") return(NULL) } - if (!.vscode_r_terminal_startup_write(context, attempt, "pending")) { + 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_r_terminal_startup_option)) + options(structure(list(context), names = .vscode_startup_option)) attempt } @@ -108,17 +108,17 @@ vscode_r_startup_finish <- function(context, attempt, state) { if (!state %in% c("ready", "failed")) { return(invisible(FALSE)) } - if (!.vscode_r_terminal_startup_registered(context)) { + if (!.vscode_startup_registered(context)) { return(invisible(FALSE)) } - registered <- getOption(.vscode_r_terminal_startup_option) + registered <- getOption(.vscode_startup_option) if (!identical(registered$attempt, attempt)) { return(invisible(FALSE)) } if (!identical(registered$endpoint, context$endpoint)) { return(invisible(FALSE)) } - if (!.vscode_r_terminal_startup_write(context, attempt, state)) { + if (!.vscode_startup_write(context, attempt, state)) { message("vscode-R could not publish terminal startup completion; status remains pending.") return(invisible(FALSE)) } From 1f646afa90fe776644d26af21480e7ce27efb5c1 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 12:55:00 +0000 Subject: [PATCH 19/21] test: synchronize terminal and hover fixtures --- src/test/suite/interactiveEditor.test.ts | 8 ++++++-- src/test/suite/sessionTerminalLifecycle.test.ts | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index 79716b2b0..b0e86c642 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -16,6 +16,7 @@ import { AssetStorageStats, exportedAssetName, readAsset } from '../../interacti import { resolveExecutable } from '../../interactive/executable'; import { shellQuote } from '../../interactive/launcher'; import type { InteractiveManager } from '../../interactive/manager'; +import * as session from '../../session'; import type { GlobalEnvItem, WorkspaceDataProvider } from '../../workspaceViewer'; import type { WorkspaceData } from '../../session'; import type { LanguageClient } from 'vscode-languageclient/node'; @@ -82,10 +83,10 @@ 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 = '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(timeoutMessage); } await new Promise(resolve => setTimeout(resolve, 50)); } }; @@ -638,6 +639,9 @@ 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); + await until(() => session.activeSession?.sessionId === `${manifests[0].id}:${manifests[0].generation}` + && session.activeSession.workspaceData.globalenv.interactive_signature?.type === 'closure', + 'Timed out waiting for the Interactive workspace snapshot'); 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(); diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index d45707283..b812a48a6 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -428,6 +428,7 @@ 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(); @@ -732,6 +733,7 @@ suite('Session Terminal Lifecycle', () => { 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'); From b30991f368cf210440c3bad3db2fdbbdf5d3f4db Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 12:56:40 +0000 Subject: [PATCH 20/21] fix: publish sess startup state on automatic reconnect --- R/terminal-startup.R | 11 ++++++++ R/tests/attach_sess.R | 44 +++++++++++++++++++++++++++++ sess/R/server.R | 16 ++++++++++- sess/inst/tinytest/test-reconnect.R | 43 +++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/R/terminal-startup.R b/R/terminal-startup.R index 09ccf012a..d9dc035ac 100644 --- a/R/terminal-startup.R +++ b/R/terminal-startup.R @@ -21,6 +21,7 @@ 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) } @@ -56,6 +57,13 @@ vscode_r_startup_register <- function(file, token, 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 } @@ -76,6 +84,9 @@ vscode_r_startup_existing <- function(endpoint) { if (!identical(registered$pid, Sys.getpid())) { return(NULL) } + if (!is.function(registered$run)) { + return(NULL) + } registered$endpoint <- endpoint registered } diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index dcfee3ffe..007e3d988 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -147,6 +147,50 @@ writeLines(c( " 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')", diff --git a/sess/R/server.R b/sess/R/server.R index 92a9d1c0c..e84dda88e 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -283,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 @@ -303,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/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) + }) From 8a05ab176d656b6deacf49a0b73c90dfe6e71632 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 10 Oct 2026 13:11:14 +0000 Subject: [PATCH 21/21] test: read Interactive workspace from active extension --- src/test/suite/interactiveEditor.test.ts | 47 +++++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index b0e86c642..b2e19dc97 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -16,7 +16,6 @@ import { AssetStorageStats, exportedAssetName, readAsset } from '../../interacti import { resolveExecutable } from '../../interactive/executable'; import { shellQuote } from '../../interactive/launcher'; import type { InteractiveManager } from '../../interactive/manager'; -import * as session from '../../session'; import type { GlobalEnvItem, WorkspaceDataProvider } from '../../workspaceViewer'; import type { WorkspaceData } from '../../session'; import type { LanguageClient } from 'vscode-languageclient/node'; @@ -83,16 +82,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, timeoutMessage = 'Timed out waiting for notebook execution'): 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(timeoutMessage); } + 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 recreateManager(context: vscode.ExtensionContext): void { const manager = context.subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as InteractiveManager; assert.ok(manager); @@ -639,9 +646,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); - await until(() => session.activeSession?.sessionId === `${manifests[0].id}:${manifests[0].generation}` - && session.activeSession.workspaceData.globalenv.interactive_signature?.type === 'closure', - 'Timed out waiting for the Interactive workspace snapshot'); + 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(); @@ -1492,7 +1507,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'); @@ -1521,8 +1536,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 });