Skip to content
Draft
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
28 changes: 8 additions & 20 deletions .github/actions/prepare-test-runtime/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,27 +37,15 @@ runs:
- name: Compile test files
run: pnpm run clean && pnpm exec tsc
shell: bash
- name: Install arf binary for Interactive tests
# mise.toml and mise.lock pin the arf version and its checksums.
- name: Install arf for Interactive tests
if: runner.os != 'Windows'
uses: jdx/mise-action@6612f0ef07ed2a736cd1e00a94a065522dd41195 # v5.2.5
- name: Expose arf for Interactive tests
if: runner.os != 'Windows'
shell: bash
env:
RUNNER_OS_NAME: ${{ runner.os }}
run: |
ARF_ARCH="$(uname -m)"
if [ "${ARF_ARCH}" = "arm64" ]; then ARF_ARCH="aarch64"; fi
if [ "${RUNNER_OS_NAME}" = "macOS" ]; then
ARF_TARGET="${ARF_ARCH}-apple-darwin"
else
ARF_TARGET="${ARF_ARCH}-unknown-linux-gnu"
fi
ARF_ARCHIVE="arf-console-${ARF_TARGET}.tar.xz"
ARF_RELEASE="https://github.com/eitsupi/arf/releases/download/v0.5.3"
curl -fsSL "${ARF_RELEASE}/${ARF_ARCHIVE}" -o "${RUNNER_TEMP}/${ARF_ARCHIVE}"
curl -fsSL "${ARF_RELEASE}/sha256.sum" -o "${RUNNER_TEMP}/arf.sha256"
(cd "${RUNNER_TEMP}" && awk -v name="${ARF_ARCHIVE}" '$2 == name || $2 == "*" name' arf.sha256 > arf-selected.sha256 && test -s arf-selected.sha256 && shasum -a 256 -c arf-selected.sha256)
mkdir -p "${RUNNER_TEMP}/arf"
tar -xJf "${RUNNER_TEMP}/${ARF_ARCHIVE}" -C "${RUNNER_TEMP}/arf"
ARF_EXECUTABLE=$(find "${RUNNER_TEMP}/arf" -type f -name arf | head -n 1)
test -n "${ARF_EXECUTABLE}"
echo "$(dirname "${ARF_EXECUTABLE}")" >> "$GITHUB_PATH"
ARF_EXECUTABLE="$(mise which arf)"
test -x "${ARF_EXECUTABLE}"
"${ARF_EXECUTABLE}" --version
echo "ARF_PATH=${ARF_EXECUTABLE}" >> "$GITHUB_ENV"
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Interactive session. Run the source/bootstrap checks with

The [architecture and backend contract](src/interactive/README.md) live beside the implementation. User setup and behavior belong in the [R Interactive wiki page](https://github.com/REditorSupport/vscode-R/wiki/R-Interactive).

Use Linux or macOS for Interactive runtime tests, with R and an installed arf 0.5.3 executable. Bundled sess is pure R and needs no compiler when its Imports are already installed; missing Imports are installed from the configured repository. Windows runs the remaining extension checks; persistent Interactive supervision remains limited to Linux/macOS. From the repository root, after installing pnpm dependencies, prepare a test library:
Use Linux or macOS for Interactive runtime tests, with R and arf 0.7.1 or later. `mise install` installs the arf version pinned in `mise.toml`, which CI also uses. Bundled sess is pure R and needs no compiler when its Imports are already installed; missing Imports are installed from the configured repository. Windows runs the remaining extension checks; persistent Interactive supervision remains limited to Linux/macOS. From the repository root, after installing pnpm dependencies, prepare a test library:

```sh
export R_LIBS=/path/to/test-library
Expand Down
25 changes: 25 additions & 0 deletions mise.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Tools for the Interactive runtime tests. mise.lock pins each platform's
# download and checksum; run `mise lock` after changing a version.
[tools]
# arf is not installed on Windows, where Interactive runtime tests do not run.
"github:eitsupi/arf" = { version = "0.7.1", os = ["linux", "macos"] }

[settings]
lockfile = true
21 changes: 21 additions & 0 deletions sess/R/interactive.R
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@
invisible(NULL)
}

# Under arf, tell the agent which arf execution runs this submission, so that
# it can interrupt that execution by ID. The event carries arf's execution ID.
# Detect the routines on each call: on Unix arf registers them only after
# startup, so an early negative result must not be cached.
.interactive_arf_started <- function(id) {
# arity-lint skip routine-registration: arf registers these routines in the embedding host, not in a package that sess could bind
if (
is.loaded("arf_api_version", PACKAGE = "(embedding)", type = ".Call") &&
identical(.Call("arf_api_version", PACKAGE = "(embedding)"), 1L)
) {
.Call(
"arf_publish_event",
"vscode-r.interactive.started",
list(id = id),
PACKAGE = "(embedding)"
)
}
invisible(NULL)
}

.interactive_write <- function(connection, text) {
remaining <- processx::conn_write(connection, charToRaw(enc2utf8(text)))
while (is.raw(remaining) && length(remaining)) {
Expand Down Expand Up @@ -333,6 +353,7 @@ interactive_execute <- function(id, code, source = NULL) {
# The agent can interrupt as soon as started arrives. Install the condition
# handlers before publishing it or doing any interruptible setup work.
.interactive_event("started")
.interactive_arf_started(id)
.interactive_plot_context()
trace_state$depth <- sys.nframe()
filename <- if (is.null(source$uri)) "<R Interactive>" else source$uri
Expand Down
21 changes: 17 additions & 4 deletions src/interactive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ The shipped `sess` backend uses arf with pure R sess helpers:
[SessBackend](backends/sessBackend.ts) owns readiness, metadata, capabilities, and
ordered output. [SessBridge](backends/sessBridge.ts) owns authenticated sess
JSON-RPC, inspection, rich events, and requests to the editor. [Arf](backends/arf.ts)
owns launch/adoption and dispatch; [process.ts](backends/process.ts) handles
signals, ownership, and confirmed process exit. Shared discovery/HTTP helpers
remain in [arf.ts](arf.ts). Plain R Interactive creation is unavailable; ordinary
owns launch/adoption, dispatch, interruption, and Stop; [process.ts](backends/process.ts)
handles ownership, signals, and confirmed process exit. Shared discovery/HTTP
helpers and the version check remain in [arf.ts](arf.ts), and the long-lived
connection in [arfAttach.ts](arfAttach.ts). Plain R Interactive creation is unavailable; ordinary
R terminals are unaffected, and existing agents can still be reconnected.

Managed sessions dispatch the private `sess:::interactive_execute` entry point
Expand All @@ -55,6 +56,17 @@ R sinks, frontend styling, and forked workers cannot rewrite those frames. A
server reply timeout after execution started does not cancel R; its events still
establish completion. Other transport failures remain ambiguous without retries.

The backend attaches to arf before submitting code. Before running a submission,
sess publishes an arf event carrying its ID, so the event's arf execution ID
identifies that submission; a managed `user_input` acknowledgement reports the
same ID. Interruption is an arf IPC `interrupt` for that execution ID, held until
the ID is known and dropped once sess reports completion. It is never a signal:
headless arf ends the whole session on SIGINT when R is idle, so a signal that
races with completion would end the session. sess reports the outcome; arf's
acceptance does not. Stop sends arf `shutdown` and waits for the process to exit,
killing it after a deadline. An interactive arf rejects `shutdown` as an unknown
method; only that answer makes Stop fall back to SIGTERM and then SIGKILL.

Adopted sessions use visible arf evaluation and return captured console output on
completion, while rich events use sess RPC. The backend waits for both completion
and the captured response. There is no subscription for arbitrary terminal console
Expand All @@ -64,7 +76,8 @@ sess hooks without changing frontend console callbacks or killing adopted R.
Both providers report notebook stdin/debugger support as unavailable: arf IPC
`user_input` submits code, rather than answering nested R prompts. Use ordinary
R terminals for `readline`, `scan`, and debugger interaction. arf IPC is
experimental; the integration is tested against arf 0.5.3. Persistent supervision
experimental; the integration requires arf 0.7.1 or later and is tested with
the version pinned in `mise.toml`. Persistent supervision
currently remains limited to Linux/macOS.

[SessGraphics](backends/sessGraphics.ts) owns JGD negotiation, its font-metrics worker, plot attribution/coalescing, SVG production, and resize transport. It emits display payloads with stable identities. The agent stores assets and journals references. Static graphics remains available without JGD; another backend can supply SVG/PNG directly.
Expand Down
89 changes: 83 additions & 6 deletions src/interactive/arf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,87 @@ export interface ArfSession {
session_type?: string;
}

// TODO: Require arf 0.7.2 once it is released. It reports an R error that
// follows a caught interrupt as an error rather than as interrupted, and bounds
// console reads that cannot be answered.
export const MIN_ARF_VERSION = '0.7.1';

/** arf JSON-RPC error codes that the Interactive client acts on. */
export const ARF_METHOD_NOT_FOUND = -32601;
export const ARF_TIMEOUT = -32007;

/** A JSON-RPC error response from arf, keeping its code and data. */
export class ArfRpcError extends Error {
constructor(
readonly code: number,
readonly rpcMessage: string,
readonly data: unknown,
raw: unknown,
) {
super(JSON.stringify(raw));
this.name = 'ArfRpcError';
}
}

/** The client stopped waiting for arf; the request may still be running. */
export class ArfTransportTimeoutError extends Error {
constructor() {
super('arf request timed out; evaluation may still be running');
this.name = 'ArfTransportTimeoutError';
}
}

export function rpcError(error: unknown): ArfRpcError {
const value =
error && typeof error === 'object' && !Array.isArray(error)
? (error as Record<string, unknown>)
: {};
return new ArfRpcError(
typeof value.code === 'number' ? value.code : NaN,
typeof value.message === 'string' ? value.message : '',
value.data,
error,
);
}

/** Compares release versions; prereleases count as older than their release. */
export function arfVersionAtLeast(version: unknown, minimum: string): boolean {
const parse = (text: string) => {
const match = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(text);
return match
? { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: !!match[4] }
: undefined;
};
const actual = typeof version === 'string' ? parse(version) : undefined;
const required = parse(minimum);
if (!actual || !required) {
return false;
}
for (let i = 0; i < 3; i++) {
if (actual.core[i] !== required.core[i]) {
return actual.core[i] > required.core[i];
}
}
return !actual.pre || required.pre;
}

/** Rejects sessions older than MIN_ARF_VERSION, or without the deadline used by inspection. */
export function checkArfSession(session: Record<string, unknown>): void {
if (!arfVersionAtLeast(session.arf_version, MIN_ARF_VERSION)) {
const actual =
typeof session.arf_version === 'string' ? session.arf_version : 'an unknown version';
throw new Error(
`R Interactive requires arf ${MIN_ARF_VERSION} or later, but the session runs ${actual}. Update arf and start a new session.`,
);
}
const capabilities = Array.isArray(session.capabilities) ? session.capabilities : [];
if (!capabilities.includes('call_execution_timeout')) {
throw new Error(
'This arf session does not support execution deadlines for handler calls. Update arf and start a new session.',
);
}
}

/** arf speaks HTTP JSON-RPC, not sess's JSON Lines framing. */
export function arfRequest(
endpoint: string,
Expand Down Expand Up @@ -52,7 +133,7 @@ export function arfRequest(
try {
const result = object(JSON.parse(Buffer.concat(chunks).toString('utf8')));
if (result.error) {
reject(new Error(JSON.stringify(result.error)));
reject(rpcError(result.error));
} else {
resolve(result.result);
}
Expand All @@ -63,11 +144,7 @@ export function arfRequest(
},
);
if (timeout > 0) {
request.setTimeout(timeout, () =>
request.destroy(
new Error('arf request timed out; evaluation may still be running'),
),
);
request.setTimeout(timeout, () => request.destroy(new ArfTransportTimeoutError()));
}
request.on('error', reject);
request.end(body);
Expand Down
Loading
Loading