Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
33b7f4c
Use active R session packages for language completion
Fred-Wu Oct 3, 2026
acb6b89
Route session packages to the active language server
Fred-Wu Oct 3, 2026
b33b255
Handle unscoped session package completion
Fred-Wu Oct 3, 2026
0a5cf6f
fix(lsp): preserve bound session packages after master rebase
Fred-Wu Oct 5, 2026
6e361b6
fix(lsp): resolve session workspace from working directory
Fred-Wu Oct 5, 2026
b2fbee2
fix(lsp): register clients before session state sync
Fred-Wu Oct 5, 2026
cc26c50
test(lsp): cover session synchronization races
Fred-Wu Oct 5, 2026
d53815d
test(session): cover workspace fallback without terminal association
Fred-Wu Oct 5, 2026
089f636
fix(lsp): replay session state for added workspaces
Fred-Wu Oct 5, 2026
bc96ca0
test(lsp): cover session workspace synchronization
Fred-Wu Oct 5, 2026
27fb9f5
test(lsp): fix session package completion fixtures
Fred-Wu Oct 8, 2026
a1905f6
Merge upstream/main into fix/session-package-completion
Fred-Wu Oct 8, 2026
2470fe1
fix(lsp): retry failed session package synchronisation
Fred-Wu Oct 8, 2026
00fd9f3
fix(interactive): restore R input binding when reopening native windows
Fred-Wu Oct 8, 2026
608354e
Merge origin/master into fix/session-package-completion
Oct 12, 2026
96afb8e
fix(test): replace removed fs-extra dependency
Oct 12, 2026
bab8357
Merge upstream main into fix/session-package-completion
Fred-Wu Oct 28, 2026
a92d321
fix: retain session workspace ownership for package completion
Fred-Wu Oct 28, 2026
4bb69c9
fix: synchronize session package completion across library and runtim…
Fred-Wu Oct 28, 2026
1b2d7a0
fix: preserve session package cleanup and resolve CI findings
Fred-Wu Oct 28, 2026
941cce9
fix: complete session synchronization reset and handle superseded met…
Fred-Wu Oct 28, 2026
2f6077a
fix: satisfy R lint and tolerate Windows startup status writer lock
Fred-Wu Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ jobs:
path: |
.vscode-test/user-data/**/logs/**/exthost/exthost.log
.vscode-test/user-data/**/logs/**/exthost/output_logging_*/*.log
.vscode-test/user-data/**/logs/**/exthost/REditorSupport.r/*.log
retention-days: 7
build:
runs-on: ubuntu-latest
Expand Down
222 changes: 221 additions & 1 deletion R/languageServer.R
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,224 @@ if (identical(Sys.getenv("VSCR_LSP_SYNTHETIC_WORKSPACE"), "TRUE")) {
options(languageserver.index_mode = "off")
}

languageserver::run(port = port, debug = debug)
tools::Rd2txt_options(underline_titles = FALSE)
tools::Rd2txt_options(itemBullet = "* ")
languageserver:::lsp_settings$update_from_options()
if (isTRUE(debug)) {
languageserver:::lsp_settings$set("debug", TRUE)
languageserver:::lsp_settings$set("log_file", NULL)
}

normalize_character <- function(value) {
if (is.list(value)) {
value <- unlist(value, use.names = FALSE)
}
if (!is.character(value)) {
return(character())
}
unique(value[nzchar(value)])
}

# This is intentionally an optional integration. Older languageserver
# installations still provide ordinary static LSP features; don't mutate
# unsupported private structures or leave attached packages behind on reset.
namespace <- asNamespace("languageserver")
startup_fn <- get0("workspace_startup_packages", namespace, inherits = FALSE)
server <- languageserver:::LanguageServer$new("localhost", port)
default_library_paths <- .libPaths()
default_startup <- if (is.function(startup_fn)) startup_fn() else character()
# Keep the project NAMESPACE imports separate from the temporary packages
# attached in the interactive R process; S3 resolution consults imports as
# well as the workspace startup list.
session_state <- new.env(parent = emptyenv())
session_state$imports <- list()
session_state$previous_packages <- character()
session_state$revision <- 0L
session_state$pending_reply <- NULL

server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) {
# A superseded worker discards its callback. Acknowledge its request so
# TypeScript can deliver the next state, including a disconnect clear.
session_state$revision <- session_state$revision + 1L
revision <- session_state$revision
if (is.function(session_state$pending_reply)) session_state$pending_reply(FALSE)
session_state$pending_reply <- NULL
if (!is.null(self$resolve_task_manager) &&
is.function(self$resolve_task_manager$cancel)) {
self$resolve_task_manager$cancel("r/syncSessionState")
}

response_state <- new.env(parent = emptyenv())
response_state$replied <- FALSE
reply <- function(applied) {
if (response_state$replied) return(invisible(NULL))
response_state$replied <- TRUE
if (identical(session_state$pending_reply, reply)) {
session_state$pending_reply <- NULL
}
self$deliver(languageserver:::Response$new(id, result = applied))
invisible(NULL)
}
if (!is.function(startup_fn) || is.null(tryCatch(self$workspaces, error = function(e) NULL))) {
# Versions before the workspace startup API cannot safely reset state.
reply(FALSE)
return(invisible(NULL))
}

tryCatch({
attached_packages <- normalize_character(params$attachedPackages)
loaded_namespaces <- normalize_character(params$loadedNamespaces)
library_paths <- normalize_character(params$libraryPaths)
locations <- params$packagePaths
if (!is.list(locations)) locations <- list()
package_paths <- unlist(locations, use.names = TRUE)
if (!is.character(package_paths)) package_paths <- character()
package_paths <- package_paths[nzchar(package_paths)]
extra_paths <- dirname(package_paths)
candidate_paths <- if (length(attached_packages)) {
unique(c(extra_paths, library_paths, default_library_paths))
} else {
default_library_paths
}
candidate_paths <- candidate_paths[dir.exists(candidate_paths)]
.libPaths(candidate_paths)

# A server can outlive an R session. Removing the previous package
# metadata avoids stale members and S3 signatures after detach.
for (workspace in self$workspaces$values()) {
matches <- vapply(
session_state$imports,
function(entry) identical(entry$workspace, workspace),
logical(1L)
)
index <- which(matches)
if (!length(index)) {
base_imports <- workspace$imported_packages
session_state$imports[[length(session_state$imports) + 1L]] <-
list(workspace = workspace, imports = base_imports)
} else {
base_imports <- session_state$imports[[index[[1L]]]]$imports
actual <- workspace$imported_packages
if (!setequal(actual, union(base_imports, session_state$previous_packages))) {
base_imports <- union(
setdiff(actual, session_state$previous_packages),
intersect(base_imports, session_state$previous_packages)
)
session_state$imports[[index[[1L]]]]$imports <- base_imports
}
}
previous <- workspace$startup_packages
new_startup <- if (length(attached_packages)) rev(attached_packages) else default_startup
removed <- setdiff(previous, unique(c(new_startup, base_imports)))
workspace$startup_packages <- new_startup
workspace$imported_packages <- union(base_imports, attached_packages)
workspace$update_loaded_packages()
for (pkg in unique(c(attached_packages, loaded_namespaces))) {
try(workspace$get_namespace(pkg), silent = TRUE)
}
if (length(removed) && !is.null(workspace$namespaces)) {
for (pkg in removed) {
if (workspace$namespaces$has(pkg)) {
workspace$namespaces$remove(pkg)
}
}
}
if (length(removed) && !is.null(workspace$member_metadata)) {
for (pkg in removed) {
if (is.function(workspace$member_metadata$remove)) {
workspace$member_metadata$remove(pkg)
}
}
}
if (length(removed) && !is.null(workspace$s3_metadata) &&
is.function(workspace$s3_metadata$clear)) {
keep <- setdiff(workspace$s3_metadata$keys(), removed)
snapshots <- lapply(keep, workspace$s3_metadata$get)
names(snapshots) <- keep
workspace$s3_metadata$clear()
for (pkg in names(snapshots)) {
workspace$s3_metadata$set(pkg, snapshots[[pkg]])
}
}
}

session_state$previous_packages <- attached_packages
missing <- attached_packages[
!vapply(
attached_packages,
function(pkg) length(find.package(pkg, quiet = TRUE)) > 0L,
logical(1L)
)
]
if (length(missing)) {
message(
"Session packages cannot be resolved by languageserver: ",
paste(missing, collapse = ", ")
)
reply(FALSE)
return(invisible(NULL))
}

# Prepare the independent member and S3 metadata providers in a worker.
# Only prepare on state updates, never in ordinary completion requests.
# Keep the request pending until metadata is available to the providers.
prepare <- get0("member_resolve_packages", namespace, inherits = FALSE)
if (!length(attached_packages) || !is.function(prepare) ||
is.null(self$resolve_task_manager)) {
reply(TRUE)
return(invisible(NULL))
}
create_task <- get0("create_task", namespace, inherits = FALSE)
package_call <- get0("package_call", namespace, inherits = FALSE)
if (!is.function(create_task) || !is.function(package_call)) {
reply(FALSE)
return(invisible(NULL))
}
task <- create_task(
target = package_call(prepare),
args = list(
pkgs = attached_packages,
lib_paths = .libPaths(),
namespace_packages = character(),
prepare = isTRUE(languageserver:::lsp_settings$get("member_completion")),
prepare_s3 = isTRUE(languageserver:::lsp_settings$get("s3_dispatch"))
),
callback = function(result) {
if (!identical(revision, session_state$revision)) return(invisible(NULL))
tryCatch({
for (workspace in self$workspaces$values()) {
if (!is.null(result$members) && !is.null(workspace$member_metadata)) {
for (pkg in names(result$members)) {
workspace$member_metadata$set(pkg, result$members[[pkg]])
}
}
if (!is.null(result$s3) && !is.null(workspace$s3_metadata)) {
for (pkg in names(result$s3)) {
workspace$s3_metadata$set(pkg, result$s3[[pkg]])
}
}
}
reply(TRUE)
}, error = function(e) {
message("Session package metadata could not be applied: ", conditionMessage(e))
reply(FALSE)
})
},
error = function(e) {
if (identical(revision, session_state$revision)) {
message("Session package metadata could not be prepared: ", conditionMessage(e))
reply(FALSE)
}
},
profiles = FALSE
)
session_state$pending_reply <- reply
self$resolve_task_manager$add_task("r/syncSessionState", task)
}, error = function(e) {
message("Session package synchronization failed: ", conditionMessage(e))
reply(FALSE)
})
invisible(NULL)
}

server$run()
15 changes: 11 additions & 4 deletions R/tests/prepare-jgd.R
Original file line number Diff line number Diff line change
Expand Up @@ -498,12 +498,19 @@ local({
while (!file.exists(profile_log) && Sys.time() < deadline) Sys.sleep(0.01)
if (!file.exists(profile_log)) stop("Declined startup profile did not connect sess.")
stopifnot(identical(readLines(profile_log), c("fixture-socket", "FALSE", "FALSE")))
startup_status <- readLines(startup_file, warn = FALSE)
# On Windows, the child may briefly hold the newly created status file
# while writing it. Treat an incomplete or unreadable snapshot as pending
# rather than failing before the existing startup deadline.
deadline <- Sys.time() + 10
while (length(startup_status) == 6L && startup_status[[6L]] == "pending" &&
Sys.time() < deadline) {
startup_status <- character()
repeat {
startup_status <- tryCatch(
if (file.exists(startup_file)) readLines(startup_file, warn = FALSE) else character(),
error = function(e) character()
)
if (length(startup_status) == 6L && startup_status[[6L]] != "pending") break
if (Sys.time() >= deadline) break
Sys.sleep(0.01)
startup_status <- readLines(startup_file, warn = FALSE)
}
if (length(startup_status) != 6L || startup_status[[6L]] != "ready") {
logs <- unlist(lapply(c("stdout", "stderr"), function(name) {
Expand Down
10 changes: 9 additions & 1 deletion sess/R/handlers.R
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,15 @@ get_workspace_data <- function(revision = NULL) {
snapshot <- list(
globalenv = objs,
search = search()[-1],
loaded_namespaces = loadedNamespaces()
loaded_namespaces = loadedNamespaces(),
library_paths = .libPaths(),
package_paths = {
attached <- sub("^package:", "", grep("^package:", search(), value = TRUE))
paths <- vapply(attached, function(package) {
tryCatch(getNamespaceInfo(asNamespace(package), "path"), error = function(e) "")
}, character(1L))
as.list(paths[nzchar(paths)])
}
)
# Compare metadata after inspecting every binding: reference objects can mutate
# in place, and R task callbacks do not cover every embedding's evaluation.
Expand Down
4 changes: 3 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export let globalPlotManager: PlotManager | undefined = undefined;
export let rmdPreviewManager: rmarkdown.RMarkdownPreviewManager | undefined = undefined;
export let rmdKnitManager: rmarkdown.RMarkdownKnitManager | undefined = undefined;
export let sessionStatusBarItem: vscode.StatusBarItem | undefined = undefined;
export let rLanguageService: languageService.LanguageService | undefined = undefined;

// Called (once) when the extension is activated
export async function activate(
Expand Down Expand Up @@ -237,7 +238,8 @@ export async function activate(
);
void vscode.commands.executeCommand('workbench.extensions.search', '@installed r-lsp');
} else {
context.subscriptions.push(new languageService.LanguageService());
rLanguageService = new languageService.LanguageService();
context.subscriptions.push(rLanguageService);
}
}

Expand Down
32 changes: 26 additions & 6 deletions src/interactive/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,11 +1406,14 @@ export class InteractiveManager
(previous.client.connected || previous.restarting)
) {
if (previous.notebook.notebookType === 'interactive') {
await vscode.commands.executeCommand(
const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>(
'interactive.open',
{ preserveFocus: true },
previous.notebook.uri,
);
if (result?.inputUri) {
await this.bindInput(previous, result.inputUri);
}
} else {
const editor = vscode.window.visibleNotebookEditors.find(
(item) => item.notebook === previous.notebook,
Expand Down Expand Up @@ -1583,11 +1586,7 @@ export class InteractiveManager
});
}
if (inputUri) {
session.bindSessionDocument(inputUri, target);
await vscode.languages.setTextDocumentLanguage(
await vscode.workspace.openTextDocument(inputUri),
'r',
);
await this.bindInput(view, inputUri);
}
const restoring =
notebook === existing || (!!savedUri && notebook.uri.toString() === savedUri);
Expand Down Expand Up @@ -1640,6 +1639,24 @@ export class InteractiveManager
}
}

private async bindInput(view: InteractiveView, inputUri: vscode.Uri): Promise<void> {
// Native Interactive can recreate its input model when a tab is restored.
// Reapply its owner and R language on reopen as well as initial creation.
if (
view.inputUri &&
view.inputUri.toString() !== inputUri.toString() &&
session.boundSessionForDocument(view.inputUri) === view.target
) {
session.unbindSessionDocument(view.inputUri);
}
view.inputUri = inputUri;
session.bindSessionDocument(inputUri, view.target);
await vscode.languages.setTextDocumentLanguage(
await vscode.workspace.openTextDocument(inputUri),
'r',
);
}

private registerTarget(client: AgentClient): session.Session {
const manifest = client.manifest;
const target = session.registerSessionTransport(
Expand Down Expand Up @@ -3285,6 +3302,9 @@ export class InteractiveManager
if (snapshot.workspace) {
target.workspaceData = snapshot.workspace as unknown as session.WorkspaceData;
}
// Restarts can finish in an unselected window. Publish the
// replacement process's packages even without activating it.
session.updateSessionWorkspace(target, target.workspaceData);
view.base = snapshot.manifest.assetBase
? (
await vscode.env.asExternalUri(vscode.Uri.parse(snapshot.manifest.assetBase))
Expand Down
Loading
Loading