Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
d37a0df
fix: avoid unsupported session sync retries and cover nested members
Fred-Wu Oct 30, 2026
ed8e10b
fix: preserve namespace imports and scope startup timer assertion
Fred-Wu Oct 30, 2026
21ae8dd
test: require newer languageserver member and S3 integration in CI
Fred-Wu Oct 30, 2026
96bea44
revert: remove development languageserver CI job and test guard
Fred-Wu Oct 30, 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
226 changes: 225 additions & 1 deletion R/languageServer.R
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,228 @@ 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())
namespace_imports <- get0("extract_namespace_imports", namespace, inherits = FALSE)

# Read static NAMESPACE imports independently from session-attached packages.
# The imported package set is otherwise indistinguishable when they overlap.
workspace_static_imports <- function(root) {
if (length(root) != 1L) return(character())
file <- file.path(root, "NAMESPACE")
if (!file.exists(file)) return(character())
if (is.function(namespace_imports)) return(namespace_imports(file)$packages)

# Released languageserver versions with startup_packages, but without the
# newer namespace parser, handle ordinary import() directives here.
expressions <- tryCatch(parse(file, keep.source = FALSE), error = function(e) expression())
packages <- unlist(lapply(expressions, function(expr) {
if (!is.call(expr) || !identical(expr[[1L]], quote(import))) return(character())
args <- as.list(expr)[-1L]
values <- vapply(args, function(arg) {
if (is.name(arg) || is.character(arg)) as.character(arg)[1L] else ""
}, character(1L), USE.NAMES = FALSE)
values[nzchar(values)]
}), use.names = FALSE)
unique(packages)
}
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)) {
# Older languageserver versions cannot support this feature.
reply("unsupported")
return(invisible(NULL))
}
if (is.null(tryCatch(self$workspaces, error = function(e) NULL))) {
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()) {
base_imports <- workspace_static_imports(workspace$root)
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve R search-path precedence for S3 resolution

attached_packages comes from search() in highest-priority-first order, but the current S3 resolver reverses workspace$imported_packages when choosing a bare generic. Putting that vector into imports unchanged therefore reverses conflicts. With two attached packages exporting generic_probe, I reproduced workspace$guess_namespace() and R selecting the first package while S3 signature help selected the second package's method. This used the current handler and a normal document metadata worker, so it is observable even while the session worker has the separate error above. Please preserve attachment precedence in both the resolver context and metadata-worker inputs, and cover masking between two attached packages.

workspace$update_loaded_packages()
for (pkg in unique(c(attached_packages, loaded_namespaces))) {
try(workspace$get_namespace(pkg), silent = TRUE)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Refresh package identity when the active session's library changes

Two sessions can attach different installed versions of the same package. Switching A → B updates .libPaths(), but the name remains in new_startup, so it is not in removed; get_namespace() returns A's cached object, and R also keeps A's namespace loaded. Against cf116dd I installed two versions in separate libraries: after switching to B, basic completion still exposed A's alpha_only and not B's beta_only. The current worker failure is separate: both syncs returned FALSE, but the partially updated server still served A's namespace. Please reconcile namespace identity by path/version, using a safe restart or isolation strategy where needed.

}
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]])
}
}
}

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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Pass the actual metadata function name to the worker

languageserver:::package_call() captures the unevaluated symbol with substitute(). Passing the local variable prepare creates a worker that calls get("prepare", asNamespace("languageserver")), but the current server has no such function. With languageserver cf116dd, every nonempty session sync returns FALSE, and completion repeatedly retries without preparing member/S3 metadata. I reproduced the callr error and ran the PR's VS Code integration test against that revision; it failed at the nested-member assertion. A temporary change to pass the member_resolve_packages symbol made the worker reply successfully, although the nested-member integration assertion still failed and needs investigation too. Please also run this test against a pinned current languageserver in CI so the provider branch cannot be skipped.

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]])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Make session-only exported factories visible to member inference

Even after correcting the worker target, caching the package snapshot here is not sufficient on languageserver cf116dd. I reran the PR's VS Code integration test with that single temporary correction: sessionChain()$all()$su still had no member suggestions. A focused provider check showed the cache contained the fixture and the workspace startup list included it; adding library(sessionfixture) to the document then produced sunrise. The current server promotes exported function roots for static library() imports, but not for its startup packages. Please coordinate a server-side integration that exposes session-attached factories to member inference and run the integration test against that server.

}
}
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