diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31f6bc1a..4dc1bbd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: run: | npm run build:sidecar npm run stage:sidecar - - name: Real R smoke test + - name: Real R runtime and frontend submission tests run: npm run test:runtime - name: Package and verify VSIX run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index a1719494..a51808d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to R Console will be documented in this file. ## [Unreleased] +### Fixed + +- Improved the reliability of code submission in the R Console. + ### Security - Improved the security of R Console connections, including persistent sessions and language-server communication. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 25ed94e7..79fe0b2c 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -816,6 +816,33 @@ Creating a console follows this implementation path: 8. backend events update terminal state 9. the extension schedules persistence of the session record +Enter, accepted multiline paste, and programmatic sends reserve their submission +positions synchronously. Completeness checks run in reservation order, while +completed expressions can enter execution immediately. An incomplete tail passes +to the next reservation before that reservation is checked, including when the +tail is waiting behind an executing expression. With no accepted successor, the +tail becomes editable; normal input handling includes that buffer in the next +Enter or paste snapshot. It is never owned by both the queue and editor. +Enter retains literal whitespace and applies its continuation newline at the +saved cursor only when input is incomplete. Each complete expression is recorded +in history as it is accepted, so interrupting later parsing preserves earlier +entries. + +This ordering retains the existing TypeScript heuristic and optional native R +parse-status check. Native checks wait for a confirmed top-level ready state +before sending; their 150 ms timeout starts only after sending. A lazy per-console +notification wakes waiting checks on readiness or cancellation, without polling. +Each submission passes its own console's native parser explicitly; the language +parser holds no shared session callback. Connecting, interrupting, or closing one +console cannot change another console's parser or readiness wait. Runtime callbacks +and socket data from a detached or replaced session cannot update its successor. +Dispatch also requires readiness, including after attaching to an already busy R +session. Cancellation invalidates old reservations by generation; fresh +input does not wait for a cancelled parser. No speculative checks or collection +of previously checked prefixes is retained. Failed delivery restores pending +source using the same continuation boundaries, preserving existing newlines and +saved editing positions instead of adding separators unconditionally. + ### 4.5 Attaching A Detached Console Attaching a detached console follows this implementation path: diff --git a/package.json b/package.json index 51a83a31..35c60e0e 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,7 @@ "scripts": { "test": "node --test tests/unit/*.test.cjs", "test:extension-host": "vscode-test", - "test:runtime": "node --test tests/runtime.test.cjs", + "test:runtime": "node --test tests/runtime.test.cjs tests/submission-runtime.test.cjs", "test:package": "node --test tests/package.test.cjs", "vscode:prepublish": "npm run package:extension && npm run build:sidecar && npm run stage:sidecar", "build:sidecar": "node scripts/build-sidecar.js", diff --git a/src/Language/parser.ts b/src/Language/parser.ts index a5b9dd94..7e58bd39 100644 --- a/src/Language/parser.ts +++ b/src/Language/parser.ts @@ -1,10 +1,4 @@ -let nativeParseCallback: ((code: string) => Promise) | null = null; - -export function setNativeParseCallback( - callback: ((code: string) => Promise) | null -): void { - nativeParseCallback = callback; -} +export type NativeParseCallback = (code: string) => Promise; type LocalParseClassification = "complete" | "incomplete" | "unknown"; @@ -606,13 +600,14 @@ interface ParserState { bracketDepth: number; } -export async function isExpressionCompleteAsync(code: string): Promise { - const result = await getExpressionCompletenessAsync(code); +export async function isExpressionCompleteAsync(code: string, nativeParse?: NativeParseCallback): Promise { + const result = await getExpressionCompletenessAsync(code, nativeParse); return result.isComplete; } async function getExpressionCompletenessAsync( - code: string + code: string, + nativeParse?: NativeParseCallback ): Promise<{ isComplete: boolean; localClassification: LocalParseClassification }> { const sanitized = stripCommentLines(code); const trimmed = sanitized.trim(); @@ -625,9 +620,9 @@ async function getExpressionCompletenessAsync( return { isComplete: false, localClassification }; } - if (nativeParseCallback) { + if (nativeParse) { try { - const status = await nativeParseCallback(sanitized); + const status = await nativeParse(sanitized); return { isComplete: status !== 2, localClassification }; } catch { } diff --git a/src/Runtime/runtimeBackend.ts b/src/Runtime/runtimeBackend.ts index e18fc627..b76ff8a6 100644 --- a/src/Runtime/runtimeBackend.ts +++ b/src/Runtime/runtimeBackend.ts @@ -345,7 +345,8 @@ export class RustSidecarRuntimeBackend implements RuntimeBackend { return; } state.pendingParseRequests.delete(requestId); - settled = true; + // reject() owns the settled flag; setting it here would leave the + // promise pending forever and prevent the parser's local fallback. pending.reject(new Error("native parse-status request timed out")); }, PARSE_STATUS_TIMEOUT_MS); state.pendingParseRequests.set(requestId, { @@ -496,6 +497,9 @@ export class RustSidecarRuntimeBackend implements RuntimeBackend { state.socketCarry = Buffer.alloc(0); socket.on("data", (chunk: Buffer) => { + if (state.socket !== socket || state.attachGeneration !== generation) { + return; + } const parsed = parseBackendFrames(chunk, state.socketCarry); state.socketCarry = parsed.carry; diff --git a/src/Terminal/inputState.ts b/src/Terminal/inputState.ts index 0ee77fce..095db8b9 100644 --- a/src/Terminal/inputState.ts +++ b/src/Terminal/inputState.ts @@ -1,5 +1,6 @@ import { isExpressionCompleteAsync as parserIsExpressionCompleteAsync, + type NativeParseCallback, } from "../Language/parser"; import { moveCursorByRenderedRows, @@ -185,8 +186,8 @@ export class InputState { this.preferredColumn = null; } - async isExpressionCompleteAsync(code: string = this._text): Promise { - return parserIsExpressionCompleteAsync(code); + async isExpressionCompleteAsync(code: string = this._text, nativeParse?: NativeParseCallback): Promise { + return parserIsExpressionCompleteAsync(code, nativeParse); } private moveByRenderedRows( diff --git a/src/Terminal/rTerminal.ts b/src/Terminal/rTerminal.ts index 4a770011..c3483e25 100644 --- a/src/Terminal/rTerminal.ts +++ b/src/Terminal/rTerminal.ts @@ -3,7 +3,6 @@ import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from import { spawnSync } from "child_process"; import { CompletionPickItem } from "../Language/completion"; import { InlineCompletion, resolveInlineCompletionColors } from "./inlineCompletion"; -import { setNativeParseCallback } from "../Language/parser"; import { ANSI, stripBracketedPasteMarkers, @@ -44,6 +43,8 @@ import { createRuntimeBackend, closeRuntimeVscodeRIntegration, enqueueRuntimeSubmission, + reserveRuntimeSubmission, + wakeRuntimeParser, finishRuntimeSubmission, getCachedRuntimeWorkspaceData, getRuntimeTerminalName, @@ -221,6 +222,7 @@ export class RTerminal implements vscode.Pseudoterminal { private runtimeHostAdapter!: RuntimeHost; private programmaticSubmissionQueue: Promise = Promise.resolve(); + private submissionGeneration = 0; private suppressNextEnterAfterPasteEnd = false; private submissionQueue: Submission[] = []; @@ -531,6 +533,13 @@ export class RTerminal implements vscode.Pseudoterminal { set submissionQueue(value) { self.submissionQueue = value; }, + get submissionGeneration() { + return self.submissionGeneration; + }, + set submissionGeneration(value) { + self.submissionGeneration = value; + }, + cancelPendingSubmissions: () => self.cancelPendingSubmissions(), get historyBrowsing() { return self.historyBrowsing; }, @@ -950,70 +959,34 @@ export class RTerminal implements vscode.Pseudoterminal { } private queueProgrammaticSubmission(data: string): void { + // The reservation is synchronous; a later Enter or paste cannot overtake it. + const slot = reserveRuntimeSubmission(this.runtimeHost(), data); + const generation = this.submissionGeneration; this.programmaticSubmissionQueue = this.programmaticSubmissionQueue.then( - async () => this.handleQueuedProgrammaticSubmission(data), - async () => this.handleQueuedProgrammaticSubmission(data) + () => this.handleProgrammaticSubmission(data, slot, generation), + () => this.handleProgrammaticSubmission(data, slot, generation) ); } - private async handleQueuedProgrammaticSubmission(data: string): Promise { - if (this.mode === "closed") { - return; - } - - if (!this.canRunProgrammaticSubmission()) { - await new Promise((resolve) => { - setTimeout(resolve, this.getProgrammaticSubmissionFlushDelay()); - }); - await this.handleQueuedProgrammaticSubmission(data); - return; - } - - await this.handleProgrammaticSubmission(data); - } - - private canRunProgrammaticSubmission(): boolean { - return ( - this.mode === "ready" && - this.promptReady && - this.inputState.text.length === 0 && - this.inputState.isAtEnd - ); - } - - private async handleProgrammaticSubmission(data: string): Promise { + private async handleProgrammaticSubmission( + data: string, + slot?: Submission, + generation: number = this.submissionGeneration + ): Promise { + if (generation !== this.submissionGeneration || this.mode === "closed") return; const normalized = stripBracketedPasteMarkers(data) .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); - const trimmed = normalized.replace(/\n+$/, ""); - const submission = trimmed.trimEnd(); - if (!submission) { - return; - } + // Preserve an incomplete source tail, including its trailing continuation newline. + const submission = normalized; + // Even an empty reserved batch must release its parsing position. this.historyBrowsing = false; this.savedHistoryInput = undefined; this.historyCollapsed = true; this.escPendingClear = false; this.rHistory.resetIndex(); - - const blocks = await this.enqueueRSubmission(submission, false); - if (blocks.length > 0) { - this.recordSubmissionHistory(blocks); - return; - } - - const isComplete = await this.inputState.isExpressionCompleteAsync(submission); - if ( - !isComplete && - this.mode === "ready" && - this.promptReady && - this.inputState.text.length === 0 && - this.inputState.isAtEnd - ) { - this.inputState.insertText(trimmed); - this.renderInput(); - } + await this.enqueueRSubmission(submission, false, slot); } private handleNestedReplyPaste(content: string): boolean { @@ -1060,9 +1033,7 @@ export class RTerminal implements vscode.Pseudoterminal { return true; } - void this.enqueueRSubmission(line, true).then((blocks) => { - this.recordSubmissionHistory(blocks); - }); + void this.enqueueRSubmission(line, true); return true; } @@ -1396,6 +1367,7 @@ export class RTerminal implements vscode.Pseudoterminal { } return; case "ctrl_c": + this.cancelPendingSubmissions(); if (this.inputState.text.length > 0) { this.clearInputRender(); this.inputState.reset(); @@ -1560,46 +1532,31 @@ export class RTerminal implements vscode.Pseudoterminal { } private async handlePasteEnd(): Promise { - if (this.pasteBuffer.length === 0) { - return; - } + if (!this.pasteBuffer) return; const content = stripBracketedPasteMarkers(this.pasteBuffer); this.pasteBuffer = ""; const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - - const endsWithNewline = normalized.endsWith("\n"); - const containsNewline = normalized.includes("\n"); - - // Single-line paste must update the input buffer synchronously so a trailing - // Enter action in the same input chunk executes that line immediately. - if (!containsNewline && !endsWithNewline) { + if (!normalized.includes("\n")) { this.inputState.insertText(normalized); this.renderInput(); return; } - - const cursorAtEnd = this.inputState.text.length === 0 || this.inputState.isAtEnd; const trimmed = normalized.replace(/\n+$/, ""); - const fullText = this.inputState.text + trimmed; - - const isComplete = await this.inputState.isExpressionCompleteAsync(fullText); - - if ((endsWithNewline || containsNewline) && cursorAtEnd && isComplete) { - const submission = fullText.trimEnd(); - if (submission) { - this.inputState.reset(); - this.historyBrowsing = false; - this.savedHistoryInput = undefined; - this.historyCollapsed = true; - this.rHistory.resetIndex(); - const blocks = await this.enqueueRSubmission(submission, !/[\r\n]/.test(submission)); - this.recordSubmissionHistory(blocks); - } + if (!this.inputState.isAtEnd) { + this.inputState.insertText(trimmed); + this.renderInput(); return; } - this.inputState.insertText(trimmed); - this.renderInput(); + const submitted = this.inputState.text + trimmed; + const slot = reserveRuntimeSubmission(this.runtimeHost(), submitted); + this.inputState.reset(); + this.lang.updateInput(""); + if (this.promptVisible) { + this.clearInputRender(); + this.promptVisible = false; + } + await this.enqueueRSubmission(submitted, false, slot); } private handleReplyInputAction(action: KeyAction): void { @@ -1688,24 +1645,13 @@ export class RTerminal implements vscode.Pseudoterminal { return; } - const isComplete = await this.inputState.isExpressionCompleteAsync(); - if (!isComplete) { - if (this.inputState.cursorCol === 0) { - this.inputState.insertText("\n"); - } else { - const indent = this.inputState.calculateNewLineIndent(this.tabSize); - this.inputState.insertText("\n" + " ".repeat(indent)); - } - this.renderInput(); - return; - } - - if (!this.inputState.isAtEnd) { - this.inputState.cursorPosition = this.inputState.text.length; - } - const fullText = this.inputState.text; - if (!fullText.trim()) { + // Enter consumes only the current editable snapshot. Subsequent typing is independent. + const indent = this.inputState.cursorCol === 0 + ? "" : " ".repeat(this.inputState.calculateNewLineIndent(this.tabSize)); + // An empty Enter can still belong to an earlier incomplete reservation. + // Keep it in order until parsing decides whether it is a continuation. + if (!fullText.trim() && this.submissionQueue.length === 0) { this.inputState.reset(); if (this.promptVisible) { this.clearInputRender(); @@ -1726,11 +1672,15 @@ export class RTerminal implements vscode.Pseudoterminal { return; } - const sanitized = stripBracketedPasteMarkers(fullText).trimEnd(); + const submitted = stripBracketedPasteMarkers(fullText).replace(/\r\n?/g, "\n"); + const cursor = stripBracketedPasteMarkers(fullText.slice(0, this.inputState.cursorPosition)) + .replace(/\r\n?/g, "\n").length; + // Keep literal whitespace and the editing position. Apply Enter's newline + // only if the snapshot has an incomplete tail; complete code stays intact. + const slot = reserveRuntimeSubmission(this.runtimeHost(), submitted, { + cursor, text: "\n" + indent, + }); if (this.promptVisible) { - // Clear the live input viewport before any async submission work starts. - // Otherwise async completion context updates can race against the reset - // input state and leave stale viewport content behind. this.clearInputRender(); this.promptVisible = false; this.lastWriteEndedWithNewline = true; @@ -1742,16 +1692,7 @@ export class RTerminal implements vscode.Pseudoterminal { this.rHistory.resetIndex(); this.lang.updateInput(""); - if (!sanitized) { - this.showPrompt(); - return; - } - - const blocks = await this.enqueueRSubmission( - sanitized, - !/[\r\n]/.test(sanitized) - ); - this.recordSubmissionHistory(blocks); + await this.enqueueRSubmission(submitted, false, slot); } private reverseSearch(): void { @@ -2201,9 +2142,11 @@ export class RTerminal implements vscode.Pseudoterminal { private async enqueueRSubmission( code: string, - skipSplit: boolean = false + skipSplit: boolean = false, + slot?: Submission ): Promise { - return await enqueueRuntimeSubmission(this.runtimeHost(), code, skipSplit); + return await enqueueRuntimeSubmission(this.runtimeHost(), code, skipSplit, slot, + (block) => this.recordSubmissionHistory([block])); } private recordSubmissionHistory(blocks: string[]): void { @@ -2214,6 +2157,16 @@ export class RTerminal implements vscode.Pseudoterminal { } } + private cancelPendingSubmissions(): void { + this.submissionGeneration++; + this.submissionQueue = []; + this.programmaticSubmissionQueue = Promise.resolve(); + this.pendingProgrammaticInput = ""; + this.clearPendingInputFlushTimer(); + this.clearPendingConsoleInput(); + wakeRuntimeParser(this.runtimeHost()); + } + private startNextSubmission(): void { startNextRuntimeSubmission(this.runtimeHost()); } @@ -2924,6 +2877,7 @@ export class RTerminal implements vscode.Pseudoterminal { if (this.extensionResourcesDisposePromise) { return this.extensionResourcesDisposePromise; } + this.cancelPendingSubmissions(); this.rHistory.dispose(); disposeRuntimeVscodeRIntegrationUi(this.runtimeHost()); this.syntax.dispose(); @@ -2932,7 +2886,6 @@ export class RTerminal implements vscode.Pseudoterminal { this.clearPendingConsoleInput(); this.clearPromptRenderTimer(); this.clearReplyPromptRenderTimer(); - setNativeParseCallback(null); this.extensionResourcesDisposePromise = this.lang.dispose(); return this.extensionResourcesDisposePromise; } @@ -2982,6 +2935,7 @@ export class RTerminal implements vscode.Pseudoterminal { } runtimeBackend.detach(runtimeSession); + this.cancelPendingSubmissions(); this.rProcess = null; this.backendChildPid = undefined; this.sessionHostConnected = false; diff --git a/src/Terminal/rTerminal/runtime.ts b/src/Terminal/rTerminal/runtime.ts index 911aec3d..65fdd11c 100644 --- a/src/Terminal/rTerminal/runtime.ts +++ b/src/Terminal/rTerminal/runtime.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import * as fs from "fs"; import * as path from "path"; -import { setNativeParseCallback, stripCommentLines } from "../../Language/parser"; +import { stripCommentLines } from "../../Language/parser"; import { type BackendControlEvent, type BackendDialogRequest, @@ -58,11 +58,59 @@ type Dimensions = { export type TerminalMode = "starting" | "ready" | "executing" | "reply" | "closed"; export type Submission = { + kind: "execute" | "parse" | "edit"; code: string; initialPromptKind: "main" | "cont"; + continuationEdit?: { cursor: number; text: string }; + editCursor?: number; + parsing?: { + generation: number; + sourceOffset?: number; + previous?: Promise; + done?: Promise; + finish?(): void; + }; }; const pendingRuntimeRewrites = new WeakMap(); +const latestParsing = new WeakMap>(); +const pendingParseReadiness = new WeakMap; resolve(): void }>(); + +function runtimeIsReady(host: RuntimeHost): boolean { + return host.mode === "ready" && host.promptReady && host.activeSubmission === null; +} + +// One lazy waiter per console, shared by readiness and cancellation. No polling +// and no native request (or native timeout) until R can service the check. +export function wakeRuntimeParser(host: RuntimeHost): void { + const waiting = pendingParseReadiness.get(host); + pendingParseReadiness.delete(host); + waiting?.resolve(); +} + +export async function requestRuntimeParseStatus(host: RuntimeHost, code: string): Promise { + const generation = host.submissionGeneration; + const process = host.rProcess; + const backend = host.runtimeBackend; + for (;;) { + if (generation !== host.submissionGeneration || process !== host.rProcess || + backend !== host.runtimeBackend || host.mode === "closed") { + throw new Error("R parse check cancelled"); + } + if (runtimeIsReady(host)) break; + let waiting = pendingParseReadiness.get(host); + if (!waiting) { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + waiting = { promise, resolve }; + pendingParseReadiness.set(host, waiting); + } + await waiting.promise; + } + const result = backend?.requestParseStatus(process, code); + if (!result) throw new Error("R native parser unavailable"); + return await result; +} export type RuntimeHost = { options: RTerminalOptions; @@ -85,6 +133,8 @@ export type RuntimeHost = { sessionHostConnected: boolean; activeSubmission: Submission | null; submissionQueue: Submission[]; + submissionGeneration: number; + cancelPendingSubmissions(): void; historyBrowsing: boolean; historyCollapsed: boolean; inputState: InputState; @@ -224,7 +274,7 @@ export async function startRuntime(host: RuntimeHost): Promise { host.lastWriteEndedWithNewline = true; host.hasReceivedOutput = false; host.inputState.reset(); - host.submissionQueue = []; + host.cancelPendingSubmissions(); host.activeSubmission = null; host.backendChildPid = undefined; host.sessionHostConnected = false; @@ -263,7 +313,6 @@ export async function startRuntime(host: RuntimeHost): Promise { }); integration.afterRuntimeStarted(); primeRuntimeAttach(host); - setNativeParseCallback(null); attachRuntimeSession(host, true); updateRuntimeTerminalName(host); @@ -288,32 +337,35 @@ export function attachRuntimeSession(host: RuntimeHost, showStartupErrors: boole return; } vscodeRIntegration(host).attachRuntime(); - setNativeParseCallback(null); - host.runtimeBackend.attach(host.rProcess, { + const backend = host.runtimeBackend; + const session = host.rProcess; + const isCurrent = (): boolean => host.runtimeBackend === backend && host.rProcess === session && host.mode !== "closed"; + backend.attach(session, { onStdout: (output) => { + if (!isCurrent()) return; handleRuntimeOutput(host, output); }, onStderr: (errorText) => { + if (!isCurrent()) return; handleRuntimeError(host, errorText); }, onControl: (event) => { + if (!isCurrent()) return; handleRuntimeControl(host, event); }, onExit: (code) => { + if (!isCurrent()) return; handleRuntimeExit(host, code); }, onError: (err) => { - setNativeParseCallback(null); + if (!isCurrent()) return; if (showStartupErrors) { host.writeEmitter.fire( `${ANSI.red}Failed to start R: ${err.message}${ANSI.reset}\r\n` ); } - const failedRuntime = host.rProcess; - if (failedRuntime && host.runtimeBackend) { - host.runtimeBackend.detach(failedRuntime); - host.runtimeBackend.close(failedRuntime); - } + backend.detach(session); + backend.close(session); handleRuntimeExit(host, 1); }, }); @@ -343,7 +395,6 @@ export function handleRuntimeControl( case "host-connected": host.sessionHostConnected = true; vscodeRIntegration(host).handleHostConnected(); - updateNativeParseCallback(host); return; case "prompt": handleBackendPrompt(host, event.kind); @@ -353,6 +404,7 @@ export function handleRuntimeControl( return; case "busy": if (event.value) { + host.promptReady = false; if (host.awaitingExecutionStart && host.activeSubmission) { host.awaitingExecutionStart = false; } @@ -420,7 +472,6 @@ function applyRuntimeSessionState( } host.sessionHostConnected = true; - updateNativeParseCallback(host); if (event.busy || event.wait.kind === "none") { host.clearPromptRenderTimer(); @@ -451,6 +502,8 @@ function applyRuntimeSessionState( if (event.wait.prompt === "main") { vscodeRIntegration(host).handleMainPrompt(); } + wakeRuntimeParser(host); + host.startNextSubmission(); return; case "nested": host.promptReady = false; @@ -474,6 +527,9 @@ function restoreReadyStateAfterExecution(host: RuntimeHost): void { return; } + // busy(false) is not an input-ready event, including on a busy reconnect. + if (!host.promptReady) return; + host.mode = "ready"; if (!host.promptReady || host.promptVisible) { @@ -518,6 +574,8 @@ export function handleBackendPrompt( host.mode = "ready"; } + wakeRuntimeParser(host); + if (host.submissionPending) { host.pendingPromptToken = false; if (kind === "main" && host.mode === "ready" && host.activeSubmission === null) { @@ -1057,16 +1115,36 @@ export function startRuntimeSubmission(host: RuntimeHost, task: Submission): voi } return; } + const sent = host.runtimeBackend?.sendSessionCommand(host.rProcess, { + type: "submit", + code: task.code, + }) ?? false; + if (!sent) { + const restored = restoreSubmissionSource([task, ...host.submissionQueue], host.inputState.text, + host.inputState.cursorPosition); + host.cancelPendingSubmissions(); + host.activeSubmission = null; + host.submissionPending = false; + host.awaitingExecutionStart = false; + host.mode = "ready"; + host.inputState.text = restored.code; + host.inputState.cursorPosition = restored.cursor; + if (host.promptVisible) { + host.clearInputRender(); + host.promptVisible = false; + } + host.pendingPromptToken = true; + handleRuntimeError(host, "R submission was not sent. Unsent code restored for editing.\n"); + host.schedulePrompt(); + return; + } host.submissionPending = false; host.pendingPromptToken = false; host.awaitingExecutionStart = true; host.mode = "executing"; writeRuntimeSubmissionEcho(host, task); + host.promptReady = false; host.clearPromptRenderTimer(); - host.runtimeBackend?.sendSessionCommand(host.rProcess, { - type: "submit", - code: task.code, - }); } export function finishRuntimeSubmission(host: RuntimeHost): void { @@ -1077,44 +1155,199 @@ export function finishRuntimeSubmission(host: RuntimeHost): void { } export function startNextRuntimeSubmission(host: RuntimeHost): void { - if (host.mode !== "ready" && !(host.mode === "executing" && host.activeSubmission === null)) { - return; - } - if (host.activeSubmission) { - return; - } - const task = host.submissionQueue.shift(); - if (!task) { + if (!runtimeIsReady(host)) return; + const next = host.submissionQueue[0]; + if (!next || next.kind === "parse") return; + host.submissionQueue.shift(); + if (next.kind === "edit") { + const draft = host.inputState.text; + const cursor = host.inputState.cursorPosition; + const prefix = next.editCursor === undefined + ? next.code + (draft && !/\n[ \t]*$/.test(next.code) ? "\n" : "") + : next.code.slice(0, next.editCursor); + const suffix = next.editCursor === undefined ? "" : next.code.slice(next.editCursor); + host.inputState.text = prefix + draft + suffix; + host.inputState.cursorPosition = prefix.length + cursor; + host.mode = "ready"; + host.renderInput(); return; } - host.activeSubmission = task; - startRuntimeSubmission(host, task); + host.activeSubmission = next; + startRuntimeSubmission(host, next); } -export async function enqueueRuntimeSubmission( +/** Reserve the accepted batch before asynchronous parsing begins. */ +export function reserveRuntimeSubmission( host: RuntimeHost, code: string, - skipSplit: boolean = false -) : Promise { - const blocks = skipSplit - ? [normalizeSubmissionBlock(code)] - : await splitSubmissionBlocks(host, code); + continuationEdit?: Submission["continuationEdit"] +): Submission { + const slot: Submission = { kind: "parse", code, initialPromptKind: host.promptKind, continuationEdit }; + const tail = host.submissionQueue.at(-1); + if (tail?.kind === "edit") { + // Parsing may have finished while an earlier expression is still running. + // The queued tail still belongs to the next accepted input. + host.submissionQueue.pop(); + continueSubmission(slot, tail.code, tail.initialPromptKind, tail.editCursor); + } + const previous = latestParsing.get(host); + let previousDone: Promise | undefined; + if (previous && previous.generation === host.submissionGeneration) { + // Allocate a completion promise only when another reservation needs to wait. + previous.done ??= new Promise((resolve) => { previous.finish = resolve; }); + previousDone = previous.done; + } + slot.parsing = { + generation: host.submissionGeneration, + previous: previousDone, + }; + latestParsing.set(host, slot.parsing); + host.submissionQueue.push(slot); + return slot; +} - if (blocks.length === 0) { - host.submissionPending = false; - host.awaitingExecutionStart = false; - return []; - } +function continueSubmission( + slot: Submission, + code: string, + promptKind: Submission["initialPromptKind"], + cursor?: number +): void { + // Input accepted while Enter is being checked belongs at the saved editing + // position, just as typing after restoration would. Editor tails without an + // Enter edit continue on the next line. + const prefix = cursor === undefined + ? code + (/\n[ \t]*$/.test(code) ? "" : "\n") + : code.slice(0, cursor); + const suffix = cursor === undefined ? "" : code.slice(cursor); + if (cursor !== undefined) slot.editCursor = prefix.length + slot.code.length; + else if (slot.editCursor !== undefined) slot.editCursor += prefix.length; + slot.code = prefix + slot.code + suffix; + if (slot.continuationEdit) slot.continuationEdit.cursor += prefix.length; + slot.initialPromptKind = promptKind; +} + +function restoreSubmissionSource(submissions: Submission[], draft: string, draftCursor: number): { code: string; cursor: number } { + let restored: Submission | undefined; + for (const submission of submissions) { + const code = submission.code || submission.continuationEdit?.text || ""; + if (!code) continue; + const editCursor = submission.editCursor === undefined ? undefined + : Math.max(0, Math.min(code.length, submission.editCursor - (submission.parsing?.sourceOffset ?? 0))); + const next = { ...submission, code: code.replace(/\r\n?/g, "\n"), editCursor, continuationEdit: undefined }; + if (restored) continueSubmission(next, restored.code, restored.initialPromptKind, restored.editCursor); + restored = next; + } + if (!restored) return { code: draft, cursor: draftCursor }; + const cursor = restored.editCursor ?? restored.code.length; + const prefix = restored.code.slice(0, cursor); + const suffix = restored.code.slice(cursor); + const separator = draft && restored.editCursor === undefined && !/\n[ \t]*$/.test(prefix) ? "\n" : ""; + return { code: prefix + separator + draft + suffix, cursor: cursor + separator.length + draftCursor }; +} - for (const block of blocks) { - host.submissionQueue.push({ - code: block, - initialPromptKind: host.promptKind, +export async function enqueueRuntimeSubmission( + host: RuntimeHost, + code: string, + skipSplit: boolean = false, + slot: Submission = reserveRuntimeSubmission(host, code), + onAcceptedBlock?: (block: string) => void +): Promise { + const generation = host.submissionGeneration; + const blocks: string[] = []; + const publish = (block: string, kind: Submission["kind"], editCursor?: number): void => { + const index = host.submissionQueue.indexOf(slot); + if (index < 0 || generation !== host.submissionGeneration) return; + host.submissionQueue.splice(index, 0, { + kind, code: block, initialPromptKind: slot.initialPromptKind, editCursor, }); - } + if (kind === "execute") blocks.push(block); + startNextRuntimeSubmission(host); + // Record each accepted expression now; cancelling later parsing must not + // erase earlier history. An immediate delivery failure invalidates this + // generation and leaves the unsent expression available for retry instead. + if (kind === "execute" && generation === host.submissionGeneration) onAcceptedBlock?.(block); + }; + try { + if (slot.parsing?.previous) { + await slot.parsing.previous; + slot.parsing.previous = undefined; + } + if (generation !== host.submissionGeneration || !host.submissionQueue.includes(slot)) return blocks; + // Parser ownership follows the submission, never the last console to connect. + const nativeParse = host.sessionHostConnected && host.runtimeBackend?.hasCapability(host.rProcess, "parse-status") + ? (source: string) => requestRuntimeParseStatus(host, source) + : undefined; + const hasContinuation = code !== slot.code; + code = slot.code; + if (skipSplit && !hasContinuation) { + slot.code = ""; + const block = normalizeSubmissionBlock(code); + if (block.trim()) publish(block, "execute"); + } else { + const normalized = code.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const lines = normalized.split("\n"); + let pending = ""; + let pendingStart = 0; + let offset = 0; + for (const line of lines) { + if (!pending) pendingStart = offset; + offset += line.length + 1; + pending = pending ? pending + "\n" + line : line; + const block = normalizeSubmissionBlock(pending); + if (!block.trim()) continue; + const complete = await host.inputState.isExpressionCompleteAsync(block, nativeParse); + if (generation !== host.submissionGeneration || !host.submissionQueue.includes(slot)) return blocks; + if (complete) { + // On delivery failure, the placeholder contains only the not-yet-published tail. + slot.code = normalized.slice(offset); + if (slot.parsing) slot.parsing.sourceOffset = offset; + if (!slot.code) slot.continuationEdit = undefined; + publish(block, "execute"); + pending = ""; + } + } + if (normalizeSubmissionBlock(pending).trim()) { + let editCursor = slot.editCursor === undefined ? undefined + : Math.max(0, Math.min(pending.length, slot.editCursor - pendingStart)); + if (slot.continuationEdit) { + const { cursor, text } = slot.continuationEdit; + const position = Math.max(0, Math.min(pending.length, cursor - pendingStart)); + pending = pending.slice(0, position) + text + pending.slice(position); + editCursor = position + text.length; + } + slot.code = ""; + const next = host.submissionQueue[host.submissionQueue.indexOf(slot) + 1]; + if (next?.kind === "parse") { + // Transfer ownership before releasing the next parser. Never restore + // this prefix into the editor as well as passing it to queued input. + continueSubmission(next, pending, slot.initialPromptKind, editCursor); + } else { + // With no accepted successor, the editable buffer owns continuation. + // Subsequent Enter/paste snapshots already include that buffer. + publish(pending, "edit", editCursor); + } + } + } + } finally { + const index = host.submissionQueue.indexOf(slot); + if (index >= 0) { + host.submissionQueue.splice(index, 1); + startNextRuntimeSubmission(host); + // A comment-only batch produces no R prompt event. Restore the local + // prompt once all accepted work is idle, without interrupting execution + // or redrawing an already visible prompt. + if (generation === host.submissionGeneration && host.mode === "ready" && + !host.activeSubmission && host.submissionQueue.length === 0 && !host.promptVisible) { + host.pendingPromptToken = true; + host.schedulePrompt(); + } + } + slot.parsing?.finish?.(); + if (latestParsing.get(host) === slot.parsing) latestParsing.delete(host); + slot.parsing = undefined; + } void host.lang.refreshCompletionContextDocument(host.inputState.text); - startNextRuntimeSubmission(host); return blocks; } @@ -1124,37 +1357,6 @@ function normalizeSubmissionBlock(code: string): string { .trimEnd(); } -async function splitSubmissionBlocks(host: RuntimeHost, code: string): Promise { - const normalized = code.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - const lines = normalized.split("\n"); - const blocks: string[] = []; - let currentBlock = ""; - - for (const line of lines) { - currentBlock = currentBlock.length > 0 ? `${currentBlock}\n${line}` : line; - const normalizedBlock = normalizeSubmissionBlock(currentBlock); - - if (!normalizedBlock.trim()) { - continue; - } - - const isComplete = await host.inputState.isExpressionCompleteAsync(normalizedBlock); - if (!isComplete) { - continue; - } - - blocks.push(normalizedBlock); - currentBlock = ""; - } - - const trailingBlock = normalizeSubmissionBlock(currentBlock); - if (trailingBlock.trim() && (await host.inputState.isExpressionCompleteAsync(trailingBlock))) { - blocks.push(trailingBlock); - } - - return blocks; -} - function writeRuntimeSubmissionEcho(host: RuntimeHost, task: Submission): void { if ((host.mode !== "ready" && host.mode !== "executing") || !host.promptReady) { return; @@ -1245,6 +1447,7 @@ export function interruptRuntime(host: RuntimeHost): void { host.promptVisible = false; host.pendingPromptToken = false; host.clearPendingConsoleInput(); + host.cancelPendingSubmissions(); return; } @@ -1261,6 +1464,7 @@ export function interruptRuntime(host: RuntimeHost): void { host.replyPromptText = ""; host.mode = "executing"; host.clearPendingConsoleInput(); + host.cancelPendingSubmissions(); return; } @@ -1269,6 +1473,7 @@ export function interruptRuntime(host: RuntimeHost): void { host.inputState.reset(); host.renderInput(); host.clearPendingConsoleInput(); + host.cancelPendingSubmissions(); return; } @@ -1280,6 +1485,7 @@ export function interruptRuntime(host: RuntimeHost): void { host.inputState.reset(); host.promptVisible = false; host.clearPendingConsoleInput(); + host.cancelPendingSubmissions(); } export function handleRuntimeExit(host: RuntimeHost, code: number): void { @@ -1289,7 +1495,6 @@ export function handleRuntimeExit(host: RuntimeHost, code: number): void { host.clearReplyPromptRenderTimer(); host.clearPendingConsoleInput(); flushPendingRuntimeRewrite(host); - setNativeParseCallback(null); host.lang.clearSessionState(); host.rProcess = null; @@ -1302,7 +1507,7 @@ export function handleRuntimeExit(host: RuntimeHost, code: number): void { host.awaitingExecutionStart = false; host.submissionPending = false; - host.submissionQueue = []; + host.cancelPendingSubmissions(); host.activeSubmission = null; host.writeEmitter.fire( @@ -1310,23 +1515,3 @@ export function handleRuntimeExit(host: RuntimeHost, code: number): void { ); host.closeEmitter.fire(code); } - -function updateNativeParseCallback(host: RuntimeHost): void { - if ( - !host.runtimeBackend || - !host.rProcess || - !host.sessionHostConnected || - !host.runtimeBackend.hasCapability(host.rProcess, "parse-status") - ) { - setNativeParseCallback(null); - return; - } - - setNativeParseCallback(async (code: string) => { - const request = host.runtimeBackend?.requestParseStatus(host.rProcess, code); - if (!request) { - return 1; - } - return await request; - }); -} diff --git a/tests/helpers/submission-terminal.cjs b/tests/helpers/submission-terminal.cjs new file mode 100644 index 00000000..94e2e69a --- /dev/null +++ b/tests/helpers/submission-terminal.cjs @@ -0,0 +1,95 @@ +const loadSource = require("./load-source.cjs"); +const parser = loadSource("src/Language/parser.ts"); +const runtime = loadSource("src/Terminal/rTerminal/runtime.ts", { vscode: {}, "../../Language/parser": parser }); +const { RTerminal } = loadSource("src/Terminal/rTerminal.ts", { + "./rTerminal/lang": {}, + "./rTerminal/runtime": runtime, + vscode: { + EventEmitter: class {}, window: {}, + workspace: { getConfiguration: () => ({ get: (_key, fallback) => fallback }) }, + }, +}); +const { InputState } = loadSource("src/Terminal/inputState.ts", { "../Language/parser": parser }); +const { Renderer } = loadSource("src/Terminal/renderer.ts"); +const { KeyProcessor } = loadSource("src/Terminal/keyProcessor.ts"); + +function deferred() { + let resolve; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve: value => resolve(value) }; +} + +function createTerminal() { + const commands = []; + const history = []; + const output = []; + const terminal = Object.assign(Object.create(RTerminal.prototype), { + options: { vscodeR: { kind: "disabled" } }, + mode: "ready", promptReady: true, promptKind: "main", promptVisible: false, + autoMatch: false, tabSize: 2, + submissionGeneration: 0, submissionQueue: [], activeSubmission: null, + programmaticSubmissionQueue: Promise.resolve(), pendingProgrammaticInput: "", + pendingConsoleInputLines: [], pasteBuffer: "", inBracketPaste: false, + submissionPending: false, awaitingExecutionStart: false, + rProcess: {}, sessionHostConnected: true, + nameEmitter: { fire() {} }, closeEmitter: { fire() {} }, notifyDisplayPidChanged() {}, + inputState: new InputState(), keyProcessor: new KeyProcessor(), + dimensions: { columns: 80, rows: 24 }, lastWriteEndedWithNewline: true, + lang: { refreshCompletionContextDocument: async () => {}, updateInput() {}, clearSessionState() {} }, + rHistory: { resetIndex() {}, push(code) { history.push(code); } }, + renderer: new Renderer(() => {}), + syntax: { setSource() {}, highlightLines(lines) { return lines; } }, + runtimeBackend: { + isAlive: () => true, + hasCapability: () => false, + canUseSessionCommands: () => true, + sendSessionCommand(session, command) { commands.push(command); return true; }, + }, + writeEmitter: { fire(text) { output.push(text); } }, + ensureReadyPromptVisibleForInput() {}, + captureVisibleInputForReplay() {}, + renderInput() { this.promptVisible = true; }, + clearInputRender() { this.promptVisible = false; }, + clearPromptRenderTimer() {}, clearReplyPromptRenderTimer() {}, + schedulePrompt() {}, scheduleReplyPrompt() {}, + getProgrammaticSubmissionFlushDelay() { return 1; }, + }); + terminal.runtimeHostAdapter = terminal.createRuntimeHost(); + return { terminal, commands, history, output }; +} +function nextPrompt(terminal) { + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: true }); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); +} + +function createTerminalWithScreen(t) { + const { Terminal } = require("@xterm/headless"); + const { InlineCompletion } = loadSource("src/Terminal/inlineCompletion.ts"); + const fixture = createTerminal(); + const { terminal, output } = fixture; + const screen = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + terminal.inlineCompletion = new InlineCompletion(screen._core.unicodeService); + terminal.inlineSuggestionRowCount = 0; + terminal.lang.cancelCompletion = () => {}; + terminal.pendingPromptToken = false; + terminal.writeEmitter.fire = text => { + output.push(text); + screen._core._writeBuffer.writeSync(text); + }; + terminal.renderer = new Renderer(text => terminal.writeEmitter.fire(text)); + // Exercise the actual prompt, clear, and rendering paths, including ANSI output. + for (const key of ["ensureReadyPromptVisibleForInput", "renderInput", "clearInputRender", + "schedulePrompt", "clearPromptRenderTimer"]) delete terminal[key]; + terminal.getPromptRenderDelay = () => 0; + terminal.showPrompt(); + t.after(() => { + terminal.clearPromptRenderTimer(); + terminal.dismissInlineCompletion(false); + screen.dispose(); + }); + const screenLines = () => Array.from({ length: screen.buffer.active.length }, + (_, index) => screen.buffer.active.getLine(index).translateToString(true)).filter(Boolean); + return { ...fixture, screenLines }; +} + +module.exports = { createTerminal, createTerminalWithScreen, nextPrompt, deferred, runtime, parser }; diff --git a/tests/submission-runtime.test.cjs b/tests/submission-runtime.test.cjs new file mode 100644 index 00000000..c8114620 --- /dev/null +++ b/tests/submission-runtime.test.cjs @@ -0,0 +1,407 @@ +const assert = require("node:assert/strict"); +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { setTimeout: delay } = require("node:timers/promises"); +const { test } = require("node:test"); +const loadSource = require("./helpers/load-source.cjs"); +const { createTerminal, runtime } = require("./helpers/submission-terminal.cjs"); +const { RustSidecarRuntimeBackend, getBundledRustSidecarPath } = loadSource("src/Runtime/runtimeBackend.ts"); + +test("frontend continuations execute in order with real R parsing and evaluation", { timeout: 60000 }, async t => { + const root = path.resolve(__dirname, ".."); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "r-console-continuations-")); + const rHome = execFileSync(process.env.R_TEST_EXECUTABLE || "R", ["RHOME"], { encoding: "utf8" }).trim(); + const rPath = path.join(rHome, "bin", process.platform === "win32" ? "R.exe" : "R"); + const settings = { + executablePath: rPath, sessionWatcher: false, + consoleArgs: ["--quiet", "--no-save", "--no-restore", "--no-site-file", "--no-environ"], + }; + const { resolveRTerminalOptions } = loadSource("src/Terminal/options.ts", { + vscode: { + extensions: { getExtension: () => undefined }, + workspace: { + getConfiguration: () => ({ get: key => settings[key] }), + workspaceFolders: [{ uri: { fsPath: cwd } }], + }, + window: { showErrorMessage: message => { throw new Error(message); } }, + }, + }); + const options = resolveRTerminalOptions(); + assert.ok(options); + const binary = getBundledRustSidecarPath(root); + assert.ok(fs.existsSync(binary), "Build and stage the sidecar before running test:runtime"); + let backend = new RustSidecarRuntimeBackend(binary); + let { terminal, history } = createTerminal(); + const sent = [], checked = [], nativeResults = [], nativeFailures = [], sessionStates = []; + let output = "", failure, ready = false; + const instrument = () => { + const send = backend.sendSessionCommand.bind(backend); + backend.sendSessionCommand = (session, command) => { + sent.push(command); + return send(session, command); + }; + const request = backend.requestParseStatus.bind(backend); + backend.requestParseStatus = (session, code) => { + if (terminal.mode !== "ready" || !terminal.promptReady || terminal.activeSubmission !== null) { + failure = new Error("native parse request sent before R was ready"); + throw failure; + } + checked.push(code); + const result = request(session, code); + assert.ok(result, "real native parser must be available"); + return result.then(status => { nativeResults.push({ code, status }); return status; }, error => { + nativeFailures.push({ code, message: error.message }); + throw error; + }); + }; + }; + instrument(); + let session = backend.start([rPath, ...options.rArgs], { + cwd, + env: { ...options.env, R_PROFILE_USER: path.join(root, "resources/r/console-profile.R") }, + }); + terminal.runtimeBackend = backend; + terminal.rProcess = session; + t.after(async () => { + terminal.cancelPendingSubmissions(); + const pid = backend.getPid(session); + backend.close(session); + backend.detach(session); + await delay(100); + if (pid) { try { process.kill(pid, "SIGKILL"); } catch {} } + fs.rmSync(cwd, { recursive: true, force: true }); + fs.rmSync(path.join(os.tmpdir(), `r-console-session-${session.sessionId}.json`), { force: true }); + }); + const waitFor = async (predicate, label) => { + const deadline = Date.now() + 10000; + while (!predicate()) { + if (failure) throw failure; + assert.ok(Date.now() < deadline, + `${label}: mode=${terminal.mode}, draft=${JSON.stringify(terminal.inputState.text)}, queue=${JSON.stringify(terminal.submissionQueue)}, sent=${JSON.stringify(sent)}, checked=${JSON.stringify(checked)}\n${output}`); + await delay(10, undefined, { signal: t.signal }); + } + }; + const idle = () => terminal.mode === "ready" && !terminal.activeSubmission && terminal.submissionQueue.length === 0; + const receiveOutput = text => { + output += text; + // Match handleRuntimeOutput's execution acknowledgement; rendering is stubbed. + if (terminal.activeSubmission) terminal.awaitingExecutionStart = false; + }; + const handlers = { + onStdout: receiveOutput, onStderr: receiveOutput, + onError: error => { failure = error; }, + onControl: event => { + if (event.type === "prompt") { + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, event.kind); + ready = true; + } else if (["busy", "input-request", "input-end"].includes(event.type)) { + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, event); + } else if (event.type === "session-state") { + sessionStates.push(event); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, event); + if (event.wait.kind === "top-level") ready = true; + } + }, + }; + backend.attach(session, handlers); + await waitFor(() => ready && backend.canUseSessionCommands(session), "startup"); + + for (const overlap of [true, false]) { + terminal.inputState.insertText("if"); + const first = terminal.handleEnterKey(); + if (!overlap) await first; + const marker = `IF_${overlap}_OK`; + terminal.inputState.insertText(`(TRUE) cat("${marker}\\n")`); + await Promise.all([first, terminal.handleEnterKey()]); + await waitFor(() => idle() && output.includes(marker), marker); + assert.equal(terminal.inputState.text, ""); + } + assert.ok(nativeResults.some(result => result.code === "if" && result.status === 2), + "native R, without an injected delay, identifies the incomplete predecessor"); + + for (const input of ["Enter", "editor", "paste"]) { + const marker = `READY_${input}_OK`; + const code = `Sys.sleep(0.1)\ncat("${marker}\\n")`; + if (input === "Enter") { + terminal.inputState.insertText(code); + await terminal.handleEnterKey(); + } else if (input === "editor") { + terminal.handleInput(code + "\n"); + await terminal.programmaticSubmissionQueue; + } else { + terminal.handleInput(`\x1b[200~${code}\n\x1b[201~`); + } + await waitFor(() => idle() && output.includes(marker), marker); + } + + terminal.handleInput('reply <- readline("READY_REPLY>")\ncat("REPLY_", reply, "_OK\\n", sep = "")\n'); + const replyBatch = terminal.programmaticSubmissionQueue; + await waitFor(() => terminal.mode === "reply", "nested reply before next native check"); + terminal.handleInput("ANSWER\n"); + await replyBatch; + await waitFor(() => idle() && output.includes("REPLY_ANSWER_OK"), "native check after nested reply"); + + for (const timing of ["overlap", "parsed", "restored"]) { + const start = sent.length; + terminal.handleInput("Sys.sleep(0.2)\nx <- (\n"); + if (timing !== "overlap") await terminal.programmaticSubmissionQueue; + if (timing === "restored") { + await waitFor(() => idle() && terminal.inputState.text === "x <- (\n", "restore continuation"); + } + terminal.handleInput("2)\n"); + await waitFor(() => idle() && sent.length === start + 2, `complete ${timing} continuation`); + assert.deepEqual(sent.slice(start).map(c => c.code), ["Sys.sleep(0.2)", "x <- (\n2)"]); + assert.equal(terminal.inputState.text, ""); + const marker = `CONTINUATION_${timing}_OK`; + terminal.handleInput(`stopifnot(identical(x, 2)); cat("${marker}\\n")\n`); + await waitFor(() => idle() && output.includes(marker), marker); + } + + for (const code of [ + 'cat("BATCH_FIRST\\n")\nscore <- function(x) {\n if (x > 0) {\n', + ' x * 2\n } else {\n', + ' -x\n }\n}\nscore(3)\nanswer <- (\n', + ' score(-4) +\n 1\n)\nstopifnot(score(3) == 6, answer == 5); cat("BATCH_LAST\\n")\n', + ]) terminal.handleInput(code); + await waitFor(() => idle() && output.includes("BATCH_LAST"), "nested batches"); + assert.ok(output.indexOf("BATCH_FIRST") < output.indexOf("BATCH_LAST")); + assert.ok(history.includes("score(3)")); + assert.ok(checked.includes("x <- (\n2)"), "combined text reaches the native parser"); + assert.ok(nativeResults.some(result => result.code === "x <- (\n2)" && result.status === 1), + "native R validates the combined expression when available"); + + for (const overlap of [false, true]) { + terminal.inputState.insertText(' x <- paste("a'); + const first = terminal.handleEnterKey(); + if (!overlap) await first; + terminal.inputState.insertText('b")'); + await Promise.all([first, terminal.handleEnterKey()]); + await waitFor(idle, "multiline string assignment"); + const marker = `STRING_${overlap}_OK`; + terminal.handleInput(`stopifnot(identical(x, "a\\n b")); cat("${marker}\\n")\n`); + await waitFor(() => idle() && output.includes(marker), marker); + } + + for (const [opening, closing, label] of [ + ['x <- "a ', 'b"', "QUOTED"], + ['x <- r"(a ', 'b)"', "RAW"], + ]) { + terminal.inputState.insertText(opening); + await terminal.handleEnterKey(); + await terminal.handleEnterKey(); + terminal.inputState.insertText(closing); + await terminal.handleEnterKey(); + await waitFor(idle, "literal whitespace assignment"); + const marker = `${label}_WHITESPACE_OK`; + terminal.handleInput(`stopifnot(identical(x, "a \\n\\nb")); cat("${marker}\\n")\n`); + await waitFor(() => idle() && output.includes(marker), marker); + } + + terminal.inputState.insertText('x <- "a'); + terminal.handleInput("\r\r\r"); + await waitFor(() => idle() && terminal.inputState.text === 'x <- "a\n\n\n', "coalesced continuation Enters"); + terminal.inputState.insertText('b"'); + await terminal.handleEnterKey(); + await waitFor(idle, "coalesced Enter assignment"); + terminal.handleInput('stopifnot(identical(x, "a\\n\\n\\nb")); cat("COALESCED_ENTER_OK\\n")\n'); + await waitFor(() => idle() && output.includes("COALESCED_ENTER_OK"), "coalesced Enter value"); + + terminal.inputState.insertText("x <- c(1,2"); + terminal.inputState.cursorPosition = 9; + await terminal.handleEnterKey(); + assert.equal(terminal.inputState.text, "x <- c(1,\n2"); + assert.equal(terminal.inputState.cursorPosition, 10); + terminal.inputState.insertText("3,"); + terminal.inputState.cursorToEnd(); + terminal.inputState.insertText(")"); + await terminal.handleEnterKey(); + await waitFor(idle, "cursor edit assignment"); + terminal.handleInput('stopifnot(identical(x, c(1, 3, 2))); cat("CURSOR_EDIT_OK\\n")\n'); + await waitFor(() => idle() && output.includes("CURSOR_EDIT_OK"), "cursor edit value"); + + const historyStart = history.length; + terminal.handleInput('cat("HISTORY_KEEP\\n")\nSys.sleep(30)\n42\n'); + const interruptedBatch = terminal.programmaticSubmissionQueue; + await waitFor(() => output.includes("HISTORY_KEEP") && + terminal.activeSubmission?.code === "Sys.sleep(30)" && !terminal.awaitingExecutionStart, "sleep before interrupt"); + terminal.handleInput("\x03"); + await interruptedBatch; + await waitFor(idle, "interrupt recovery"); + // If a busy CI runner processes the 150 ms fallback before Ctrl+C, 42 may + // already have been accepted too. Earlier entries must survive either timing; + // the unit test gates the later check to cover cancellation while it is pending. + assert.deepEqual(history.slice(historyStart, historyStart + 2), ['cat("HISTORY_KEEP\\n")', "Sys.sleep(30)"]); + assert.equal(history.filter(code => code === 'cat("HISTORY_KEEP\\n")').length, 1); + // A detached R process keeps executing. The new frontend has no active + // submission, but must retain both parse requests and commands until ready. + terminal.handleInput("Sys.sleep(30)\n"); + await terminal.programmaticSubmissionQueue; + await waitFor(() => terminal.mode === "executing" && !terminal.awaitingExecutionStart, "sleep before reconnect"); + const reconnect = backend.getReconnectInfo(session); + assert.ok(reconnect); + terminal.cancelPendingSubmissions(); + backend.detach(session); + backend = new RustSidecarRuntimeBackend(binary); + instrument(); + session = backend.reconnect(reconnect); + ({ terminal, history } = createTerminal()); + terminal.runtimeBackend = backend; + terminal.rProcess = session; + sessionStates.length = 0; + backend.attach(session, handlers); + await waitFor(() => sessionStates.some(event => event.busy) && backend.canUseSessionCommands(session), "busy reconnect"); + assert.equal(terminal.mode, "executing"); + assert.equal(terminal.activeSubmission, null); + const sentBeforeBusyBatch = sent.length; + const checksBeforeBusyBatch = checked.length; + const failuresBeforeBusyBatch = nativeFailures.length; + terminal.handleInput('cat("MUST_NOT_RUN\\n")\n42\n'); + const cancelledBatch = terminal.programmaticSubmissionQueue; + // Cross the old native timeout while real R remains busy. + await delay(220, undefined, { signal: t.signal }); + assert.equal(checked.length, checksBeforeBusyBatch, "no native IPC while R is busy"); + assert.equal(nativeFailures.length, failuresBeforeBusyBatch); + assert.equal(sent.length, sentBeforeBusyBatch, "no dispatch to the Rust queue while busy"); + terminal.handleInput("\x03"); + await cancelledBatch; + await waitFor(idle, "busy reconnect interrupt recovery"); + terminal.handleInput('cat("AFTER_RECONNECT_OK\\n")\n'); + await terminal.programmaticSubmissionQueue; + await waitFor(() => idle() && output.includes("AFTER_RECONNECT_OK"), "fresh submission after cancellation"); + assert.ok(!output.includes("MUST_NOT_RUN")); + + // Recover raw source after a rejected send, then evaluate the retry with R. + const deliver = backend.sendSessionCommand; + backend.sendSessionCommand = () => false; + terminal.handleInput('1\nx <- "hello\n'); + terminal.handleInput('world"\n'); + await terminal.programmaticSubmissionQueue; + assert.equal(terminal.inputState.text, '1\nx <- "hello\nworld"\n'); + backend.sendSessionCommand = deliver; + await terminal.handleEnterKey(); + await waitFor(idle, "recovered literal retry"); + terminal.handleInput('stopifnot(identical(x, "hello\\nworld")); cat("RECOVERY_LITERAL_OK\\n")\n'); + await waitFor(() => idle() && output.includes("RECOVERY_LITERAL_OK"), "recovered literal value"); + if (failure) throw failure; + assert.doesNotMatch(output, /Error/); +}); + +test("two real R consoles parse and execute independently across busy, reply, detach and exit", { timeout: 60000 }, async t => { + const root = path.resolve(__dirname, ".."); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "r-console-sessions-")); + const rHome = execFileSync(process.env.R_TEST_EXECUTABLE || "R", ["RHOME"], { encoding: "utf8" }).trim(); + const rPath = path.join(rHome, "bin", process.platform === "win32" ? "R.exe" : "R"); + const settings = { + executablePath: rPath, sessionWatcher: false, + consoleArgs: ["--quiet", "--no-save", "--no-restore", "--no-site-file", "--no-environ"], + }; + const { resolveRTerminalOptions } = loadSource("src/Terminal/options.ts", { + vscode: { + extensions: { getExtension: () => undefined }, + workspace: { + getConfiguration: () => ({ get: key => settings[key] }), + workspaceFolders: [{ uri: { fsPath: cwd } }], + }, + window: { showErrorMessage: message => { throw new Error(message); } }, + }, + }); + const options = resolveRTerminalOptions(); + assert.ok(options); + // Sharing a backend instance also exercises its per-session request maps. + const backend = new RustSidecarRuntimeBackend(getBundledRustSidecarPath(root)); + const fixtures = [], checked = [], failures = []; + const request = backend.requestParseStatus.bind(backend); + backend.requestParseStatus = (session, code) => { + const result = request(session, code); + assert.ok(result); + return result.then(status => { checked.push({ session, code, status }); return status; }, error => { + failures.push(error); + throw error; + }); + }; + t.after(async () => { + const pids = fixtures.map(f => backend.getPid(f.session)); + for (const f of fixtures) { + f.terminal.cancelPendingSubmissions(); + backend.close(f.session); + backend.detach(f.session); + } + await delay(100); + for (const pid of pids) if (pid) { try { process.kill(pid, "SIGKILL"); } catch {} } + fs.rmSync(cwd, { recursive: true, force: true }); + for (const f of fixtures) { + fs.rmSync(path.join(os.tmpdir(), `r-console-session-${f.session.sessionId}.json`), { force: true }); + } + }); + const waitFor = async (predicate, label) => { + const deadline = Date.now() + 10000; + while (!predicate()) { + assert.equal(failures.length, 0, failures.map(e => e.message).join("\n")); + assert.ok(Date.now() < deadline, `${label}: ${fixtures.map(f => `${f.name}: ${f.terminal.mode}\n${f.output.join("")}`).join("\n")}`); + await delay(10, undefined, { signal: t.signal }); + } + }; + const idle = f => f.terminal.mode === "ready" && f.terminal.promptReady && !f.terminal.activeSubmission && !f.terminal.submissionQueue.length; + for (const name of ["A", "B"]) { + const fixture = createTerminal(); + const { terminal } = fixture; + const session = backend.start([rPath, ...options.rArgs], { + cwd, env: { ...options.env, R_PROFILE_USER: path.join(root, "resources/r/console-profile.R") }, + }); + Object.assign(terminal, { runtimeBackend: backend, rProcess: session, + mode: "starting", promptReady: false, sessionHostConnected: false }); + const f = { ...fixture, session, name }; + fixtures.push(f); + runtime.attachRuntimeSession(terminal.runtimeHostAdapter, true); + await waitFor(() => idle(f) && backend.hasCapability(session, "parse-status"), `${name} startup`); + } + const [a, b] = fixtures; + let value = 8100; + async function execute(f, input) { + const number = ++value; + const terminal = f.terminal; + if (input === "Enter") { + terminal.inputState.insertText(String(number)); + void terminal.handleEnterKey(); + } else { + terminal.handleInput(input === "editor" ? `${number}\n` : `\x1b[200~${number}\n\x1b[201~`); + } + await waitFor(() => idle(f) && f.output.join("").includes(`[1] ${number}`), `${f.name} ${input}`); + assert.ok(checked.some(c => c.code === String(number) && c.session === f.session && c.status === 1)); + assert.ok(checked.filter(c => c.code === String(number)).every(c => c.session === f.session)); + } + // Exercise both ownership directions; B is the last console to connect. + for (const [working, busy] of [[a, b], [b, a]]) { + busy.terminal.handleInput("Sys.sleep(30)\n"); + await waitFor(() => busy.terminal.mode === "executing" && !busy.terminal.awaitingExecutionStart, "other R executing"); + for (const input of ["Enter", "editor", "paste"]) await execute(working, input); + assert.equal(busy.terminal.mode, "executing", "submissions must finish before the other console is released"); + busy.terminal.interruptR(); + await waitFor(() => idle(busy), "other R interrupted"); + } + b.terminal.handleInput('readline("OTHER_REPLY>")\n'); + await waitFor(() => b.terminal.mode === "reply", "other R nested reply"); + await execute(a, "editor"); + assert.equal(b.terminal.mode, "reply"); + b.terminal.handleInput("answer\n"); + await waitFor(() => idle(b), "other R reply completed"); + + backend.detach(b.session); + b.terminal.cancelPendingSubmissions(); + await execute(a, "paste"); + runtime.attachRuntimeSession(b.terminal.runtimeHostAdapter); + await waitFor(() => backend.hasCapability(b.session, "parse-status"), "other R reattached"); + await execute(b, "Enter"); + b.terminal.handleInput('q("no")\n'); + await waitFor(() => b.terminal.mode === "closed", "other R exited"); + a.terminal.inputState.insertText("if"); + await a.terminal.handleEnterKey(); + assert.ok(checked.some(c => c.session === a.session && c.code === "if" && c.status === 2)); + assert.equal(a.terminal.inputState.text, "if\n"); + a.terminal.inputState.insertText("(TRUE) 9191"); + await a.terminal.handleEnterKey(); + await waitFor(() => idle(a) && a.output.join("").includes("[1] 9191"), "continuation after other console exited"); + assert.deepEqual(failures, [], "native parsing must succeed without timeout fallback"); +}); diff --git a/tests/unit/backend-parse.test.cjs b/tests/unit/backend-parse.test.cjs new file mode 100644 index 00000000..1cc1da15 --- /dev/null +++ b/tests/unit/backend-parse.test.cjs @@ -0,0 +1,84 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const loadSource = require("../helpers/load-source.cjs"); +const { RustSidecarRuntimeBackend } = loadSource("src/Runtime/runtimeBackend.ts"); +const parser = loadSource("src/Language/parser.ts"); + +test("native parse timeout settles the request, permits fallback, and ignores late replies", async t => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const backend = new RustSidecarRuntimeBackend("unused"); + const session = { sessionId: "parse-timeout" }; + const state = { + hostConnected: true, capabilities: new Set(["parse-status"]), + nextRequestId: 0, pendingParseRequests: new Map(), + }; + backend.sessionStates.set(session, state); + backend.writeFrame = () => true; + + const rejected = assert.rejects(backend.requestParseStatus(session, "if"), /timed out/); + t.mock.timers.tick(151); + await rejected; + assert.equal(state.pendingParseRequests.size, 0); + + const fallback = parser.isExpressionCompleteAsync("42", code => backend.requestParseStatus(session, code)); + t.mock.timers.tick(151); + assert.equal(await fallback, true, "the existing local fallback must be reachable"); + + const fresh = backend.requestParseStatus(session, "x <- (\n2)"); + backend.handleSessionControlEvent(state, { type: "parse-status-result", requestId: 1, status: 2 }); + backend.handleSessionControlEvent(state, { type: "parse-status-result", requestId: 2, status: 2 }); + assert.equal(state.pendingParseRequests.size, 1, "late results cannot settle a different request"); + backend.handleSessionControlEvent(state, { type: "parse-status-result", requestId: 3, status: 1 }); + assert.equal(await fresh, 1); + assert.equal(state.pendingParseRequests.size, 0); +}); + +test("detached socket frames cannot settle a reattached session's native parse or change its state", async () => { + const { EventEmitter } = require("node:events"); + const backend = new RustSidecarRuntimeBackend("unused"); + const session = { sessionId: "reattach" }; + const sockets = []; + backend.resolveReconnectInfo = async () => ({ sessionId: session.sessionId }); + backend.connectSocketWithRetry = async () => { + const socket = Object.assign(new EventEmitter(), { + destroyed: false, resume() {}, destroy() { this.destroyed = true; }, + }); + sockets.push(socket); + return socket; + }; + backend.writeFrame = () => true; + const events = []; + const attach = async () => { + backend.attach(session, { onControl: event => events.push(event), onError: error => { throw error; } }); + await new Promise(resolve => setImmediate(resolve)); + }; + await attach(); + const old = sockets[0]; + backend.detach(session); + await attach(); + const state = backend.sessionStates.get(session); + state.hostConnected = true; + state.capabilities.add("parse-status"); + let settled = false; + const pending = backend.requestParseStatus(session, "if").then(result => { settled = true; return result; }); + const frame = Buffer.alloc(16); + frame.writeUInt32LE(4, 0); + frame.writeUInt16LE(11, 4); // ParseStatusResult + frame.writeUInt32LE(state.nextRequestId, 8); + frame[12] = 1; + old.emit("data", frame); + // Partial old frames must not corrupt the new connection's frame assembly either. + old.emit("data", frame.subarray(0, 6)); + old.emit("close"); + old.emit("error", new Error("late socket error")); + await Promise.resolve(); + assert.equal(settled, false); + assert.equal(state.hostConnected, true); + assert.equal(state.socketCarry.length, 0); + assert.deepEqual(events, []); + frame[12] = 2; + sockets[1].emit("data", frame); + assert.equal(await pending, 2); + assert.equal(events.length, 1); + backend.detach(session); +}); diff --git a/tests/unit/history-worker.test.cjs b/tests/unit/history-worker.test.cjs index 5cfea1d9..920522c1 100644 --- a/tests/unit/history-worker.test.cjs +++ b/tests/unit/history-worker.test.cjs @@ -75,28 +75,17 @@ test("console input and submission continue while history reads, compacts, and w "\n# time: original\n+command-" + i + "\n").join("")); const history = f.create(); history.load(); - const { RTerminal } = loadSource("src/Terminal/rTerminal.ts", { - vscode: { EventEmitter: class {}, window: { createOutputChannel: () => ({}) } }, - "./rTerminal/lang": {}, - }); - const { InputState } = loadSource("src/Terminal/inputState.ts"); + const { createTerminal, nextPrompt } = require("../helpers/submission-terminal.cjs"); + const { terminal } = createTerminal(); + terminal.rHistory = history; const sent = []; - const terminal = Object.assign(Object.create(RTerminal.prototype), { - mode: "ready", promptReady: true, promptVisible: false, rHistory: history, - inputState: new InputState(), lang: { updateInput() {} }, - ensureReadyPromptVisibleForInput() {}, renderInput() {}, - }); - const host = { - mode: "ready", promptReady: false, activeSubmission: null, submissionQueue: [], - inputState: terminal.inputState, promptKind: "main", clearPromptRenderTimer() {}, - lang: { refreshCompletionContextDocument() {} }, - runtimeBackend: { sendSessionCommand(_session, command) { sent.push(command.code); return true; } }, + terminal.runtimeBackend.sendSessionCommand = (_session, command) => { + sent.push(command.code); + return true; }; - terminal.runtimeHost = () => host; for (const operation of ["readSync", "renameSync", "appendFileSync"]) { await waitFor(() => f.messages.some((message) => message.blocked === operation)); - host.mode = "ready"; - host.activeSubmission = null; + if (terminal.activeSubmission) nextPrompt(terminal); const code = `print("${operation}")`; terminal.applyKeyAction({ type: "text", text: code }); assert.equal(terminal.inputState.text, code); diff --git a/tests/unit/parser.test.cjs b/tests/unit/parser.test.cjs index 8678be1b..696a2ddf 100644 --- a/tests/unit/parser.test.cjs +++ b/tests/unit/parser.test.cjs @@ -21,13 +21,10 @@ test("comment stripping preserves strings, inline comments, and line endings", ( 'x <- "first\n# inside string\nlast"'); }); -test("native parsing distinguishes incomplete input from errors and falls back on failure", async (t) => { - t.after(() => parser.setNativeParseCallback(null)); - parser.setNativeParseCallback(async () => 2); - assert.equal(await parser.isExpressionCompleteAsync("x"), false); - parser.setNativeParseCallback(async () => 3); - assert.equal(await parser.isExpressionCompleteAsync("x"), true); - parser.setNativeParseCallback(async () => { throw new Error("timeout"); }); - assert.equal(await parser.isExpressionCompleteAsync("x[1]"), true); - assert.equal(await parser.isExpressionCompleteAsync("x["), false); +test("native parsing distinguishes incomplete input from errors and falls back on failure", async () => { + assert.equal(await parser.isExpressionCompleteAsync("x", async () => 2), false); + assert.equal(await parser.isExpressionCompleteAsync("x", async () => 3), true); + const unavailable = async () => { throw new Error("timeout"); }; + assert.equal(await parser.isExpressionCompleteAsync("x[1]", unavailable), true); + assert.equal(await parser.isExpressionCompleteAsync("x[", unavailable), false); }); diff --git a/tests/unit/submission-sessions.test.cjs b/tests/unit/submission-sessions.test.cjs new file mode 100644 index 00000000..53300cdf --- /dev/null +++ b/tests/unit/submission-sessions.test.cjs @@ -0,0 +1,156 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { createTerminal, nextPrompt, deferred, runtime } = require("../helpers/submission-terminal.cjs"); +const settle = () => new Promise(resolve => setImmediate(resolve)); + +function consoleSession() { + const fixture = createTerminal(); + const { terminal } = fixture; + const checked = [], closed = [], detached = []; + let handlers; + Object.assign(terminal.runtimeBackend, { + hasCapability: () => true, + requestParseStatus(session, code) { + assert.equal(session, terminal.rProcess); + checked.push(code); + return Promise.resolve(code === "if" ? 2 : 1); + }, + attach(_session, callbacks) { handlers = callbacks; }, + detach(session) { detached.push(session); }, + close(session) { closed.push(session); }, + getPid() {}, + }); + terminal.rHistory.dispose = () => {}; + terminal.syntax.dispose = () => {}; + terminal.lang.dispose = async () => {}; + terminal.terminalState = { dispose() {} }; + terminal.exportPersistentState = () => ({ sessionId: "test" }); + function connect() { + runtime.attachRuntimeSession(terminal.runtimeHostAdapter); + handlers.onControl({ type: "host-connected" }); + } + return { ...fixture, checked, closed, detached, connect, get handlers() { return handlers; } }; +} + +function submit(terminal, input, code) { + if (input === "Enter") { + terminal.inputState.insertText(code); + return terminal.handleEnterKey(); + } + terminal.handleInput(input === "editor" ? code + "\n" : `\x1b[200~${code}\n\x1b[201~`); + return terminal.programmaticSubmissionQueue; +} + +for (const input of ["Enter", "editor", "paste"]) { + for (const order of ["AB", "BA"]) { + test(`${input} uses its own ready R while the other console executes (connect ${order})`, async () => { + const a = consoleSession(), b = consoleSession(); + for (const fixture of order === "AB" ? [a, b] : [b, a]) fixture.connect(); + await b.terminal.handleProgrammaticSubmission("Sys.sleep(30)\n"); + await submit(a.terminal, input, "42"); + await settle(); + assert.deepEqual(a.checked, ["42"]); + assert.deepEqual(a.commands.map(c => c.code), ["42"]); + assert.deepEqual(b.checked, ["Sys.sleep(30)"]); + assert.equal(b.terminal.mode, "executing"); + }); + } +} + +const lifecycle = { + "connect without parse capability": b => { + b.terminal.runtimeBackend.hasCapability = () => false; + b.connect(); + }, + "busy session snapshot": b => b.handlers.onControl({ type: "session-state", busy: true, wait: { kind: "none" } }), + "interrupt": b => b.terminal.interruptR(), + "exit": b => b.handlers.onExit(0), + "error": b => b.handlers.onError(new Error("connection failed")), + "detach": b => b.terminal.detachPersistentSession(), + "close": b => b.terminal.forceClose(), +}; +for (const [name, action] of Object.entries(lifecycle)) { + test(`another console's ${name} cannot release or disable an owned native check`, async () => { + const a = consoleSession(), b = consoleSession(); + a.connect(); b.connect(); + a.terminal.mode = "executing"; + a.terminal.promptReady = false; + const pending = a.terminal.handleProgrammaticSubmission("if\n"); + await settle(); + await action(b); + await settle(); + assert.deepEqual(a.checked, [], "A still waits for its own prompt"); + assert.deepEqual(a.commands, []); + nextPrompt(a.terminal); + await pending; + assert.ok(a.checked.length > 0 && a.checked.every(code => code === "if")); + assert.deepEqual(a.commands, [], "native incomplete result is respected"); + assert.equal(a.terminal.inputState.text, "if\n"); + a.terminal.inputState.insertText("(TRUE) 42"); + await a.terminal.handleEnterKey(); + assert.deepEqual(a.commands.map(c => c.code), ["if\n(TRUE) 42"]); + }); +} + +for (const name of ["exit", "error", "detach", "close"]) { + for (const phase of ["waiting", "in flight"]) { + test(`${name} cancels its own ${phase} parse without leaking source into a replacement session`, async () => { + const a = consoleSession(); + a.connect(); + const check = deferred(); + if (phase === "waiting") { + a.terminal.mode = "executing"; + a.terminal.promptReady = false; + } else { + a.terminal.runtimeBackend.requestParseStatus = () => check.promise; + } + const pending = a.terminal.handleProgrammaticSubmission("if\n"); + await settle(); + await lifecycle[name](a); + if (phase === "waiting") await pending; // Cancellation must wake without an R event. + a.terminal.rProcess = {}; + a.terminal.mode = "ready"; + a.terminal.promptReady = true; + a.terminal.runtimeBackend.requestParseStatus = () => Promise.resolve(1); + a.connect(); + check.resolve(2); + await pending; + assert.deepEqual(a.commands, []); + assert.equal(a.terminal.inputState.text, ""); + await a.terminal.handleProgrammaticSubmission("42\n"); + assert.deepEqual(a.commands.map(c => c.code), ["42"]); + }); + } +} + +test("late callbacks from a replaced session cannot mutate or close its replacement", async () => { + const a = consoleSession(); + a.connect(); + const stale = a.handlers; + a.terminal.cancelPendingSubmissions(); + a.terminal.rProcess = {}; + a.connect(); + const generation = a.terminal.submissionGeneration; + stale.onStdout("stale output"); + stale.onStderr("stale error"); + stale.onControl({ type: "input-request", prompt: "stale reply" }); + stale.onControl({ type: "busy", value: true }); + stale.onExit(1); + stale.onError(new Error("stale failure")); + assert.equal(a.terminal.mode, "ready"); + assert.equal(a.terminal.promptReady, true); + assert.equal(a.terminal.submissionGeneration, generation); + assert.deepEqual(a.closed, []); + assert.deepEqual(a.output, []); + await a.terminal.handleProgrammaticSubmission("42\n"); + assert.deepEqual(a.checked, ["42"]); +}); + +test("losing parser availability uses the local fallback, not a fabricated native complete result", async () => { + const a = consoleSession(); + a.connect(); + a.terminal.runtimeBackend.requestParseStatus = () => undefined; + await a.terminal.handleProgrammaticSubmission("if\n"); + assert.deepEqual(a.commands, []); + assert.equal(a.terminal.inputState.text, "if\n"); +}); diff --git a/tests/unit/submission.test.cjs b/tests/unit/submission.test.cjs new file mode 100644 index 00000000..974b5ab4 --- /dev/null +++ b/tests/unit/submission.test.cjs @@ -0,0 +1,749 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { createTerminal, createTerminalWithScreen, nextPrompt, deferred, runtime } = require("../helpers/submission-terminal.cjs"); + +// An extra turn also drains Enter handlers invoked by handleInput without an +// exposed promise. Tests control parser completion explicitly, never by sleeps. +const settle = () => new Promise(resolve => setImmediate(resolve)); + +for (const source of ["# comment", "# first\n# second"]) { + test(`comment-only console Enter restores the visible prompt: ${source}`, async t => { + const { terminal, commands, history, screenLines } = createTerminalWithScreen(t); + terminal.inputState.insertText(source); + terminal.renderInput(); + terminal.handleInput("\r"); + await settle(); + assert.deepEqual(screenLines(), ["R> "]); + assert.equal(terminal.promptVisible, true); + assert.equal(terminal.inputState.text, ""); + assert.deepEqual(commands, []); + assert.deepEqual(history, []); + }); +} + +for (const path of ["editor", "paste"]) { + test(`comment-only ${path} submission leaves a visible prompt`, async t => { + const { terminal, commands, screenLines } = createTerminalWithScreen(t); + terminal.handleInput(path === "editor" ? "# comment\n" : "\x1b[200~# comment\n\x1b[201~"); + await terminal.programmaticSubmissionQueue; + await settle(); + assert.deepEqual(screenLines(), ["R> "]); + assert.deepEqual(commands, []); + }); +} + +test("discarding comments behind execution does not render an early prompt", async t => { + const { terminal, commands, screenLines } = createTerminalWithScreen(t); + terminal.handleInput("1\n# comment\n"); + await terminal.programmaticSubmissionQueue; + assert.equal(terminal.mode, "executing"); + assert.equal(terminal.promptVisible, false); + assert.deepEqual(screenLines(), ["R> 1"]); + assert.deepEqual(commands.map(c => c.code), ["1"]); + nextPrompt(terminal); + assert.deepEqual(screenLines(), ["R> 1", "R> "]); +}); + +for (const [opening, closing] of [['x <- "a', 'b"'], ['x <- r"(a', 'b)"']]) { + test(`coalesced Enter actions preserve blank literal lines: ${opening}`, async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText(opening); + terminal.handleInput("\r\r\r"); + await settle(); + assert.equal(terminal.inputState.text, opening + "\n\n\n"); + terminal.inputState.insertText(closing); + await terminal.handleEnterKey(); + assert.deepEqual(commands.map(c => c.code), [opening + "\n\n\n" + closing]); + }); +} + +test("a queued whitespace-only Enter preserves spaces inside a literal", async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText('x <- "a'); + const first = terminal.handleEnterKey(); + terminal.inputState.insertText(" "); + await Promise.all([first, terminal.handleEnterKey()]); + terminal.inputState.insertText('b"'); + await terminal.handleEnterKey(); + assert.deepEqual(commands.map(c => c.code), ['x <- "a\n \nb"']); +}); + +test("empty Enter behind complete code does not submit an extra expression", async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText("42"); + terminal.handleInput("\r\r"); + await settle(); + assert.deepEqual(commands.map(c => c.code), ["42"]); + assert.deepEqual(terminal.submissionQueue, []); + assert.equal(terminal.promptVisible, false); +}); + +for (const [opening, closing] of [['x <- "a ', 'b"'], ['x <- r"(a ', 'b)"']]) { + for (const overlap of [false, true]) { + test(`Enter preserves literal trailing whitespace (${opening}, overlap=${overlap})`, async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText(opening); + const first = terminal.handleEnterKey(); + if (!overlap) await first; + terminal.inputState.insertText(closing); + await Promise.all([first, terminal.handleEnterKey()]); + assert.deepEqual(commands.map(c => c.code), [opening + "\n" + closing]); + }); + } +} + +test("repeated Enter preserves blank lines inside a multiline string", async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText('x <- "a '); + await terminal.handleEnterKey(); + await terminal.handleEnterKey(); + await terminal.handleEnterKey(); + assert.equal(terminal.inputState.text, 'x <- "a \n\n\n'); + terminal.inputState.insertText('b"'); + await terminal.handleEnterKey(); + assert.deepEqual(commands.map(c => c.code), ['x <- "a \n\n\nb"']); +}); + +test("Enter restores its newline and cursor inside incomplete input", async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText("c(1,2"); + terminal.inputState.cursorPosition = 4; + await terminal.handleEnterKey(); + assert.equal(terminal.inputState.text, "c(1,\n2"); + assert.equal(terminal.inputState.cursorPosition, 5); + assert.deepEqual(commands, []); +}); + +test("Enter inside complete input executes the original expression unchanged", async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText('paste("ab")'); + terminal.inputState.cursorPosition = 8; + await terminal.handleEnterKey(); + assert.deepEqual(commands.map(c => c.code), ['paste("ab")']); + assert.equal(terminal.inputState.text, ""); +}); + +test("typing during a check is restored at the saved cursor, before the suffix", async () => { + const { terminal } = createTerminal(); + const gate = deferred(); + terminal.inputState.isExpressionCompleteAsync = () => gate.promise; + terminal.inputState.insertText("c(1,2"); + terminal.inputState.cursorPosition = 4; + const first = terminal.handleEnterKey(); + terminal.inputState.insertText("3,"); + gate.resolve(false); + await first; + assert.equal(terminal.inputState.text, "c(1,\n3,2"); + assert.equal(terminal.inputState.cursorPosition, 7); +}); + +for (const overlap of [false, true]) { + test(`successive Enter edits preserve the original suffix (overlap=${overlap})`, async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText("c(1,2"); + terminal.inputState.cursorPosition = 4; + const first = terminal.handleEnterKey(); + if (!overlap) await first; + terminal.inputState.insertText("3,"); + await Promise.all([first, terminal.handleEnterKey()]); + assert.equal(terminal.inputState.text, "c(1,\n3,\n2"); + assert.equal(terminal.inputState.cursorPosition, 8); + terminal.inputState.cursorToEnd(); + terminal.inputState.insertText(")"); + await terminal.handleEnterKey(); + assert.deepEqual(commands.map(c => c.code), ["c(1,\n3,\n2)"]); + }); +} + +test("an editor send extends a queued cursor edit behind execution", async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText("1\nc(1,2"); + terminal.inputState.cursorPosition = 6; + await terminal.handleEnterKey(); + assert.deepEqual(commands.map(c => c.code), ["1"]); + terminal.handleInput("3,\n"); + await terminal.programmaticSubmissionQueue; + nextPrompt(terminal); + assert.equal(terminal.inputState.text, "c(1,\n3,\n2"); + assert.equal(terminal.inputState.cursorPosition, 8); +}); + +test("interruption retains earlier history while later parsing is pending", async () => { + const { terminal, commands, history } = createTerminal(); + const gate = deferred(); + const check = terminal.inputState.isExpressionCompleteAsync.bind(terminal.inputState); + terminal.inputState.isExpressionCompleteAsync = code => code === "42" ? gate.promise : check(code); + terminal.handleInput('cat("ran\\n")\nSys.sleep(30)\n42\n'); + const batch = terminal.programmaticSubmissionQueue; + await settle(); + nextPrompt(terminal); + assert.deepEqual(history, ['cat("ran\\n")', "Sys.sleep(30)"]); + terminal.interruptR(); + gate.resolve(true); + await batch; + assert.deepEqual(commands.map(c => c.type), ["submit", "submit", "interrupt"]); + assert.deepEqual(history, ['cat("ran\\n")', "Sys.sleep(30)"]); +}); + +test("a later delivery failure keeps earlier history without duplicating a retry", async () => { + const { terminal, commands, history } = createTerminal(); + const gate = deferred(); + const check = terminal.inputState.isExpressionCompleteAsync.bind(terminal.inputState); + terminal.inputState.isExpressionCompleteAsync = code => code === "2" ? gate.promise : check(code); + terminal.handleInput("1\n2\n"); + const batch = terminal.programmaticSubmissionQueue; + await settle(); + nextPrompt(terminal); + const send = terminal.runtimeBackend.sendSessionCommand; + terminal.runtimeBackend.sendSessionCommand = () => false; + gate.resolve(true); + await batch; + assert.deepEqual(history, ["1"]); + assert.equal(terminal.inputState.text, "2"); + terminal.runtimeBackend.sendSessionCommand = send; + await terminal.handleEnterKey(); + assert.deepEqual(commands.map(c => c.code), ["1", "2"]); + assert.deepEqual(history, ["1", "2"]); +}); + +for (const timing of ["during parsing", "after parsing", "after restoration"]) { + test(`editor continuation is consumed once ${timing}`, async () => { + const { terminal, commands, history } = createTerminal(); + terminal.handleInput("Sys.sleep(1)\nx <- (\n"); + if (timing !== "during parsing") await terminal.programmaticSubmissionQueue; + if (timing === "after restoration") { + nextPrompt(terminal); + assert.equal(terminal.inputState.text, "x <- (\n"); + } + terminal.handleInput("2)\n"); + await terminal.programmaticSubmissionQueue; + await settle(); + if (timing !== "after restoration") nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), ["Sys.sleep(1)", "x <- (\n2)"]); + assert.deepEqual(history, ["Sys.sleep(1)", "x <- (\n2)"]); + assert.equal(terminal.inputState.text, ""); + assert.deepEqual(terminal.submissionQueue, []); + }); +} + +test("Enter waits for the previous native check and checks only the combined continuation", async (t) => { + const { terminal, commands } = createTerminal(); + const first = deferred(); + const checked = []; + terminal.runtimeBackend.hasCapability = () => true; + terminal.runtimeBackend.requestParseStatus = (_session, code) => { + checked.push(code); + return code === "if" ? first.promise : Promise.resolve(1); + }; + terminal.inputState.insertText("if"); + const a = terminal.handleEnterKey(); + terminal.inputState.insertText("(TRUE) 1"); + const b = terminal.handleEnterKey(); + await settle(); + assert.deepEqual(checked, ["if"], "no speculative check of the second input"); + assert.deepEqual(commands, []); + first.resolve(2); + await Promise.all([a, b]); + assert.equal(checked.filter(code => code === "if\n(TRUE) 1").length, 1); + assert.ok(checked.every(code => code.startsWith("if"))); + assert.deepEqual(commands.map(c => c.code), ["if\n(TRUE) 1"]); + assert.equal(terminal.inputState.text, ""); +}); + +for (const source of ["Enter", "paste", "editor"]) { + test(`${source} extends a pending editor expression without consuming newer typing`, async () => { + const { terminal, commands } = createTerminal(); + const gate = deferred(); + const check = terminal.inputState.isExpressionCompleteAsync.bind(terminal.inputState); + let first = true; + terminal.inputState.isExpressionCompleteAsync = async code => { + if (first) { first = false; await gate.promise; } + return check(code); + }; + terminal.handleInput("x <- (\n"); + let accepted; + if (source === "Enter") { + terminal.inputState.insertText("2)"); + accepted = terminal.handleEnterKey(); + } else if (source === "paste") { + terminal.pasteBuffer = "2)\n"; + accepted = terminal.handlePasteEnd(); + } else { + terminal.handleInput("2)\n"); + accepted = terminal.programmaticSubmissionQueue; + } + terminal.handleInput("z"); + gate.resolve(); + await accepted; + assert.deepEqual(commands.map(c => c.code), ["x <- (\n2)"]); + assert.equal(terminal.inputState.text, "z"); + }); +} + +test("several editor batches complete nested expressions and leave a new tail", async () => { + const { terminal, commands, history } = createTerminal(); + for (const code of [ + 'cat("Starting\\n")\nSys.sleep(1)\nscore <- function(x) {\n if (x > 0) {\n', + ' x * 2\n } else {\n', + ' -x\n }\n}\nscore(3)\nanswer <- (\n', + ' score(-4) +\n 1\n)\nanswer\n', + ]) terminal.handleInput(code); + await terminal.programmaticSubmissionQueue; + const expected = [ + 'cat("Starting\\n")', 'Sys.sleep(1)', + 'score <- function(x) {\n if (x > 0) {\n x * 2\n } else {\n -x\n }\n}', + 'score(3)', 'answer <- (\n score(-4) +\n 1\n)', 'answer', + ]; + for (let i = 1; i < expected.length; i++) nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), expected); + assert.deepEqual(history, expected); + assert.equal(terminal.inputState.text, ""); +}); + +test("overlapping and settled Enter preserve the same indented multiline string", async () => { + for (const overlap of [false, true]) { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText(' x <- paste("a'); + const first = terminal.handleEnterKey(); + if (!overlap) await first; + terminal.inputState.insertText('b")'); + await Promise.all([first, terminal.handleEnterKey()]); + assert.deepEqual(commands.map(c => c.code), [' x <- paste("a\n b")']); + assert.equal(terminal.inputState.text, ""); + } +}); + +test("cancellation releases new submissions without waiting for a stale parser", async () => { + const { terminal, commands } = createTerminal(); + const stale = deferred(); + const check = terminal.inputState.isExpressionCompleteAsync.bind(terminal.inputState); + terminal.inputState.isExpressionCompleteAsync = code => code === "if" ? stale.promise : check(code); + terminal.inputState.insertText("if"); + const first = terminal.handleEnterKey(); + terminal.inputState.insertText("(TRUE) 1"); + const second = terminal.handleEnterKey(); + terminal.applyKeyAction({ type: "ctrl_c" }); + terminal.inputState.insertText("42"); + await terminal.handleEnterKey(); + assert.deepEqual(commands.map(c => c.code), ["42"]); + stale.resolve(false); + await Promise.all([first, second]); + assert.deepEqual(commands.map(c => c.code), ["42"]); + assert.deepEqual(terminal.submissionQueue, []); + assert.equal(terminal.inputState.text, ""); +}); + +test("delivery failure restores combined continuation and later reservations once", async () => { + const { terminal, commands } = createTerminal(); + terminal.runtimeBackend.sendSessionCommand = () => false; + terminal.handleInput("x <- (\n"); + terminal.handleInput("2)\n"); + terminal.handleInput("3\n"); + await terminal.programmaticSubmissionQueue; + assert.deepEqual(commands, []); + assert.equal(terminal.inputState.text, "x <- (\n2)\n3\n"); + assert.deepEqual(terminal.submissionQueue, []); + assert.equal(terminal.mode, "ready"); +}); + +test("an empty reservation does not block the next parser", async () => { + const { terminal, commands } = createTerminal(); + terminal.queueProgrammaticSubmission("\n"); + terminal.handleInput("42\n"); + await terminal.programmaticSubmissionQueue; + assert.deepEqual(commands.map(c => c.code), ["42"]); +}); + +test("bracketed paste while R is executing remains discarded", async () => { + const { terminal, commands } = createTerminal(); + await terminal.handleProgrammaticSubmission("Sys.sleep(1)\n"); + terminal.handleInput("\x1b[200~ignored <- 1\nignored <- 2\x1b[201~"); + nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), ["Sys.sleep(1)"]); + assert.equal(terminal.inputState.text, ""); + assert.deepEqual(terminal.submissionQueue, []); +}); + +test("Enter commits a snapshot, accepts further typing, and executes only the original command", async () => { + const { terminal, commands } = createTerminal(); + const parse = deferred(); + terminal.inputState.insertText("x <- 1"); + terminal.inputState.isExpressionCompleteAsync = () => parse.promise; + const enter = terminal.handleEnterKey(); + assert.equal(terminal.inputState.text, ""); + terminal.handleInput("b"); + terminal.handleInput("\x7f"); + terminal.handleInput("c"); + assert.equal(terminal.inputState.text, "c"); + parse.resolve(true); + await enter; + assert.deepEqual(commands, [{ type: "submit", code: "x <- 1" }]); + assert.equal(terminal.inputState.text, "c"); +}); + +test("an incomplete Enter restores continuation without dropping subsequent typing", async () => { + const { terminal, commands } = createTerminal(); + const parse = deferred(); + terminal.inputState.insertText("x <- ("); + terminal.inputState.isExpressionCompleteAsync = () => parse.promise; + const enter = terminal.handleEnterKey(); + terminal.handleInput("2"); + parse.resolve(false); + await enter; + assert.equal(terminal.inputState.text, "x <- (\n 2"); + assert.deepEqual(commands, []); +}); + +test("multiline paste commits its snapshot and does not erase newer keyboard input", async () => { + const { terminal, commands } = createTerminal(); + const parse = deferred(); + terminal.inputState.isExpressionCompleteAsync = code => code === "a <- 1" ? parse.promise : Promise.resolve(true); + terminal.pasteBuffer = "a <- 1\nb <- 2\n"; + const pasted = terminal.handlePasteEnd(); + terminal.handleInput("z"); + assert.equal(terminal.inputState.text, "z"); + parse.resolve(true); + await pasted; + assert.deepEqual(commands, [{ type: "submit", code: "a <- 1" }]); + assert.equal(terminal.inputState.text, "z"); + nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), ["a <- 1", "b <- 2"]); +}); + +test("editor source streams the first complete block without waiting for later parsing", async () => { + const { terminal, commands } = createTerminal(); + const later = deferred(); + terminal.inputState.isExpressionCompleteAsync = code => code === "b <- 2" ? later.promise : Promise.resolve(true); + const pending = terminal.handleProgrammaticSubmission("a <- 1\nb <- 2\nc <- 3\n"); + await Promise.resolve(); + assert.deepEqual(commands.map(c => c.code), ["a <- 1"]); + nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), ["a <- 1"]); + later.resolve(true); + await pending; + assert.deepEqual(commands.map(c => c.code), ["a <- 1", "b <- 2"]); + nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), ["a <- 1", "b <- 2", "c <- 3"]); +}); + +test("editor batch retains its position ahead of a later console Enter", async () => { + const { terminal, commands } = createTerminal(); + const first = deferred(); + terminal.inputState.isExpressionCompleteAsync = code => code === "a <- 1" ? first.promise : Promise.resolve(true); + terminal.handleInput("a <- 1\na <- 2\n"); + await Promise.resolve(); + terminal.handleInput("b"); + const enter = terminal.handleEnterKey(); + assert.equal(terminal.inputState.text, ""); + first.resolve(true); + await terminal.programmaticSubmissionQueue; + await enter; + assert.deepEqual(commands.map(c => c.code), ["a <- 1"]); + nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), ["a <- 1", "a <- 2"]); + nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), ["a <- 1", "a <- 2", "b"]); +}); + +test("editor batch retains its position ahead of a later multiline console paste", async () => { + const { terminal, commands } = createTerminal(); + const first = deferred(); + terminal.inputState.isExpressionCompleteAsync = code => code === "a <- 1" ? first.promise : Promise.resolve(true); + terminal.handleInput("a <- 1\na <- 2\n"); + await Promise.resolve(); + terminal.pasteBuffer = "b <- 1\nb <- 2\n"; + const pasted = terminal.handlePasteEnd(); + first.resolve(true); + await terminal.programmaticSubmissionQueue; + await pasted; + assert.deepEqual(commands.map(c => c.code), ["a <- 1"]); + for (const expected of ["a <- 2", "b <- 1", "b <- 2"]) { + nextPrompt(terminal); + assert.equal(commands.at(-1).code, expected); + } +}); + +test("incomplete source tail survives completed blocks and becomes editable at the next prompt", async () => { + const { terminal, commands, history } = createTerminal(); + await terminal.handleProgrammaticSubmission("a <- 1\nb <- (\n"); + assert.deepEqual(commands.map(c => c.code), ["a <- 1"]); + nextPrompt(terminal); + assert.equal(terminal.inputState.text, "b <- (\n"); + assert.deepEqual(history, ["a <- 1"]); +}); + +test("Ctrl+C cancels queued blocks after an accepted interrupt", async () => { + const { terminal, commands } = createTerminal(); + await terminal.handleProgrammaticSubmission("1\n2\n3\n"); + assert.deepEqual(commands.map(c => c.code), ["1"]); + terminal.interruptR(); + nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.type), ["submit", "interrupt"]); + assert.deepEqual(terminal.submissionQueue, []); +}); + +test("Ctrl+C during parsing invalidates pending source without late execution", async () => { + const { terminal, commands } = createTerminal(); + const parse = deferred(); + terminal.inputState.isExpressionCompleteAsync = () => parse.promise; + terminal.handleInput("x <- 1\n"); + const oldQueue = terminal.programmaticSubmissionQueue; + await Promise.resolve(); + terminal.applyKeyAction({ type: "ctrl_c" }); + parse.resolve(true); + await oldQueue; + assert.deepEqual(commands, []); + assert.equal(terminal.submissionQueue.length, 0); +}); + +test("failed backend delivery restores unsent blocks and does not strand the console", async () => { + const { terminal, commands, output } = createTerminal(); + terminal.runtimeBackend.sendSessionCommand = () => false; + await terminal.handleProgrammaticSubmission("a <- 1\nb <- 2\n"); + assert.deepEqual(commands, []); + assert.equal(terminal.mode, "ready"); + assert.equal(terminal.activeSubmission, null); + assert.equal(terminal.submissionQueue.length, 0); + assert.equal(terminal.inputState.text, "a <- 1\nb <- 2\n"); + assert.match(output.join(""), /submission was not sent/i); +}); + +test("failed later backend delivery restores only unsent blocks", async () => { + const { terminal, commands } = createTerminal(); + let sends = 0; + terminal.runtimeBackend.sendSessionCommand = (session, command) => { + if (++sends === 1) { commands.push(command); return true; } + return false; + }; + await terminal.handleProgrammaticSubmission("1\n2\n"); + nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), ["1"]); + assert.equal(terminal.mode, "ready"); + assert.equal(terminal.inputState.text, "2"); +}); + +test("rejected interrupt does not discard queued source", async () => { + const { terminal } = createTerminal(); + await terminal.handleProgrammaticSubmission("1\n2\n"); + terminal.runtimeBackend.sendSessionCommand = () => false; + terminal.interruptR(); + assert.equal(terminal.submissionQueue.length, 1); +}); + +function nativeChecks(t, terminal) { + const checked = []; + terminal.runtimeBackend.requestParseStatus = (_session, code) => { + checked.push(code); + return Promise.resolve(code === "if" ? 2 : 1); + }; + terminal.runtimeBackend.hasCapability = () => true; + return checked; +} + +for (const input of ["Enter", "editor", "paste"]) { + test(`${input} reserves immediately and waits for readiness between native checks`, async t => { + const { terminal, commands } = createTerminal(); + const checked = nativeChecks(t, terminal); + let pending; + if (input === "Enter") { + terminal.inputState.insertText("1\n2\n3"); + pending = terminal.handleEnterKey(); + } else if (input === "editor") { + terminal.handleInput("1\n2\n3\n"); + pending = terminal.programmaticSubmissionQueue; + } else { + terminal.handleInput("\x1b[200~1\n2\n3\n\x1b[201~"); + } + assert.equal(terminal.submissionQueue[0].kind, "parse"); + await settle(); + assert.deepEqual(checked, ["1"]); + assert.deepEqual(commands.map(c => c.code), ["1"]); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: false }); + await settle(); + assert.deepEqual(checked, ["1"], "busy(false) cannot release the next check"); + nextPrompt(terminal); + await settle(); + assert.deepEqual(checked, ["1", "2"]); + nextPrompt(terminal); + await pending; + await settle(); + assert.deepEqual(checked, ["1", "2", "3"]); + assert.deepEqual(commands.map(c => c.code), ["1", "2", "3"]); + }); +} + +for (const withNative of [false, true]) { + test(`busy reconnect keeps editor work cancellable (native=${withNative})`, async t => { + const { terminal, commands } = createTerminal(); + const checked = withNative ? nativeChecks(t, terminal) : []; + terminal.mode = "executing"; + terminal.promptReady = false; + terminal.handleInput('cat("must not execute")\n'); + const batch = terminal.programmaticSubmissionQueue; + await settle(); + assert.deepEqual(commands, []); + assert.deepEqual(checked, []); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: false }); + await settle(); + assert.equal(terminal.mode, "executing"); + assert.deepEqual(commands, []); + terminal.handleInput("\x03"); + await batch; + nextPrompt(terminal); + await settle(); + assert.deepEqual(commands.map(c => c.type), ["interrupt"]); + assert.deepEqual(checked, []); + terminal.handleInput("42\n"); + await terminal.programmaticSubmissionQueue; + assert.deepEqual(commands.map(c => c.code).filter(Boolean), ["42"]); + }); +} + +test("waiting native checks retain continuation semantics after readiness", async t => { + const { terminal, commands } = createTerminal(); + const checked = nativeChecks(t, terminal); + terminal.mode = "executing"; + terminal.promptReady = false; + terminal.handleInput("if\n"); + terminal.handleInput("(TRUE) 1\n"); + await settle(); + assert.deepEqual(checked, []); + nextPrompt(terminal); + await terminal.programmaticSubmissionQueue; + assert.equal(checked.at(-1), "if\n(TRUE) 1"); + assert.ok(checked.slice(0, -1).every(code => code === "if")); + assert.deepEqual(commands.map(c => c.code), ["if\n(TRUE) 1"]); +}); + +test("cancelling a readiness wait releases its batch without an R event", async t => { + const { terminal } = createTerminal(); + const checked = nativeChecks(t, terminal); + terminal.mode = "executing"; + terminal.promptReady = false; + terminal.handleInput("42\n"); + const oldBatch = terminal.programmaticSubmissionQueue; + await settle(); + terminal.cancelPendingSubmissions(); + await oldBatch; + assert.deepEqual(checked, []); + assert.deepEqual(terminal.submissionQueue, []); +}); + +for (const input of ["Enter", "editor", "paste"]) { + for (const raw of [false, true]) { + test(`${input} delivery recovery preserves a split ${raw ? "raw" : "quoted"} literal`, async () => { + const { terminal, commands } = createTerminal(); + terminal.runtimeBackend.sendSessionCommand = () => false; + const opening = raw ? 'x <- r"(hello' : 'x <- "hello'; + const closing = raw ? 'world)"' : 'world"'; + let first, second; + if (input === "Enter") { + terminal.inputState.insertText("1\n" + opening); + first = terminal.handleEnterKey(); + terminal.inputState.insertText(closing); + second = terminal.handleEnterKey(); + } else if (input === "editor") { + terminal.handleInput("1\n" + opening + "\n"); + terminal.handleInput(closing + "\n"); + second = terminal.programmaticSubmissionQueue; + } else { + terminal.handleInput("\x1b[200~1\n" + opening + "\n\x1b[201~"); + terminal.handleInput("\x1b[200~" + closing + "\n\x1b[201~"); + } + await Promise.all([first, second]); + await settle(); + const expected = "1\n" + opening + "\n" + closing; + assert.equal(terminal.inputState.text, expected + (input === "editor" ? "\n" : "")); + assert.deepEqual(commands, []); + terminal.runtimeBackend.sendSessionCommand = (_session, command) => { commands.push(command); return true; }; + await terminal.handleEnterKey(); + nextPrompt(terminal); + assert.deepEqual(commands.map(c => c.code), ["1", opening + "\n" + closing]); + }); + } +} + +test("failed delivery retains trailing literal blank lines and continuation indentation", async () => { + const { terminal } = createTerminal(); + terminal.runtimeBackend.sendSessionCommand = () => false; + terminal.handleInput('1\nx <- "hello\n\n '); + terminal.handleInput('world\n\n'); + await terminal.programmaticSubmissionQueue; + assert.equal(terminal.inputState.text, '1\nx <- "hello\n\n world\n\n'); +}); + +test("failed delivery retains an empty Enter queued inside a literal", async () => { + const { terminal } = createTerminal(); + terminal.runtimeBackend.sendSessionCommand = () => false; + terminal.inputState.insertText('1\nx <- "hello'); + const first = terminal.handleEnterKey(); + const blank = terminal.handleEnterKey(); + terminal.inputState.insertText('world"'); + await Promise.all([first, blank, terminal.handleEnterKey()]); + assert.equal(terminal.inputState.text, '1\nx <- "hello\n\nworld"'); +}); + +test("a top-level session snapshot releases a native check without a separate prompt event", async t => { + const { terminal, commands } = createTerminal(); + const checked = nativeChecks(t, terminal); + terminal.runtimeBackend.hasCapability = () => true; + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "session-state", busy: true, wait: { kind: "none" } }); + terminal.handleInput("42\n"); + await settle(); + assert.deepEqual(checked, []); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "session-state", busy: false, wait: { kind: "top-level", prompt: "main" } }); + await terminal.programmaticSubmissionQueue; + assert.deepEqual(checked, ["42"]); + assert.deepEqual(commands.map(c => c.code), ["42"]); +}); + +test("R exit cancels a waiting native check without sending it", async t => { + const { terminal, commands } = createTerminal(); + const checked = nativeChecks(t, terminal); + terminal.mode = "executing"; + terminal.promptReady = false; + terminal.handleInput("42\n"); + const batch = terminal.programmaticSubmissionQueue; + await settle(); + runtime.handleRuntimeExit(terminal.runtimeHostAdapter, 0); + await batch; + assert.deepEqual(checked, []); + assert.deepEqual(commands, []); + assert.equal(terminal.mode, "closed"); +}); + +test("a rejected complete Enter restores its original source without a continuation newline", async () => { + const { terminal } = createTerminal(); + terminal.runtimeBackend.sendSessionCommand = () => false; + terminal.inputState.insertText('paste("ab")'); + terminal.inputState.cursorPosition = 8; + await terminal.handleEnterKey(); + assert.equal(terminal.inputState.text, 'paste("ab")'); +}); + +test("a later delivery failure preserves the cursor inside a queued continuation", async () => { + const { terminal } = createTerminal(); + terminal.inputState.insertText("1\n2\nc(1,2"); + terminal.inputState.cursorPosition = 8; + await terminal.handleEnterKey(); + terminal.runtimeBackend.sendSessionCommand = () => false; + nextPrompt(terminal); + assert.equal(terminal.inputState.text, "2\nc(1,\n2"); + assert.equal(terminal.inputState.cursorPosition, 7); +}); + +test("recovery keeps a saved cursor relative to the unparsed remainder", async t => { + const { terminal } = createTerminal(); + nativeChecks(t, terminal); + terminal.inputState.insertText("1\nc(1,2"); + terminal.inputState.cursorPosition = 6; + await terminal.handleEnterKey(); + terminal.handleInput("3)\n4 +\n"); + const pending = terminal.programmaticSubmissionQueue; + await settle(); + terminal.runtimeBackend.sendSessionCommand = () => false; + nextPrompt(terminal); + await pending; + assert.equal(terminal.inputState.text, "c(1,\n3)\n4 +\n2"); + assert.equal(terminal.inputState.cursorPosition, terminal.inputState.text.length - 1); +});