From ce0d07f6ee6f34f961c207a351a241ac30315438 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Wed, 15 Jul 2026 10:38:13 -0700 Subject: [PATCH 1/4] contribute R to terminals --- extensions/positron-r/src/runtime-manager.ts | 57 +++++++-- .../positron-r/src/terminal-environment.ts | 97 +++++++++++++++ .../test/terminal-environment.unit.test.ts | 116 ++++++++++++++++++ 3 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 extensions/positron-r/src/terminal-environment.ts create mode 100644 extensions/positron-r/src/test/terminal-environment.unit.test.ts diff --git a/extensions/positron-r/src/runtime-manager.ts b/extensions/positron-r/src/runtime-manager.ts index eaeccfd8b0a6..d870abddc1d0 100644 --- a/extensions/positron-r/src/runtime-manager.ts +++ b/extensions/positron-r/src/runtime-manager.ts @@ -13,9 +13,10 @@ import { createJupyterKernelSpec } from './kernel-spec'; import { LOGGER, supervisorApi } from './extension'; import { POSITRON_R_INTERPRETERS_DEFAULT_SETTING_KEY } from './constants'; import { getDefaultInterpreterPath } from './interpreter-settings.js'; -import { dirname } from 'path'; import { getEnvironmentModulesApi } from './provider-module.js'; import { setupArkJupyterKernel } from './kernel'; +import { getRTerminalEnvironmentMutations } from './terminal-environment'; +import { RSessionManager } from './session-manager'; export class RRuntimeManager implements positron.LanguageRuntimeManager { @@ -31,6 +32,26 @@ export class RRuntimeManager implements positron.LanguageRuntimeManager { constructor(private readonly _context: vscode.ExtensionContext) { this.onDidDiscoverRuntime = this.onDidDiscoverRuntimeEmitter.event; this.onDidCompleteDiscovery = this._onDidCompleteDiscoveryEmitter.event; + + // Keep the contributed terminal environment in sync with the active R + // console. Creating or restoring a session updates it (see + // createSession/restoreSession); this handles the case where the user + // switches the foreground session between R consoles that are already + // running different R versions, so a newly launched terminal matches the + // console the user is currently working in. When multiple R consoles + // exist, the most recently focused one wins; this ambiguity is expected + // (https://github.com/posit-dev/positron/issues/7403). + this._context.subscriptions.push( + positron.runtime.onDidChangeForegroundSession(async (sessionId) => { + if (!sessionId) { + return; + } + const session = await RSessionManager.instance.getSessionById(sessionId); + if (session) { + this.updateEnvironment(session.runtimeMetadata); + } + }) + ); } /** @@ -145,17 +166,31 @@ export class RRuntimeManager implements positron.LanguageRuntimeManager { return; } - // Update the QUARTO_R environment variable to point to the script path - // of the R runtime, if needed. Note that our 'scriptpath' is the full - // path to the Rscript binary (foo/bar/Rscript), but Quarto expects the - // directory (foo/bar) - if (metadataExtra.scriptpath) { - const currentQuartoR = collection.get('QUARTO_R'); - const scriptPath = dirname(metadataExtra.scriptpath); - if (currentQuartoR?.value !== scriptPath) { - collection.replace('QUARTO_R', scriptPath); - LOGGER.debug(`Updated QUARTO_R environment variable to ${scriptPath}`); + // Contribute environment variables so that terminals launched from + // Positron use the same R installation as the active console (PATH, + // R_HOME, QUARTO_R). This ensures that extensions which start R in a + // terminal (Quarto Preview, Shiny Run App, etc.) run against the R the + // user selected. Apply at both process creation and shell integration so + // the variables are present however the terminal resolves them. + const options = { applyAtProcessCreation: true, applyAtShellIntegration: true }; + for (const mutation of getRTerminalEnvironmentMutations(metadataExtra)) { + // Skip variables that already hold the desired value, to avoid + // needlessly marking open terminals as stale. + if (collection.get(mutation.variable)?.value === mutation.value) { + continue; + } + switch (mutation.action) { + case 'replace': + collection.replace(mutation.variable, mutation.value, options); + break; + case 'prepend': + collection.prepend(mutation.variable, mutation.value, options); + break; + case 'append': + collection.append(mutation.variable, mutation.value, options); + break; } + LOGGER.debug(`Updated terminal environment variable ${mutation.variable} (${mutation.action}) to ${mutation.value}`); } // Update the ark Jupyter kernel spec with this R's environment. diff --git a/extensions/positron-r/src/terminal-environment.ts b/extensions/positron-r/src/terminal-environment.ts new file mode 100644 index 000000000000..d8baa194fa6e --- /dev/null +++ b/extensions/positron-r/src/terminal-environment.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { dirname } from 'path'; +import { RMetadataExtra } from './r-installation'; + +/** + * The kind of mutation to apply to a terminal environment variable. Mirrors the + * `replace`/`prepend`/`append` methods on VS Code's + * `EnvironmentVariableCollection`. + */ +export type TerminalEnvironmentAction = 'replace' | 'prepend' | 'append'; + +/** + * A single mutation to apply to the contributed terminal environment. + */ +export interface TerminalEnvironmentMutation { + readonly action: TerminalEnvironmentAction; + readonly variable: string; + readonly value: string; +} + +/** + * Compute the terminal environment variable mutations that make a terminal use + * the same R installation as the active console. + * + * Several extensions (Quarto Preview, Shiny Run App, etc.) start R in a terminal + * to perform background tasks. Without these mutations, those tasks run against + * the system default R rather than the version selected in Positron's console. + * + * When multiple consoles run different R versions, whichever is activated last + * wins; this ambiguity is expected and acceptable (see + * https://github.com/posit-dev/positron/issues/7403). + * + * @param metadataExtra Extra metadata for the active R installation. + * @param platform The platform to compute mutations for. Defaults to the + * current platform; overridable for testing. + * @returns The mutations to apply to the terminal environment collection. + */ +export function getRTerminalEnvironmentMutations( + metadataExtra: RMetadataExtra, + platform: NodeJS.Platform = process.platform +): TerminalEnvironmentMutation[] { + const mutations: TerminalEnvironmentMutation[] = []; + const pathSeparator = platform === 'win32' ? ';' : ':'; + + // Prepend the directory containing the selected R binary to PATH so that + // `R`, `Rscript`, and tools that shell out to them resolve to the version + // selected in the console rather than the system default. This is the + // primary mechanism by which the terminal's R matches the console's R, and + // mirrors how rig makes a selected R version available (symlinks on PATH). + if (metadataExtra.binpath) { + mutations.push({ + action: 'prepend', + variable: 'PATH', + value: dirname(metadataExtra.binpath) + pathSeparator, + }); + } + + // Set R_HOME so tools that read it (rather than deriving it from the R + // binary) find the selected installation. R_HOME is R-specific, so setting + // it in the terminal does not affect unrelated programs. + if (metadataExtra.homepath) { + mutations.push({ + action: 'replace', + variable: 'R_HOME', + value: metadataExtra.homepath, + }); + } + + // Point QUARTO_R at the directory containing Rscript so that `quarto render` + // (and the bundled Quarto extension) use the selected R version. Note that + // `scriptpath` is the full path to the Rscript binary (foo/bar/Rscript), but + // Quarto expects the directory (foo/bar). + if (metadataExtra.scriptpath) { + mutations.push({ + action: 'replace', + variable: 'QUARTO_R', + value: dirname(metadataExtra.scriptpath), + }); + } + + // We intentionally do NOT set DYLD_LIBRARY_PATH (macOS) or LD_LIBRARY_PATH + // (Linux) here, even though the Ark kernel sets them. Those variables affect + // dynamic linking for *every* program run in the terminal, not just R, and + // can cause unrelated tools to load R's bundled copies of common libraries + // (libcurl, libz, etc.). R's own launcher scripts (`R`/`Rscript`) already + // configure their library paths, so the variables are unnecessary for R to + // work from the terminal. This mirrors rig, which scopes DYLD_LIBRARY_PATH to + // R's launcher script rather than exporting it to the shell. Ark needs the + // variables because it is a compiled binary that loads libR directly, + // bypassing the launcher scripts. + + return mutations; +} diff --git a/extensions/positron-r/src/test/terminal-environment.unit.test.ts b/extensions/positron-r/src/test/terminal-environment.unit.test.ts new file mode 100644 index 000000000000..e1cacd200a5f --- /dev/null +++ b/extensions/positron-r/src/test/terminal-environment.unit.test.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { RMetadataExtra } from '../r-installation'; +import { getRTerminalEnvironmentMutations, TerminalEnvironmentMutation } from '../terminal-environment'; + +/** + * Build an RMetadataExtra with sensible defaults for the paths under test. + */ +function makeMetadataExtra(overrides: Partial = {}): RMetadataExtra { + return { + homepath: '/opt/R/4.4.0/lib/R', + binpath: '/opt/R/4.4.0/bin/R', + scriptpath: '/opt/R/4.4.0/bin/Rscript', + current: false, + default: false, + reasonDiscovered: null, + ...overrides, + }; +} + +/** + * Find the mutation for a given variable, or undefined if none exists. + */ +function find(mutations: TerminalEnvironmentMutation[], variable: string): TerminalEnvironmentMutation | undefined { + return mutations.find(m => m.variable === variable); +} + +suite('getRTerminalEnvironmentMutations', () => { + + test('prepends the R binary directory to PATH (posix separator)', () => { + const mutations = getRTerminalEnvironmentMutations(makeMetadataExtra(), 'darwin'); + const path = find(mutations, 'PATH'); + + assert.ok(path, 'expected a PATH mutation'); + assert.strictEqual(path!.action, 'prepend'); + assert.strictEqual(path!.value, '/opt/R/4.4.0/bin:'); + }); + + test('prepends the R binary directory to PATH (windows separator)', () => { + const mutations = getRTerminalEnvironmentMutations( + makeMetadataExtra({ binpath: 'C:/R/R-4.4.0/bin/x64/R.exe' }), + 'win32' + ); + const path = find(mutations, 'PATH'); + + assert.ok(path, 'expected a PATH mutation'); + assert.strictEqual(path!.action, 'prepend'); + assert.strictEqual(path!.value, 'C:/R/R-4.4.0/bin/x64;'); + }); + + test('replaces R_HOME with the home path', () => { + const mutations = getRTerminalEnvironmentMutations(makeMetadataExtra(), 'darwin'); + const rHome = find(mutations, 'R_HOME'); + + assert.ok(rHome, 'expected an R_HOME mutation'); + assert.strictEqual(rHome!.action, 'replace'); + assert.strictEqual(rHome!.value, '/opt/R/4.4.0/lib/R'); + }); + + test('sets QUARTO_R to the directory containing Rscript, not Rscript itself', () => { + const mutations = getRTerminalEnvironmentMutations(makeMetadataExtra(), 'darwin'); + const quartoR = find(mutations, 'QUARTO_R'); + + assert.ok(quartoR, 'expected a QUARTO_R mutation'); + assert.strictEqual(quartoR!.action, 'replace'); + assert.strictEqual(quartoR!.value, '/opt/R/4.4.0/bin'); + }); + + test('does not set library-path variables on macOS', () => { + const mutations = getRTerminalEnvironmentMutations(makeMetadataExtra(), 'darwin'); + + assert.strictEqual(find(mutations, 'DYLD_LIBRARY_PATH'), undefined); + assert.strictEqual(find(mutations, 'LD_LIBRARY_PATH'), undefined); + }); + + test('does not set library-path variables on Linux', () => { + const mutations = getRTerminalEnvironmentMutations(makeMetadataExtra(), 'linux'); + + assert.strictEqual(find(mutations, 'LD_LIBRARY_PATH'), undefined); + assert.strictEqual(find(mutations, 'DYLD_LIBRARY_PATH'), undefined); + }); + + test('omits PATH when there is no binary path', () => { + const mutations = getRTerminalEnvironmentMutations( + makeMetadataExtra({ binpath: '' }), + 'darwin' + ); + + assert.strictEqual(find(mutations, 'PATH'), undefined); + // The other variables are still contributed. + assert.ok(find(mutations, 'R_HOME')); + assert.ok(find(mutations, 'QUARTO_R')); + }); + + test('omits QUARTO_R when there is no script path', () => { + const mutations = getRTerminalEnvironmentMutations( + makeMetadataExtra({ scriptpath: '' }), + 'darwin' + ); + + assert.strictEqual(find(mutations, 'QUARTO_R'), undefined); + assert.ok(find(mutations, 'PATH')); + assert.ok(find(mutations, 'R_HOME')); + }); + + test('contributes only the expected variables', () => { + const mutations = getRTerminalEnvironmentMutations(makeMetadataExtra(), 'darwin'); + const variables = mutations.map(m => m.variable).sort(); + + assert.deepStrictEqual(variables, ['PATH', 'QUARTO_R', 'R_HOME']); + }); +}); From 1c20bd133a8744c0cd620e272d340ccc1d8623cb Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Thu, 30 Jul 2026 16:19:51 -0700 Subject: [PATCH 2/4] you know what, forget R_HOME --- extensions/positron-r/src/runtime-manager.ts | 2 +- extensions/positron-r/src/terminal-environment.ts | 13 +++---------- .../src/test/terminal-environment.unit.test.ts | 11 +++-------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/extensions/positron-r/src/runtime-manager.ts b/extensions/positron-r/src/runtime-manager.ts index d870abddc1d0..ea3f4d62a6bf 100644 --- a/extensions/positron-r/src/runtime-manager.ts +++ b/extensions/positron-r/src/runtime-manager.ts @@ -168,7 +168,7 @@ export class RRuntimeManager implements positron.LanguageRuntimeManager { // Contribute environment variables so that terminals launched from // Positron use the same R installation as the active console (PATH, - // R_HOME, QUARTO_R). This ensures that extensions which start R in a + // QUARTO_R). This ensures that extensions which start R in a // terminal (Quarto Preview, Shiny Run App, etc.) run against the R the // user selected. Apply at both process creation and shell integration so // the variables are present however the terminal resolves them. diff --git a/extensions/positron-r/src/terminal-environment.ts b/extensions/positron-r/src/terminal-environment.ts index d8baa194fa6e..3a37739e4a42 100644 --- a/extensions/positron-r/src/terminal-environment.ts +++ b/extensions/positron-r/src/terminal-environment.ts @@ -59,16 +59,9 @@ export function getRTerminalEnvironmentMutations( }); } - // Set R_HOME so tools that read it (rather than deriving it from the R - // binary) find the selected installation. R_HOME is R-specific, so setting - // it in the terminal does not affect unrelated programs. - if (metadataExtra.homepath) { - mutations.push({ - action: 'replace', - variable: 'R_HOME', - value: metadataExtra.homepath, - }); - } + // We do not set R_HOME here: R's launcher scripts (`R`/`Rscript`) derive it + // themselves from their own location, so once PATH points at the selected R, + // R_HOME is redundant. // Point QUARTO_R at the directory containing Rscript so that `quarto render` // (and the bundled Quarto extension) use the selected R version. Note that diff --git a/extensions/positron-r/src/test/terminal-environment.unit.test.ts b/extensions/positron-r/src/test/terminal-environment.unit.test.ts index e1cacd200a5f..4a0af99c23fe 100644 --- a/extensions/positron-r/src/test/terminal-environment.unit.test.ts +++ b/extensions/positron-r/src/test/terminal-environment.unit.test.ts @@ -52,13 +52,10 @@ suite('getRTerminalEnvironmentMutations', () => { assert.strictEqual(path!.value, 'C:/R/R-4.4.0/bin/x64;'); }); - test('replaces R_HOME with the home path', () => { + test('does not set R_HOME (R launcher scripts derive it themselves)', () => { const mutations = getRTerminalEnvironmentMutations(makeMetadataExtra(), 'darwin'); - const rHome = find(mutations, 'R_HOME'); - assert.ok(rHome, 'expected an R_HOME mutation'); - assert.strictEqual(rHome!.action, 'replace'); - assert.strictEqual(rHome!.value, '/opt/R/4.4.0/lib/R'); + assert.strictEqual(find(mutations, 'R_HOME'), undefined); }); test('sets QUARTO_R to the directory containing Rscript, not Rscript itself', () => { @@ -92,7 +89,6 @@ suite('getRTerminalEnvironmentMutations', () => { assert.strictEqual(find(mutations, 'PATH'), undefined); // The other variables are still contributed. - assert.ok(find(mutations, 'R_HOME')); assert.ok(find(mutations, 'QUARTO_R')); }); @@ -104,13 +100,12 @@ suite('getRTerminalEnvironmentMutations', () => { assert.strictEqual(find(mutations, 'QUARTO_R'), undefined); assert.ok(find(mutations, 'PATH')); - assert.ok(find(mutations, 'R_HOME')); }); test('contributes only the expected variables', () => { const mutations = getRTerminalEnvironmentMutations(makeMetadataExtra(), 'darwin'); const variables = mutations.map(m => m.variable).sort(); - assert.deepStrictEqual(variables, ['PATH', 'QUARTO_R', 'R_HOME']); + assert.deepStrictEqual(variables, ['PATH', 'QUARTO_R']); }); }); From 173ec3cdeebc2ec81b2b35c17353be9256181eaa Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Thu, 30 Jul 2026 17:35:00 -0700 Subject: [PATCH 3/4] fix windows paths --- extensions/positron-r/src/terminal-environment.ts | 10 +++++++--- .../src/test/terminal-environment.unit.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/extensions/positron-r/src/terminal-environment.ts b/extensions/positron-r/src/terminal-environment.ts index 3a37739e4a42..a16aebff0c12 100644 --- a/extensions/positron-r/src/terminal-environment.ts +++ b/extensions/positron-r/src/terminal-environment.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { dirname } from 'path'; +import { posix, win32 } from 'path'; import { RMetadataExtra } from './r-installation'; /** @@ -44,6 +44,10 @@ export function getRTerminalEnvironmentMutations( platform: NodeJS.Platform = process.platform ): TerminalEnvironmentMutation[] { const mutations: TerminalEnvironmentMutation[] = []; + // Parse paths with the flavor matching the target platform, not the host + // running this code, so that Windows backslash paths are handled correctly + // even when computing mutations on a posix host (e.g. in tests). + const path = platform === 'win32' ? win32 : posix; const pathSeparator = platform === 'win32' ? ';' : ':'; // Prepend the directory containing the selected R binary to PATH so that @@ -55,7 +59,7 @@ export function getRTerminalEnvironmentMutations( mutations.push({ action: 'prepend', variable: 'PATH', - value: dirname(metadataExtra.binpath) + pathSeparator, + value: path.dirname(metadataExtra.binpath) + pathSeparator, }); } @@ -71,7 +75,7 @@ export function getRTerminalEnvironmentMutations( mutations.push({ action: 'replace', variable: 'QUARTO_R', - value: dirname(metadataExtra.scriptpath), + value: path.dirname(metadataExtra.scriptpath), }); } diff --git a/extensions/positron-r/src/test/terminal-environment.unit.test.ts b/extensions/positron-r/src/test/terminal-environment.unit.test.ts index 4a0af99c23fe..44e511538262 100644 --- a/extensions/positron-r/src/test/terminal-environment.unit.test.ts +++ b/extensions/positron-r/src/test/terminal-environment.unit.test.ts @@ -42,14 +42,14 @@ suite('getRTerminalEnvironmentMutations', () => { test('prepends the R binary directory to PATH (windows separator)', () => { const mutations = getRTerminalEnvironmentMutations( - makeMetadataExtra({ binpath: 'C:/R/R-4.4.0/bin/x64/R.exe' }), + makeMetadataExtra({ binpath: 'C:\\R\\R-4.4.0\\bin\\x64\\R.exe' }), 'win32' ); const path = find(mutations, 'PATH'); assert.ok(path, 'expected a PATH mutation'); assert.strictEqual(path!.action, 'prepend'); - assert.strictEqual(path!.value, 'C:/R/R-4.4.0/bin/x64;'); + assert.strictEqual(path!.value, 'C:\\R\\R-4.4.0\\bin\\x64;'); }); test('does not set R_HOME (R launcher scripts derive it themselves)', () => { From 785ec2d851d2b9cdb8338498c214897dac7b9196 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Thu, 30 Jul 2026 17:39:09 -0700 Subject: [PATCH 4/4] align session activation semantics --- extensions/positron-r/src/runtime-manager.ts | 18 ++++++++---------- extensions/positron-r/src/session-manager.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/extensions/positron-r/src/runtime-manager.ts b/extensions/positron-r/src/runtime-manager.ts index ea3f4d62a6bf..fb547616145e 100644 --- a/extensions/positron-r/src/runtime-manager.ts +++ b/extensions/positron-r/src/runtime-manager.ts @@ -38,18 +38,16 @@ export class RRuntimeManager implements positron.LanguageRuntimeManager { // createSession/restoreSession); this handles the case where the user // switches the foreground session between R consoles that are already // running different R versions, so a newly launched terminal matches the - // console the user is currently working in. When multiple R consoles - // exist, the most recently focused one wins; this ambiguity is expected + // console the user is currently working in. We ride the session + // manager's console-activation event rather than the raw foreground + // event so this inherits its console-only filtering and change + // deduplication (notebook sessions must not drive the terminal + // environment). When multiple R consoles exist, the most recently + // focused one wins; this ambiguity is expected // (https://github.com/posit-dev/positron/issues/7403). this._context.subscriptions.push( - positron.runtime.onDidChangeForegroundSession(async (sessionId) => { - if (!sessionId) { - return; - } - const session = await RSessionManager.instance.getSessionById(sessionId); - if (session) { - this.updateEnvironment(session.runtimeMetadata); - } + RSessionManager.instance.onDidActivateConsoleSession((session) => { + this.updateEnvironment(session.runtimeMetadata); }) ); } diff --git a/extensions/positron-r/src/session-manager.ts b/extensions/positron-r/src/session-manager.ts index 835d7640e5c5..79dc8eaf1bb6 100644 --- a/extensions/positron-r/src/session-manager.ts +++ b/extensions/positron-r/src/session-manager.ts @@ -27,9 +27,22 @@ export class RSessionManager implements vscode.Disposable { /// The last binpath that was used private _lastBinpath = ''; + /// Fires when a console session is activated as the foreground R console. + private readonly _onDidActivateConsoleSession = new vscode.EventEmitter(); + + /** + * An event that fires when a console session is activated as the foreground + * R console. Consumers can use this to sync state to the active console + * (e.g. the contributed terminal environment). Only console sessions are + * reported; notebook and background sessions never fire this event, and it + * only fires when the foreground console actually changes. + */ + readonly onDidActivateConsoleSession = this._onDidActivateConsoleSession.event; + /// Constructor; private since we only want one of these private constructor() { this._disposables.push( + this._onDidActivateConsoleSession, positron.runtime.onDidChangeForegroundSession(async sessionId => { await this.didChangeForegroundSession(sessionId); }) @@ -163,6 +176,12 @@ export class RSessionManager implements vscode.Disposable { }) ); await this.activateSession(session, reason); + + // Notify consumers that the foreground R console changed so they can + // sync state to it (e.g. the contributed terminal environment). This + // rides the console-activation path so it inherits the console-only + // and change-deduplication guards above. + this._onDidActivateConsoleSession.fire(session); } /**