Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 14 additions & 3 deletions .vscode-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ const common = {
}
};

// Keep mock/provider tests independent of R language-server startup. The real
// Interactive integration suite has its own window with the built-in LSP enabled.
// Keep mock/provider tests independent of R language-server startup. Editor
// command tests also need a fresh window after fixtures create terminals and
// webviews. The real Interactive suite has its own window with the LSP enabled.
function testProfile(name, lspEnabled) {
const directory = path.resolve('.vscode-test/user-data', name);
mkdirSync(path.join(directory, 'User'), { recursive: true });
Expand All @@ -35,10 +36,20 @@ export default defineConfig([
{
...common,
label: 'provider-tests',
files: 'out/test/suite/**/!(interactiveEditor).test.js',
files: 'out/test/suite/**/!(interactiveEditor|interactiveInputFeatures|completionMiddleware).test.js',
mocha: { ...common.mocha, require: './out/test/common/extensionSetup.js' },
launchArgs: testProfile('provider-tests', false)
},
{
...common,
label: 'editor-command-tests',
files: [
'out/test/suite/interactiveInputFeatures.test.js',
'out/test/suite/completionMiddleware.test.js'
],
mocha: { ...common.mocha, require: './out/test/common/extensionSetup.js' },
launchArgs: testProfile('editor-command-tests', false)
},
{
...common,
label: 'interactive-integration',
Expand Down
75 changes: 65 additions & 10 deletions R/tests/attach_sess.R
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,13 @@ writeLines(c(
"while (length(ids) < count && Sys.time() < deadline) {",
" requests <- list.files(directory, pattern='\\\\.request$', full.names=TRUE)",
" for (request in requests) {",
" lines <- readLines(request, warn=FALSE)",
" if (length(lines) != 5L || lines[[1L]] != 'vscode-r-sess-consent-v1') stop('bad request')",
" id <- lines[[2L]]; if (id %in% ids) next",
" id <- sub('\\\\.request$', '', basename(request))",
" if (id %in% ids) next",
" lines <- suppressWarnings(tryCatch(readLines(request, warn=FALSE),",
" error=function(e) { if (!file.exists(request)) NULL else stop(e) }))",
" if (is.null(lines)) next",
" if (length(lines) != 5L || lines[[1L]] != 'vscode-r-sess-consent-v1' ||",
" lines[[2L]] != id) stop('bad request')",
" if (nzchar(wait_marker) && !file.exists(wait_marker)) next",
" ids <- c(ids, id); temporary <- tempfile(tmpdir=directory)",
" identities <- c(identities, lines[[4L]])",
Expand Down Expand Up @@ -609,7 +613,7 @@ run_case <- function(mode) {
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 = " ")))
stop(paste("consent agent failed in", mode, ":", paste(agent_status, collapse = " ")))
}
log_path <- file.path(case_root, "consent-ids")
ids <- readLines(log_path, warn = FALSE)
Expand Down Expand Up @@ -647,7 +651,8 @@ wait_for_request <- function(directory, seconds) {
requests[[1L]]
}

start_consent_agent <- function(case_root, answer, wait_marker = "", label = "consent") {
start_consent_agent <- function(case_root, answer, wait_marker = "", label = "consent",
count = 1L, script = agent) {
consent <- file.path(case_root, "consent")
log <- file.path(case_root, paste0(label, "-ids"))
identity_log <- file.path(case_root, paste0(label, "-identities"))
Expand All @@ -657,8 +662,8 @@ start_consent_agent <- function(case_root, answer, wait_marker = "", label = "co
system2(
r_binary,
shQuote(c(
"--vanilla", "--slave", paste0("--file=", agent), "--args",
consent, "1", answer, log, identity_log, status, wait_marker
"--vanilla", "--slave", paste0("--file=", script), "--args",
consent, as.character(count), answer, log, identity_log, status, wait_marker
)),
wait = FALSE,
stdout = stdout,
Expand All @@ -668,25 +673,75 @@ start_consent_agent <- function(case_root, answer, wait_marker = "", label = "co
stdout = stdout, stderr = stderr)
}

wait_for_agent <- function(paths) {
wait_for_agent <- function(paths, count = 1L) {
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))
stopifnot(length(ids) == count, !anyDuplicated(ids),
all(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))
stopifnot(identical(identities, rep(expected_runtime, count)))
ids
}

# Force the list/read race instead of relying on platform scheduling: one request
# vanishes after listing, and a handled request remains while the next arrives.
scan_root <- file.path(root, "consent-scan-race")
scan_consent <- file.path(scan_root, "consent")
dir.create(scan_consent, recursive = TRUE)
expired_id <- paste0(strrep("0", 16), "expired")
first_id <- strrep("a", 20)
second_id <- strrep("b", 20)
expired_request <- file.path(scan_consent, paste0(expired_id, ".request"))
first_request <- file.path(scan_consent, paste0(first_id, ".request"))
second_request <- file.path(scan_consent, paste0(second_id, ".request"))
first_response <- file.path(scan_consent, paste0(first_id, ".response"))
scan_reads <- file.path(scan_root, "first-request-reads")
request_lines <- function(id) {
c("vscode-r-sess-consent-v1", id, revision, sess_runtime_identity(), "missing")
}
writeLines(request_lines(expired_id), expired_request)
writeLines(request_lines(first_id), first_request)
scan_agent <- file.path(scan_root, "raced-agent.R")
writeLines(c(
paste0("expired <- ", r_literal(expired_request)),
paste0("first <- ", r_literal(first_request)),
paste0("second <- ", r_literal(second_request)),
paste0("response <- ", r_literal(first_response)),
"reads <- 0L",
"list.files <- function(...) {",
paste0(" if (file.exists(response) && !file.exists(second)) writeLines(",
"c(", paste(vapply(request_lines(second_id), r_literal, ""), collapse = ", "),
"), second)"),
" files <- base::list.files(...)",
" if (expired %in% files) unlink(expired)",
" files",
"}",
"readLines <- function(con, ...) {",
" if (identical(con, first)) {",
" reads <<- reads + 1L",
" if (reads > 1L) stop('handled request read twice')",
" }",
" base::readLines(con, ...)",
"}",
paste0("source(", r_literal(agent), ")"),
paste0("writeLines(as.character(reads), ", r_literal(scan_reads), ")")
), scan_agent)
scan_paths <- start_consent_agent(scan_root, "decline", count = 2L, script = scan_agent)
scan_ids <- wait_for_agent(scan_paths, count = 2L)
wait_for_file(scan_reads, 5, "consent scan fixture completion")
stopifnot(identical(scan_ids, c(first_id, second_id)), !file.exists(expired_request),
identical(readLines(scan_reads), "1"))

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_)
Expand Down
38 changes: 38 additions & 0 deletions scripts/benchmark-console-output.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Run after TypeScript compilation: node scripts/benchmark-console-output.cjs
const assert = require('node:assert/strict');
const { performance } = require('node:perf_hooks');
const { StreamText } = require('../out/interactive/streamText');
const { NotebookOutputState, prepareNotebookStream, updateNotebookOutputs } = require('../out/interactive/notebookOutput');

async function measure(incremental, writes) {
const stream = new StreamText('');
const state = new NotebookOutputState();
const outputs = [];
let delivered = 0;
const normalize = value => 'mime' in value ? [value] : [...value];
const execution = {
replaceOutput: value => { outputs.splice(0, outputs.length, ...value); return Promise.resolve(); },
appendOutput: value => { delivered += value.flatMap(output => output.items).reduce((sum, item) => sum + item.data.length, 0); outputs.push(...value); return Promise.resolve(); },
replaceOutputItems: (value, output) => { const items = normalize(value); delivered += items.reduce((sum, item) => sum + item.data.length, 0); output.items = items; return Promise.resolve(); },
appendOutputItems: (value, output) => { const items = normalize(value); delivered += items.reduce((sum, item) => sum + item.data.length, 0); output.items = [{ mime: items[0].mime, data: Buffer.concat([...output.items, ...items].map(item => item.data)) }]; return Promise.resolve(); },
};
const started = performance.now();
for (let write = 0; write < writes; write++) {
stream.append('x'.repeat(32768));
const output = { metadata: { rStreamId: 'console' }, items: [{ mime: 'application/vnd.code.notebook.stdout', data: incremental ? Buffer.alloc(0) : Buffer.from(stream.snapshot().text()) }] };
if (incremental) prepareNotebookStream(output, stream.snapshot());
await updateNotebookOutputs(execution, [...outputs], [output], state);
}
assert.equal(outputs[0].items[0].data.length, writes * 32768);
return { incremental, writes, finalMiB: writes / 32, deliveredMiB: delivered / 1024 / 1024, elapsedMs: +(performance.now() - started).toFixed(3) };
}

async function main() {
for (const writes of [32, 128, 512]) {
const before = await measure(false, writes);
const after = await measure(true, writes);
assert.equal(after.deliveredMiB, after.finalMiB);
console.log(JSON.stringify({ before, after }));
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });
44 changes: 44 additions & 0 deletions scripts/benchmark-journal.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Run after compiling TypeScript: node scripts/benchmark-journal.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { performance } = require('node:perf_hooks');
const { SessionJournal, readPreviousJournalAsync } = require(process.argv[2] || '../out/interactive/journal');

async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'r-journal-benchmark-'));
try {
for (const outputMiB of [16, 112]) {
const directory = path.join(root, String(outputMiB), 'generation');
const journal = new SessionJournal(directory, 'generation');
for (let order = 0; order < outputMiB / 4; order++) {
const record = journal.accept({ id: 'old' + order, code: 'print(large_result)' }).record;
journal.append('accepted', { record }, record.id);
for (let chunk = 0; chunk < 128; chunk++) journal.append('stream', { text: 'x'.repeat(32768), channel: 'stdout' }, record.id);
const final = { ...record, state: 'success', ended: Date.now() };
journal.update(final); journal.append('finished', { record: final }, record.id);
}
const latest = journal.accept({ id: 'latest', code: '1 + 1' }).record;
journal.append('accepted', { record: latest }, latest.id);
journal.append('stream', { text: '[1] 2\n', channel: 'stdout' }, latest.id);
journal.append('finished', { record: { ...latest, state: 'success' } }, latest.id);
const samples = [];
for (let run = 0; run < 7; run++) {
const start = performance.now(), recent = journal.recent(1);
assert.equal(recent.events.length, 3);
samples.push(performance.now() - start);
}
journal.close();
const archiveSamples = [];
for (let run = 0; run < 7; run++) {
const start = performance.now(), recent = await readPreviousJournalAsync(path.dirname(directory), 'generation', 1);
assert.equal(recent.events.length, 3);
archiveSamples.push(performance.now() - start);
}
samples.sort((a, b) => a - b); archiveSamples.sort((a, b) => a - b);
console.log(JSON.stringify({ outputMiB, recentMedianMs: +samples[3].toFixed(3), archivedMedianMs: +archiveSamples[3].toFixed(3) }));
}
} finally { fs.rmSync(root, { recursive: true, force: true }); }
}
main().catch(error => { console.error(error); process.exitCode = 1; });
30 changes: 30 additions & 0 deletions scripts/benchmark-workspace-refresh.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Run from the repository root: Rscript --vanilla scripts/benchmark-workspace-refresh.R
# Metadata is always recomputed; unchanged replies avoid the full JSON encoding.
local({
helpers <- new.env(parent = globalenv())
source("sess/R/zzz.R", local = helpers)
source("sess/R/handlers.R", local = helpers)
encode <- function(snapshot) {
jsonlite::toJSON(list(jsonrpc = "2.0", id = 1L, result = snapshot),
auto_unbox = TRUE, null = "null", force = TRUE, digits = NA)
}
for (n in c(1000L, 10000L)) {
symbols <- sprintf("workspace_benchmark_%05d", seq_len(n))
list2env(setNames(rep(list(1:10), n), symbols), envir = .GlobalEnv)
snapshot <- helpers$get_workspace_data()
invisible(encode(snapshot))
snapshot <- helpers$get_workspace_data()
full <- replicate(5L, unname(system.time(encode(helpers$get_workspace_data()))[["elapsed"]]))
unchanged <- replicate(5L, unname(system.time(
encode(helpers$get_workspace_data(snapshot$revision))
)[["elapsed"]]))
cat(jsonlite::toJSON(list(
objects = n,
full_median_ms = median(full) * 1000,
unchanged_median_ms = median(unchanged) * 1000,
full_bytes = nchar(encode(snapshot), type = "bytes"),
unchanged_bytes = nchar(encode(helpers$get_workspace_data(snapshot$revision)), type = "bytes")
), auto_unbox = TRUE), "\n", sep = "")
rm(list = symbols, envir = .GlobalEnv)
}
})
69 changes: 59 additions & 10 deletions sess/R/handlers.R
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,32 @@ workspace_child_count <- function(object) {
}
}

get_workspace_data <- function() {
# Read structural metadata without invoking arbitrary S3 inspection methods.
# Expensive/custom completion still runs through handle_complete() on demand.
workspace_length <- function(object) {
if (is.environment(object)) return(length(workspace_env_names(object)))
if (isS4(object)) return(NULL)
if (inherits(object, "POSIXlt")) return(length(.subset2(object, 1L)))
if (is.data.frame(object)) return(length(attr(object, "names", exact = TRUE)))
if (!is.object(object)) return(length(object))
dimensions <- attr(object, "dim", exact = TRUE)
if (!is.null(dimensions)) return(prod(dimensions))
object_names <- attr(object, "names", exact = TRUE)
if (!is.null(object_names)) return(length(object_names))
if (typeof(object) %in% c("closure", "builtin")) return(1L)
# unclass() can copy an entire classed vector. Check dispatch before using the
# primitive length instead; unknown custom lengths belong to explicit queries.
registry <- get(".__S3MethodsTable__.", envir = .BaseNamespaceEnv, inherits = FALSE)
has_method <- any(vapply(paste0("length.", c(class(object), "default")), function(method) {
# exists(mode = "any") does not force active bindings or promises. Treat an
# ambiguous binding as a method; getS3method's function lookup would force it.
exists(method, envir = .GlobalEnv, mode = "any", inherits = TRUE) ||
exists(method, envir = registry, mode = "any", inherits = FALSE)
}, logical(1)))
if (has_method) NULL else length(object)
}

get_workspace_data <- function(revision = NULL) {
env <- .GlobalEnv
all_names <- workspace_env_names(env)

Expand All @@ -54,8 +79,12 @@ get_workspace_data <- function() {
obj <- env[[name]]
obj_class <- class(obj)
obj_type <- typeof(obj)
obj_length <- length(obj)
obj_dim <- dim(obj)
obj_length <- workspace_length(obj)
obj_dim <- if (is.data.frame(obj)) {
c(.row_names_info(obj, 2L), obj_length)
} else {
attr(obj, "dim", exact = TRUE)
}
first_class <- if (length(obj_class)) obj_class[[1]] else obj_type
info <- list(
class = class(obj),
Expand All @@ -66,17 +95,25 @@ get_workspace_data <- function() {
} else if (obj_type == "environment") {
"<environment>"
} else if (obj_type %in% c("closure", "builtin")) {
trimws(try_capture_str(obj))
trimws(try_capture_str(if (is.object(obj)) unclass(obj) else obj))
} else if (is.null(obj_length)) {
first_class
} else {
paste0(first_class, ", length ", obj_length)
},
has_children = workspace_child_count(obj) > 0L
has_children = if (is.environment(obj)) {
obj_length > 0L
} else if (isS4(obj)) {
length(methods::slotNames(obj)) > 0L
} else {
!inherits(obj, "POSIXlt") && obj_type %in% c("list", "pairlist") &&
(is.null(obj_length) || obj_length > 0L)
}
)
if (is.null(obj_length)) info$length <- NULL

obj_names <- if (is.object(obj)) {
utils::.DollarNames(obj, pattern = "")
} else if (is.recursive(obj)) {
names(obj)
obj_names <- if (obj_type %in% c("list", "pairlist")) {
attr(obj, "names", exact = TRUE)
} else {
NULL
}
Expand All @@ -95,11 +132,23 @@ get_workspace_data <- function() {
})
names(objs) <- all_names

list(
snapshot <- list(
globalenv = objs,
search = search()[-1],
loaded_namespaces = loadedNamespaces()
)
# Compare metadata after inspecting every binding: reference objects can mutate
# in place, and R task callbacks do not cover every embedding's evaluation.
if (!identical(snapshot, .sess_env$workspace_snapshot)) {
.sess_env$workspace_snapshot <- snapshot
.sess_env$workspace_revision <- (.sess_env$workspace_revision %||% 0) + 1
}
current_revision <- .sess_env$workspace_revision
if (length(revision) == 1L && is.numeric(revision) &&
is.finite(revision) && identical(as.numeric(revision), current_revision)) {
return(list(revision = current_revision, unchanged = TRUE))
}
c(snapshot, list(revision = current_revision))
}

workspace_object <- function(name, path = list()) {
Expand Down
2 changes: 1 addition & 1 deletion sess/R/server.R
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ dispatch_message <- function(line) {
interactive_stop()
TRUE
},
"workspace" = function(p) get_workspace_data(),
"workspace" = function(p) get_workspace_data(p$revision),
"workspace_children" = function(p) get_workspace_children(p$name, p$path, p$start, p$view_id),
"workspace_view" = function(p) handle_workspace_view(p$name, p$path),
"hover" = function(p) handle_hover(p$expr),
Expand Down
Loading
Loading