From 2b4139a8a6aa03a38382c4d284fe4dd0990eeb07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Roald-Arb=C3=B8l?= Date: Sat, 10 Oct 2026 15:02:12 +0200 Subject: [PATCH 1/2] feat: offer to install dependencies missing for a managed sess A managed sess is shared by every R with the same platform and version, but its Imports may only have been visible to the R that prepared it. When a ready managed copy is reused by an R that cannot find them, list the missing packages and ask with a new "dependencies" consent reason, then install only those packages into the managed library. Declining keeps R running without the session watcher, and sess_load_namespace() now names every missing package. Co-Authored-By: Claude Opus 5.5 --- R/attach_sess.R | 135 ++++++++++++++++++------------ R/sess-package-install.R | 41 ++++++--- R/sess_source.R | 29 ++++++- R/tests/attach_sess.R | 80 ++++++++++++++++-- src/sessConsent.ts | 7 +- src/session.ts | 10 ++- src/test/node/sessConsent.test.ts | 28 ++++++- 7 files changed, 244 insertions(+), 86 deletions(-) diff --git a/R/attach_sess.R b/R/attach_sess.R index 2f23f844c..1e6dcae4d 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -11,6 +11,66 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, } runtime <- sess_runtime_identity() managed_library <- sess_managed_library(managed_root, expected) + # Ask the extension for a single-use installation grant. reason is + # "missing", "mismatch", or "dependencies". + request_consent <- function(reason) { + if (!dir.exists(consent_dir)) { + stop("The extension's sess consent service is unavailable. Restart VS Code and try again.") + } + new_id_part <- function() { + temporary <- basename(tempfile(pattern = "request-", tmpdir = consent_dir)) + gsub("[^A-Za-z0-9_-]", "", sub("^request-", "", temporary)) + } + id <- paste0(new_id_part(), new_id_part()) + if (!grepl("^[A-Za-z0-9_-]{16,64}$", id)) { + stop("Could not create a unique sess installation request.") + } + request <- paste( + "vscode-r-sess-consent-v1", id, expected, runtime, reason, sep = "\n") + request_path <- file.path(consent_dir, paste0(id, ".request")) + response_path <- file.path(consent_dir, paste0(id, ".response")) + temporary_path <- tempfile(pattern = paste0(id, "-"), tmpdir = consent_dir) + on.exit(unlink(c(temporary_path, request_path, response_path)), add = TRUE) + writeLines(request, temporary_path, useBytes = TRUE) + if (.Platform$OS.type == "unix") { + Sys.chmod(temporary_path, "0600") + } + if (!file.rename(temporary_path, request_path)) { + stop("Could not request permission to install bundled sess.") + } + + deadline <- Sys.time() + timeout_seconds + response <- "" + while (Sys.time() < deadline && dir.exists(consent_dir) && !nzchar(response)) { + if (file.exists(response_path)) { + lines <- tryCatch( + readLines(response_path, warn = FALSE, n = 2L), + error = function(e) character()) + if (length(lines) == 1L && lines %in% c("approve", "decline")) { + response <- lines + } else { + stop("Invalid response to the sess installation request.") + } + } else { + Sys.sleep(0.2) + } + } + response + } + configured_repo <- function() { + 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" + } + repo + } loaded <- "sess" %in% loadedNamespaces() if (loaded) { @@ -74,8 +134,24 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, error = function(e) character() ) installed <- sess_find_source_library(expected, managed_library) - if (length(ready_revision) == 1L && identical(ready_revision, expected) && - !is.null(installed)) { + ready <- length(ready_revision) == 1L && identical(ready_revision, expected) && + !is.null(installed) + missing_dependencies <- if (ready) sess_missing_dependencies(installed) else character() + if (ready && length(missing_dependencies)) { + # The managed copy is shared by every R with this platform and version. + # Dependencies an earlier setup found in its own libraries may be absent here. + message("The vscode-R managed sess needs R packages that this R cannot find: ", + paste(missing_dependencies, collapse = ", ")) + if (!identical(request_consent("dependencies"), "approve")) { + message("The missing packages were not installed. The session watcher was not attached.") + return(NULL) + } + installer <- new.env(parent = baseenv()) + sys.source(installer_helper, envir = installer) + installer$sess_install_missing_dependencies(missing_dependencies, managed_library, + configured_repo()) + ns <- sess_load_namespace(installed, expected) + } else if (ready) { ns <- sess_load_namespace(installed, expected) } else if (followed_setup) { return(NULL) @@ -88,48 +164,7 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, } else { "missing" } - if (!dir.exists(consent_dir)) { - stop("The extension's sess consent service is unavailable. Restart VS Code and try again.") - } - new_id_part <- function() { - temporary <- basename(tempfile(pattern = "request-", tmpdir = consent_dir)) - gsub("[^A-Za-z0-9_-]", "", sub("^request-", "", temporary)) - } - id <- paste0(new_id_part(), new_id_part()) - if (!grepl("^[A-Za-z0-9_-]{16,64}$", id)) { - stop("Could not create a unique sess installation request.") - } - request <- paste( - "vscode-r-sess-consent-v1", id, expected, runtime, reason, sep = "\n") - request_path <- file.path(consent_dir, paste0(id, ".request")) - response_path <- file.path(consent_dir, paste0(id, ".response")) - temporary_path <- tempfile(pattern = paste0(id, "-"), tmpdir = consent_dir) - on.exit(unlink(c(temporary_path, request_path, response_path)), add = TRUE) - writeLines(request, temporary_path, useBytes = TRUE) - if (.Platform$OS.type == "unix") { - Sys.chmod(temporary_path, "0600") - } - if (!file.rename(temporary_path, request_path)) { - stop("Could not request permission to install bundled sess.") - } - - deadline <- Sys.time() + timeout_seconds - response <- "" - while (Sys.time() < deadline && dir.exists(consent_dir) && !nzchar(response)) { - if (file.exists(response_path)) { - lines <- tryCatch( - readLines(response_path, warn = FALSE, n = 2L), - error = function(e) character()) - if (length(lines) == 1L && lines %in% c("approve", "decline")) { - response <- lines - } else { - stop("Invalid response to the sess installation request.") - } - } else { - Sys.sleep(0.2) - } - } - if (!identical(response, "approve")) { + if (!identical(request_consent(reason), "approve")) { message("Bundled sess was not installed. The session watcher was not attached.") return(NULL) } @@ -140,17 +175,7 @@ vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, (length(ready_link) && !is.na(ready_link) && nzchar(ready_link))) { stop("Could not clear the previous vscode-R managed sess completion marker.") } - configured <- getOption("repos") - repo <- if ("CRAN" %in% names(configured)) { - configured[["CRAN"]] - } else if (length(configured)) { - configured[[1L]] - } else { - "https://cloud.r-project.org" - } - if (!length(repo) || is.na(repo) || !nzchar(repo) || identical(repo, "@CRAN@")) { - repo <- "https://cloud.r-project.org" - } + repo <- configured_repo() 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.") diff --git a/R/sess-package-install.R b/R/sess-package-install.R index 47b229f00..4ae552fc8 100644 --- a/R/sess-package-install.R +++ b/R/sess-package-install.R @@ -33,21 +33,42 @@ sess_verify_package <- function(library, required, expected_revision, interactiv if (!identical(as.integer(status), 0L)) stop("The installed sess package failed its compatibility/load check.") } +# Pass the caller's library search path to installation/verification children, +# including project libraries, without sourcing the caller's startup profile. +sess_set_install_environment <- function(library) { + previous <- Sys.getenv(c("R_LIBS", "R_PROFILE_USER", "R_ENVIRON_USER"), + unset = NA_character_, names = TRUE) + Sys.setenv(R_LIBS = paste(unique(c(library, .libPaths())), collapse = .Platform$path.sep), + R_PROFILE_USER = "", R_ENVIRON_USER = "") + previous +} + +sess_restore_install_environment <- function(previous) { + Sys.unsetenv(names(previous)[is.na(previous)]) + if (any(!is.na(previous))) do.call(Sys.setenv, as.list(previous[!is.na(previous)])) +} + +# Install only the given dependencies beside an already prepared managed sess. +sess_install_missing_dependencies <- function(packages, library, repos) { + previous <- sess_set_install_environment(library) + on.exit(sess_restore_install_environment(previous), add = TRUE) + sess_install_dependencies(packages, library, repos) + still_missing <- packages[!nzchar(vapply(packages, function(package) { + system.file(package = package, lib.loc = unique(c(.libPaths(), library))) + }, ""))] + if (length(still_missing)) { + stop("Could not install: ", paste(still_missing, collapse = ", ")) + } + invisible(TRUE) +} + sess_install <- function(pkg_path, library, repos, interactive = FALSE) { description <- read.dcf(file.path(pkg_path, "DESCRIPTION")) required <- description[1L, "Version"] expected_revision <- description[1L, "Config/vscode-R/source-revision"] - # Pass the caller's library search path to installation/verification children, - # including project libraries, without sourcing the caller's startup profile. - keys <- c("R_LIBS", "R_PROFILE_USER", "R_ENVIRON_USER") - previous <- Sys.getenv(keys, unset = NA_character_, names = TRUE) - on.exit({ - Sys.unsetenv(keys[is.na(previous)]) - if (any(!is.na(previous))) do.call(Sys.setenv, as.list(previous[!is.na(previous)])) - }, add = TRUE) - Sys.setenv(R_LIBS = paste(unique(c(library, .libPaths())), collapse = .Platform$path.sep), - R_PROFILE_USER = "", R_ENVIRON_USER = "") + previous <- sess_set_install_environment(library) + on.exit(sess_restore_install_environment(previous), add = TRUE) deps <- if ("Imports" %in% colnames(description)) description[1L, "Imports"] else "" deps <- trimws(gsub("\\s*\\(.*\\)", "", unlist(strsplit(deps, ",")))) diff --git a/R/sess_source.R b/R/sess_source.R index 499470d16..9ef2f98ca 100644 --- a/R/sess_source.R +++ b/R/sess_source.R @@ -65,15 +65,36 @@ sess_loaded_source_revision <- function() { 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)) +sess_imports <- function(library) { description <- read.dcf(file.path(library, "sess", "DESCRIPTION")) - imports <- if ("Imports" %in% colnames(description)) { + if ("Imports" %in% colnames(description)) { trimws(gsub("\\s*\\(.*\\)", "", unlist(strsplit(description[1L, "Imports"], ",")))) } else { character() } +} + +# Imports, plus ps for processx's .onLoad, that neither the normal libraries nor +# the managed library provide to this R. +sess_missing_dependencies <- function(library, normal_libraries = .libPaths()) { + support_paths <- unique(c(normal_libraries, library)) + imports <- sess_imports(library) + required <- unique(c(imports, if ("processx" %in% imports) "ps")) + found <- vapply(required, function(package) { + nzchar(system.file(package = package, lib.loc = support_paths)) + }, FALSE) + required[!found] +} + +sess_load_namespace <- function(library, revision, normal_libraries = .libPaths()) { + paths <- unique(c(library, normal_libraries)) + support_paths <- unique(c(normal_libraries, library)) + imports <- sess_imports(library) + missing <- sess_missing_dependencies(library, normal_libraries) + if (length(missing)) { + stop("sess needs R packages that this R cannot find: ", paste(missing, collapse = ", "), + ". Install them with the project's package manager, then restart R.") + } # 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) { diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index 007e3d988..9c78cf5c6 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -34,6 +34,35 @@ writeLines(c( writeLines(c("export(connect)", "export(notify_client)", "export(request_client)"), file.path(pkg, "NAMESPACE")) +# A managed copy prepared where its Imports were visible, reused by an R that cannot see them. +dependency <- file.path(root, "dependency source", "sessfixturedep") +dir.create(file.path(dependency, "R"), recursive = TRUE) +writeLines(c( + "Package: sessfixturedep", "Version: 1.0.0", "Title: Dependency Fixture", + "Description: A local dependency fixture.", "License: MIT", + "Author: Test Author", "Maintainer: Test Author " +), file.path(dependency, "DESCRIPTION")) +writeLines("fixture_value <- function() 1L", file.path(dependency, "R", "dep.R")) +writeLines("export(fixture_value)", file.path(dependency, "NAMESPACE")) +dependency_contrib <- file.path(root, "dependency repo", "src", "contrib") +dir.create(dependency_contrib, recursive = TRUE) +previous_directory <- setwd(dirname(dependency)) +tryCatch( + utils::tar(file.path(dependency_contrib, "sessfixturedep_1.0.0.tar.gz"), + "sessfixturedep", compression = "gzip", tar = "internal"), + finally = setwd(previous_directory)) +tools::write_PACKAGES(dependency_contrib, type = "source") +dependency_repo <- paste0("file://", if (.Platform$OS.type == "windows") "/", + normalizePath(file.path(root, "dependency repo"), winslash = "/")) +pkg_with_dependency <- file.path(root, "bundled with dependency", "sess") +dir.create(dirname(pkg_with_dependency)) +file.copy(pkg, dirname(pkg_with_dependency), recursive = TRUE) +dependency_description <- read.dcf(file.path(pkg_with_dependency, "DESCRIPTION")) +write.dcf(cbind(dependency_description, Imports = "sessfixturedep"), + file.path(pkg_with_dependency, "DESCRIPTION")) +cat("import(sessfixturedep)\n", file = file.path(pkg_with_dependency, "NAMESPACE"), append = TRUE) +dependency_modes <- c("dependencies_approve", "dependencies_decline") + runner <- file.path(root, "run-case.R") writeLines(c( "args <- commandArgs(TRUE)", @@ -301,6 +330,23 @@ writeLines(c( " stopifnot(!file.exists(ready_path))", " stopifnot(same_paths(.libPaths(), ordinary))", " }", + "} else if (mode %in% c('dependencies_approve', 'dependencies_decline')) {", + " # The local fixture repository has only a source index.", + " options(repos=c(CRAN=Sys.getenv('VSCODE_R_TEST_DEPENDENCY_REPO')), pkgType='source')", + " stopifnot(!nzchar(system.file(package='sessfixturedep')))", + " result <- attach_sess(timeout=20)", + " stopifnot(same_paths(.libPaths(), ordinary))", + " stopifnot(identical(readLines(ready_path), revision))", + " stopifnot(!dir.exists(file.path(normal, 'sessfixturedep')))", + " if (mode == 'dependencies_approve') {", + " stopifnot(identical(result, TRUE), identical(sess_loaded_source_revision(), revision))", + " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", + " stopifnot(same_paths(actual, file.path(managed_library, 'sess')))", + " stopifnot(file.exists(file.path(managed_library, 'sessfixturedep', 'DESCRIPTION')))", + " } else {", + " stopifnot(identical(result, FALSE), !('sess' %in% loadedNamespaces()))", + " stopifnot(!dir.exists(file.path(managed_library, 'sessfixturedep')))", + " }", "} else if (mode == 'lock_timeout') {", " lock <- file.path(dirname(managed_library), '.setup-lock')", " lock_marker <- file.path(lock, 'foreign-owner')", @@ -334,7 +380,8 @@ writeLines(c( " inherited <- vscode_r_startup_existing('endpoint')", " if (!is.null(inherited)) stop('child reused the parent startup context')", "}", - "ids <- character(); identities <- character(); deadline <- Sys.time() + 30", + "ids <- character(); identities <- character(); reasons <- 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) {", @@ -343,7 +390,7 @@ writeLines(c( " 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]])", + " identities <- c(identities, lines[[4L]]); reasons <- c(reasons, lines[[5L]])", " answer <- answers[[min(length(ids), length(answers))]]", " writeLines(answer, temporary)", " response <- file.path(directory, paste0(id, '.response'))", @@ -354,6 +401,7 @@ writeLines(c( "if (length(ids) != count) stop('timed out waiting for consent requests')", "writeLines(ids, log)", "writeLines(identities, identity_log)", + "writeLines(reasons, paste0(identity_log, '.reasons'))", "writeLines('ok', status_log)", "}, error=function(e) {", "writeLines(paste0('error: ', conditionMessage(e)), status_log)", @@ -444,14 +492,31 @@ run_case <- function(mode) { }, add = TRUE) do.call(Sys.setenv, as.list(profile_environment)) } - consent_modes <- c("markers", "mismatch", "profile_decline", "profile_approve") + package <- if (mode %in% dependency_modes) pkg_with_dependency else pkg + if (mode %in% dependency_modes) { + other_project <- file.path(case_root, "other project library") + managed_library <- sess_managed_library(file.path(case_root, "vscode-R"), revision) + dir.create(other_project) + dir.create(managed_library, recursive = TRUE) + utils::install.packages("sessfixturedep", repos = dependency_repo, type = "source", + lib = other_project, quiet = TRUE) + previous_libraries <- .libPaths() + .libPaths(c(other_project, previous_libraries)) + utils::install.packages(package, repos = NULL, type = "source", lib = managed_library, + quiet = TRUE) + .libPaths(previous_libraries) + writeLines(revision, file.path(dirname(managed_library), ".ready")) + Sys.setenv(VSCODE_R_TEST_DEPENDENCY_REPO = dependency_repo) + on.exit(Sys.unsetenv("VSCODE_R_TEST_DEPENDENCY_REPO"), add = TRUE) + } + consent_modes <- c("markers", "mismatch", "profile_decline", "profile_approve", dependency_modes) if (mode %in% consent_modes) { log <- file.path(case_root, "consent-ids") identity_log <- file.path(case_root, "runtime-identities") status_log <- file.path(case_root, "consent-agent-status") count <- if (mode %in% c("markers", "profile_decline")) 2L else 1L answer <- switch(mode, profile_approve = "approve", profile_decline = "decline,approve", - "decline") + dependencies_approve = "approve", "decline") release_marker <- if (mode %in% c("profile_decline", "profile_approve")) { file.path(case_root, "release-profile-consent") } else { @@ -516,7 +581,7 @@ run_case <- function(mode) { r_binary, shQuote(c( flags, - paste0("--file=", runner), "--args", mode, case_root, pkg, revision, mismatch, + paste0("--file=", runner), "--args", mode, case_root, package, revision, mismatch, "", "" )), stdout = TRUE, @@ -547,11 +612,14 @@ run_case <- function(mode) { paste(R.version$major, minor, sep = "."), sep = "|") stopifnot(length(runtime_identity) == expected_count) stopifnot(all(runtime_identity == expected_runtime)) + reasons <- readLines(paste0(identity_log, ".reasons"), warn = FALSE) + if (mode %in% dependency_modes) stopifnot(identical(reasons, "dependencies")) } } for (mode in c( - "mismatch", "exact", "prepared", "markers", "profile_exact", "profile_decline", "profile_approve" + "mismatch", "exact", "prepared", "markers", "profile_exact", "profile_decline", "profile_approve", + dependency_modes )) run_case(mode) wait_for_file <- function(path, seconds, description) { diff --git a/src/sessConsent.ts b/src/sessConsent.ts index d3e557ef3..f29ccd370 100644 --- a/src/sessConsent.ts +++ b/src/sessConsent.ts @@ -3,7 +3,8 @@ import * as fsSync from 'node:fs'; import * as path from 'node:path'; import * as crypto from 'node:crypto'; -export type SessConsentReason = 'missing' | 'mismatch'; +export type SessConsentReason = 'missing' | 'mismatch' | 'dependencies'; +const REASONS: readonly string[] = ['missing', 'mismatch', 'dependencies']; export interface SessConsentRequest { id: string; @@ -132,10 +133,10 @@ export class SessConsentService { 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')) { + || !REASONS.includes(lines[4])) { throw new Error('Malformed sess consent request.'); } - return { id, revision: lines[2], runtime: lines[3], reason: lines[4] }; + return { id, revision: lines[2], runtime: lines[3], reason: lines[4] as SessConsentReason }; } private async readBoundedRequest(filePath: string): Promise { diff --git a/src/session.ts b/src/session.ts index 0cbb51b4f..6b99fe267 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1037,10 +1037,12 @@ async function ensureAttachSessConsentService(): Promise { await Promise.resolve(extensionContext.globalState.update(SESS_INSTALL_PROMPT_DISMISSED_REVISION, revision)); }, prompt: async request => { - const mismatch = request.reason === 'mismatch'; - const message = mismatch - ? 'The installed sess does not match this build of vscode-R. Install the bundled copy in a vscode-R-managed library? Your existing sess installation will not be modified. “Don’t ask again” applies to this bundled sess revision.' - : 'vscode-R needs bundled sess to attach the session watcher. Install it in a vscode-R-managed library? Your existing sess installation will not be modified. “Don’t ask again” applies to this bundled sess revision.'; + const messages: Record = { + mismatch: 'The installed sess does not match this build of vscode-R. Install the bundled copy in a vscode-R-managed library? Your existing sess installation will not be modified. “Don’t ask again” applies to this bundled sess revision.', + missing: '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.', + dependencies: 'This R cannot find some R packages that vscode-R’s managed sess needs (listed in the R terminal). Install them in the vscode-R-managed library? Your project libraries will not be modified. “Don’t ask again” applies to this bundled sess revision.', + }; + const message = messages[request.reason]; const choice = await window.showWarningMessage( message, 'Install bundled sess', 'Not now', "Don't ask again"); const choices: Record = { diff --git a/src/test/node/sessConsent.test.ts b/src/test/node/sessConsent.test.ts index 9ba703e47..819fa40f2 100644 --- a/src/test/node/sessConsent.test.ts +++ b/src/test/node/sessConsent.test.ts @@ -2,7 +2,7 @@ import * as assert from 'node:assert/strict'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { SessConsentChoice, SessConsentService } from '../../sessConsent'; +import { SessConsentChoice, SessConsentReason, SessConsentService } from '../../sessConsent'; const revision = `git-tree:${'a'.repeat(40)}`; const nextRevision = `git-tree:${'c'.repeat(40)}`; @@ -16,7 +16,7 @@ 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', +async function writeRequest(directory: string, id: string, reason: string = 'missing', requestRevision = revision): Promise { await fs.writeFile(path.join(directory, `${id}.request`), `vscode-r-sess-consent-v1\n${id}\n${requestRevision}\nlinux-x86_64|4.5\n${reason}\n`); @@ -39,7 +39,7 @@ suite('sess install consent bridge', () => { await fs.rm(directory, { recursive: true, force: true }); }); - function service(prompt: (request: { reason: 'missing' | 'mismatch' }) => Promise<'install' | 'notNow' | 'dontAskAgain' | 'dismiss'>, + function service(prompt: (request: { reason: SessConsentReason }) => Promise<'install' | 'notNow' | 'dontAskAgain' | 'dismiss'>, isEnabled = () => true, expectedRevision = revision): SessConsentService { const result = new SessConsentService({ @@ -57,7 +57,7 @@ suite('sess install consent bridge', () => { test('writes a fresh approval only after the prompt accepts', async () => { const id = '1'.repeat(32); - const prompt = (request: { reason: 'missing' | 'mismatch' }) => { + const prompt = (request: { reason: SessConsentReason }) => { assert.strictEqual(request.reason, 'missing'); return Promise.resolve('install'); }; @@ -68,6 +68,26 @@ suite('sess install consent bridge', () => { assert.strictEqual(dismissedRevision, undefined); }); + test('passes a missing-dependencies request to the prompt', async () => { + const id = 'd'.repeat(32); + const reasons: SessConsentReason[] = []; + const broker = service(request => { reasons.push(request.reason); return Promise.resolve('install'); }); + await broker.start(); + await writeRequest(directory, id, 'dependencies'); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'approve\n'); + assert.deepStrictEqual(reasons, ['dependencies']); + }); + + test('declines an unknown reason without prompting', async () => { + let prompts = 0; + const broker = service(() => { prompts++; return Promise.resolve('install'); }); + await broker.start(); + const id = 'e'.repeat(32); + await writeRequest(directory, id, 'upgrade'); + assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); + assert.strictEqual(prompts, 0); + }); + test('accepts the CRLF lines written by base R on Windows', async () => { const id = '7'.repeat(32); const broker = service(() => Promise.resolve('install')); From bbf2e3012183666cf6b1961fdcab6538b1277da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Roald-Arb=C3=B8l?= Date: Sat, 10 Oct 2026 15:08:27 +0200 Subject: [PATCH 2/2] docs: describe the managed sess dependency prompt Co-Authored-By: Claude Opus 5.5 --- sess/README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/sess/README.md b/sess/README.md index c99f92bec..4bd1678ab 100644 --- a/sess/README.md +++ b/sess/README.md @@ -46,16 +46,19 @@ bundled installation fails. > [!NOTE] > -> ### Known limitation: isolated libraries +> ### 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. +> library. If an isolated project's `.libPaths()` cannot see them, the R terminal +> lists the missing packages and the extension asks before installing them into the +> managed library. Declining leaves R running without the session watcher. > -> 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. +> To keep them in the project instead, install the missing dependencies with the +> project's package manager, for example +> [renv::install()](https://pkgs.rstudio.com/renv/reference/install.html) or +> `pixi add r-jsonlite r-later r-processx r-rstudioapi`. 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