From b4ddd1607473aa54976d8a15de7630f65441eef8 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:14:14 +0900 Subject: [PATCH 1/6] fix: preserve accepted R submissions across parsing and interruption - Commit Enter and paste snapshots synchronously while leaving typing editable. - Reserve FIFO submission positions and stream complete expressions to R. - Retain incomplete source tails and cancel queued work on interruption. - Restore unsent work on rejected backend delivery. - Add delayed-parser, ordering, cancellation, and recovery regression tests. --- src/Terminal/rTerminal.ts | 173 +++++++++-------------- src/Terminal/rTerminal/runtime.ts | 166 +++++++++++++--------- tests/unit/submission.test.cjs | 222 ++++++++++++++++++++++++++++++ 3 files changed, 386 insertions(+), 175 deletions(-) create mode 100644 tests/unit/submission.test.cjs diff --git a/src/Terminal/rTerminal.ts b/src/Terminal/rTerminal.ts index 4a77001..477fa93 100644 --- a/src/Terminal/rTerminal.ts +++ b/src/Terminal/rTerminal.ts @@ -44,6 +44,7 @@ import { createRuntimeBackend, closeRuntimeVscodeRIntegration, enqueueRuntimeSubmission, + reserveRuntimeSubmission, 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,35 @@ 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; + if (!submission.trim()) return; 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(); - } + const blocks = await this.enqueueRSubmission(submission, false, slot); + if (generation === this.submissionGeneration) this.recordSubmissionHistory(blocks); } private handleNestedReplyPaste(content: string): boolean { @@ -1396,6 +1370,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 +1535,33 @@ 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); + const generation = this.submissionGeneration; + this.inputState.reset(); + this.lang.updateInput(""); + if (this.promptVisible) { + this.clearInputRender(); + this.promptVisible = false; + } + const blocks = await this.enqueueRSubmission(submitted, false, slot); + if (generation === this.submissionGeneration) this.recordSubmissionHistory(blocks); } private handleReplyInputAction(action: KeyAction): void { @@ -1688,23 +1650,10 @@ 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; + // Enter consumes only the current editable snapshot. Subsequent typing is independent. + const indent = this.inputState.cursorCol === 0 + ? "" : " ".repeat(this.inputState.calculateNewLineIndent(this.tabSize)); if (!fullText.trim()) { this.inputState.reset(); if (this.promptVisible) { @@ -1727,10 +1676,10 @@ export class RTerminal implements vscode.Pseudoterminal { } const sanitized = stripBracketedPasteMarkers(fullText).trimEnd(); + const submitted = sanitized + "\n" + indent; + const slot = reserveRuntimeSubmission(this.runtimeHost(), submitted); + const generation = this.submissionGeneration; 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 +1691,8 @@ 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); + const blocks = await this.enqueueRSubmission(submitted, false, slot); + if (generation === this.submissionGeneration) this.recordSubmissionHistory(blocks); } private reverseSearch(): void { @@ -2201,9 +2142,10 @@ 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); } private recordSubmissionHistory(blocks: string[]): void { @@ -2214,6 +2156,15 @@ export class RTerminal implements vscode.Pseudoterminal { } } + private cancelPendingSubmissions(): void { + this.submissionGeneration++; + this.submissionQueue = []; + this.programmaticSubmissionQueue = Promise.resolve(); + this.pendingProgrammaticInput = ""; + this.clearPendingInputFlushTimer(); + this.clearPendingConsoleInput(); + } + private startNextSubmission(): void { startNextRuntimeSubmission(this.runtimeHost()); } diff --git a/src/Terminal/rTerminal/runtime.ts b/src/Terminal/rTerminal/runtime.ts index 911aec3..3d36d53 100644 --- a/src/Terminal/rTerminal/runtime.ts +++ b/src/Terminal/rTerminal/runtime.ts @@ -58,6 +58,7 @@ type Dimensions = { export type TerminalMode = "starting" | "ready" | "executing" | "reply" | "closed"; export type Submission = { + kind: "execute" | "parse" | "edit"; code: string; initialPromptKind: "main" | "cont"; }; @@ -85,6 +86,8 @@ export type RuntimeHost = { sessionHostConnected: boolean; activeSubmission: Submission | null; submissionQueue: Submission[]; + submissionGeneration: number; + cancelPendingSubmissions(): void; historyBrowsing: boolean; historyCollapsed: boolean; inputState: InputState; @@ -1057,16 +1060,35 @@ 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 unsent = [task, ...host.submissionQueue].map((item) => item.code).filter(Boolean).join("\n").replace(/\n+$/, ""); + host.cancelPendingSubmissions(); + host.activeSubmission = null; + host.submissionPending = false; + host.awaitingExecutionStart = false; + host.mode = "ready"; + const draft = host.inputState.text; + host.inputState.text = unsent + (draft ? "\n" + draft : ""); + host.inputState.cursorToEnd(); + 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.clearPromptRenderTimer(); - host.runtimeBackend?.sendSessionCommand(host.rProcess, { - type: "submit", - code: task.code, - }); } export function finishRuntimeSubmission(host: RuntimeHost): void { @@ -1077,44 +1099,87 @@ 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 (host.mode !== "ready" && !(host.mode === "executing" && host.activeSubmission === null)) return; + if (host.activeSubmission) 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.code + (draft && !/\n[ \t]*$/.test(next.code) ? "\n" : ""); + host.inputState.text = prefix + draft; + host.inputState.cursorPosition = prefix.length + cursor; + host.mode = "ready"; + host.renderInput(); return; } - host.activeSubmission = task; - startRuntimeSubmission(host, task); + host.activeSubmission = next; + startRuntimeSubmission(host, next); +} + +/** Reserve the accepted batch before asynchronous parsing begins. */ +export function reserveRuntimeSubmission(host: RuntimeHost, code: string): Submission { + const slot: Submission = { kind: "parse", code, initialPromptKind: host.promptKind }; + host.submissionQueue.push(slot); + return slot; } export async function enqueueRuntimeSubmission( host: RuntimeHost, code: string, - skipSplit: boolean = false -) : Promise { - const blocks = skipSplit - ? [normalizeSubmissionBlock(code)] - : await splitSubmissionBlocks(host, code); - - if (blocks.length === 0) { - host.submissionPending = false; - host.awaitingExecutionStart = false; - return []; - } - - for (const block of blocks) { - host.submissionQueue.push({ - code: block, - initialPromptKind: host.promptKind, + skipSplit: boolean = false, + slot: Submission = reserveRuntimeSubmission(host, code) +): Promise { + const generation = host.submissionGeneration; + const blocks: string[] = []; + const publish = (block: string, kind: Submission["kind"]): 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, }); - } + if (kind === "execute") blocks.push(block); + startNextRuntimeSubmission(host); + }; + try { + if (skipSplit) { + 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 offset = 0; + for (const line of lines) { + 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); + 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); + publish(block, "execute"); + pending = ""; + } + } + if (normalizeSubmissionBlock(pending).trim()) { + slot.code = ""; + publish(pending, "edit"); + } + } + } finally { + const index = host.submissionQueue.indexOf(slot); + if (index >= 0) { + host.submissionQueue.splice(index, 1); + startNextRuntimeSubmission(host); + } + } void host.lang.refreshCompletionContextDocument(host.inputState.text); - startNextRuntimeSubmission(host); return blocks; } @@ -1124,37 +1189,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 +1279,7 @@ export function interruptRuntime(host: RuntimeHost): void { host.promptVisible = false; host.pendingPromptToken = false; host.clearPendingConsoleInput(); + host.cancelPendingSubmissions(); return; } @@ -1261,6 +1296,7 @@ export function interruptRuntime(host: RuntimeHost): void { host.replyPromptText = ""; host.mode = "executing"; host.clearPendingConsoleInput(); + host.cancelPendingSubmissions(); return; } @@ -1269,6 +1305,7 @@ export function interruptRuntime(host: RuntimeHost): void { host.inputState.reset(); host.renderInput(); host.clearPendingConsoleInput(); + host.cancelPendingSubmissions(); return; } @@ -1280,6 +1317,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 { @@ -1302,7 +1340,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( diff --git a/tests/unit/submission.test.cjs b/tests/unit/submission.test.cjs new file mode 100644 index 0000000..943b262 --- /dev/null +++ b/tests/unit/submission.test.cjs @@ -0,0 +1,222 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const loadSource = require("../helpers/load-source.cjs"); +const { RTerminal } = loadSource("src/Terminal/rTerminal.ts", { + "./rTerminal/lang": {}, + vscode: { EventEmitter: class {} }, +}); +const runtime = loadSource("src/Terminal/rTerminal/runtime.ts", { vscode: {} }); +const { InputState } = loadSource("src/Terminal/inputState.ts"); +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), { + 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, + inputState: new InputState(), keyProcessor: new KeyProcessor(), + dimensions: { columns: 80, rows: 24 }, lastWriteEndedWithNewline: true, + lang: { refreshCompletionContextDocument: async () => {}, updateInput() {} }, + rHistory: { resetIndex() {}, push(code) { history.push(code); } }, + renderer: new Renderer(() => {}), + syntax: { setSource() {}, highlightLines(lines) { return lines; } }, + runtimeBackend: { + isAlive: () => true, + canUseSessionCommands: () => true, + sendSessionCommand(session, command) { commands.push(command); return true; }, + }, + writeEmitter: { fire(text) { output.push(text); } }, + ensureReadyPromptVisibleForInput() {}, + 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"); +} + +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"); + 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); +}); From b3331c6751babbda12d7012ebfec77ce00db97f8 Mon Sep 17 00:00:00 2001 From: Fred Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:39:17 +0000 Subject: [PATCH 2/6] fix: preserve continuations across ordered submission parsing Serialize completeness checks in reservation order and transfer incomplete input to the next accepted submission, including tails waiting behind execution. Keep the existing parser policy and release parsing state on cancellation without speculative checks or prefix caches. Fix native parse timeout rejection so busy R sessions reach the existing local fallback instead of leaving the queue waiting forever. Add unit regressions and real-R frontend integration coverage to the cross-platform CI runtime suite. Validated on Linux: 244 unit tests, both real-R integration tests, and the production type-check/bundle. --- .github/workflows/ci.yml | 2 +- docs/IMPLEMENTATION.md | 14 ++ package.json | 2 +- src/Runtime/runtimeBackend.ts | 3 +- src/Terminal/rTerminal.ts | 2 +- src/Terminal/rTerminal/runtime.ts | 57 ++++++- tests/helpers/submission-terminal.cjs | 57 +++++++ tests/submission-runtime.test.cjs | 155 +++++++++++++++++++ tests/unit/backend-parse.test.cjs | 36 +++++ tests/unit/submission.test.cjs | 209 ++++++++++++++++++++------ 10 files changed, 481 insertions(+), 56 deletions(-) create mode 100644 tests/helpers/submission-terminal.cjs create mode 100644 tests/submission-runtime.test.cjs create mode 100644 tests/unit/backend-parse.test.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31f6bc1..4dc1bbd 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/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 25ed94e..ce27902 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -816,6 +816,20 @@ 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. + +This ordering retains the existing TypeScript heuristic and optional native R +parse-status check. Native checks time out after 150 ms and reject so the local +fallback can run. 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. + ### 4.5 Attaching A Detached Console Attaching a detached console follows this implementation path: diff --git a/package.json b/package.json index 51a83a3..35c60e0 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/Runtime/runtimeBackend.ts b/src/Runtime/runtimeBackend.ts index e18fc62..e251612 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, { diff --git a/src/Terminal/rTerminal.ts b/src/Terminal/rTerminal.ts index 477fa93..da4a25d 100644 --- a/src/Terminal/rTerminal.ts +++ b/src/Terminal/rTerminal.ts @@ -979,7 +979,7 @@ export class RTerminal implements vscode.Pseudoterminal { .replace(/\r/g, "\n"); // Preserve an incomplete source tail, including its trailing continuation newline. const submission = normalized; - if (!submission.trim()) return; + // Even an empty reserved batch must release its parsing position. this.historyBrowsing = false; this.savedHistoryInput = undefined; diff --git a/src/Terminal/rTerminal/runtime.ts b/src/Terminal/rTerminal/runtime.ts index 3d36d53..6cd7797 100644 --- a/src/Terminal/rTerminal/runtime.ts +++ b/src/Terminal/rTerminal/runtime.ts @@ -61,9 +61,16 @@ export type Submission = { kind: "execute" | "parse" | "edit"; code: string; initialPromptKind: "main" | "cont"; + parsing?: { + generation: number; + previous?: Promise; + done?: Promise; + finish?(): void; + }; }; const pendingRuntimeRewrites = new WeakMap(); +const latestParsing = new WeakMap>(); export type RuntimeHost = { options: RTerminalOptions; @@ -1121,10 +1128,36 @@ export function startNextRuntimeSubmission(host: RuntimeHost): void { /** Reserve the accepted batch before asynchronous parsing begins. */ export function reserveRuntimeSubmission(host: RuntimeHost, code: string): Submission { const slot: Submission = { kind: "parse", code, initialPromptKind: host.promptKind }; + 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(); + slot.code = appendContinuation(tail.code, code); + slot.initialPromptKind = tail.initialPromptKind; + } + 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; } +function appendContinuation(prefix: string, code: string): string { + // Enter already includes its newline and optional indentation. Preserve both, + // including inside a multiline string; only supply a missing line boundary. + return prefix + (/\n[ \t]*$/.test(prefix) ? "" : "\n") + code; +} + export async function enqueueRuntimeSubmission( host: RuntimeHost, code: string, @@ -1144,7 +1177,14 @@ export async function enqueueRuntimeSubmission( }; try { - if (skipSplit) { + if (slot.parsing?.previous) { + await slot.parsing.previous; + slot.parsing.previous = undefined; + } + if (generation !== host.submissionGeneration || !host.submissionQueue.includes(slot)) return blocks; + const hasContinuation = code !== slot.code; + code = slot.code; + if (skipSplit && !hasContinuation) { slot.code = ""; const block = normalizeSubmissionBlock(code); if (block.trim()) publish(block, "execute"); @@ -1169,7 +1209,17 @@ export async function enqueueRuntimeSubmission( } if (normalizeSubmissionBlock(pending).trim()) { slot.code = ""; - publish(pending, "edit"); + 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. + next.code = appendContinuation(pending, next.code); + next.initialPromptKind = slot.initialPromptKind; + } else { + // With no accepted successor, the editable buffer owns continuation. + // Subsequent Enter/paste snapshots already include that buffer. + publish(pending, "edit"); + } } } } finally { @@ -1178,6 +1228,9 @@ export async function enqueueRuntimeSubmission( host.submissionQueue.splice(index, 1); startNextRuntimeSubmission(host); } + slot.parsing?.finish?.(); + if (latestParsing.get(host) === slot.parsing) latestParsing.delete(host); + slot.parsing = undefined; } void host.lang.refreshCompletionContextDocument(host.inputState.text); return blocks; diff --git a/tests/helpers/submission-terminal.cjs b/tests/helpers/submission-terminal.cjs new file mode 100644 index 0000000..ddc05fe --- /dev/null +++ b/tests/helpers/submission-terminal.cjs @@ -0,0 +1,57 @@ +const loadSource = require("./load-source.cjs"); +const { RTerminal } = loadSource("src/Terminal/rTerminal.ts", { + "./rTerminal/lang": {}, + vscode: { EventEmitter: class {} }, +}); +const runtime = loadSource("src/Terminal/rTerminal/runtime.ts", { vscode: {} }); +const parser = loadSource("src/Language/parser.ts"); +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), { + 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, + inputState: new InputState(), keyProcessor: new KeyProcessor(), + dimensions: { columns: 80, rows: 24 }, lastWriteEndedWithNewline: true, + lang: { refreshCompletionContextDocument: async () => {}, updateInput() {} }, + rHistory: { resetIndex() {}, push(code) { history.push(code); } }, + renderer: new Renderer(() => {}), + syntax: { setSource() {}, highlightLines(lines) { return lines; } }, + runtimeBackend: { + isAlive: () => true, + canUseSessionCommands: () => true, + sendSessionCommand(session, command) { commands.push(command); return true; }, + }, + writeEmitter: { fire(text) { output.push(text); } }, + ensureReadyPromptVisibleForInput() {}, + 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"); +} + +module.exports = { createTerminal, nextPrompt, deferred, runtime, parser }; diff --git a/tests/submission-runtime.test.cjs b/tests/submission-runtime.test.cjs new file mode 100644 index 0000000..8f23508 --- /dev/null +++ b/tests/submission-runtime.test.cjs @@ -0,0 +1,155 @@ +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, parser } = 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"); + const backend = new RustSidecarRuntimeBackend(binary); + const { terminal, history } = createTerminal(); + const sent = [], checked = [], nativeResults = []; + let output = "", failure, ready = false; + const send = backend.sendSessionCommand.bind(backend); + backend.sendSessionCommand = (session, command) => { + sent.push(command); + return send(session, command); + }; + const 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; + parser.setNativeParseCallback(code => { + checked.push(code); + const result = backend.requestParseStatus(session, code); + if (!result) { + failure = new Error("real native parser must be available"); + throw failure; + } + return result.then(status => { nativeResults.push({ code, status }); return status; }); + }); + t.after(async () => { + parser.setNativeParseCallback(null); + 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; + }; + backend.attach(session, { + 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" && event.wait.kind === "top-level") { + ready = true; + } + }, + }); + 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 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); + } + assert.doesNotMatch(output, /Error/); +}); diff --git a/tests/unit/backend-parse.test.cjs b/tests/unit/backend-parse.test.cjs new file mode 100644 index 0000000..21aa3d4 --- /dev/null +++ b/tests/unit/backend-parse.test.cjs @@ -0,0 +1,36 @@ +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); + + parser.setNativeParseCallback(code => backend.requestParseStatus(session, code)); + t.after(() => parser.setNativeParseCallback(null)); + const fallback = parser.isExpressionCompleteAsync("42"); + 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); +}); diff --git a/tests/unit/submission.test.cjs b/tests/unit/submission.test.cjs index 943b262..bc1a11b 100644 --- a/tests/unit/submission.test.cjs +++ b/tests/unit/submission.test.cjs @@ -1,60 +1,169 @@ const assert = require("node:assert/strict"); const { test } = require("node:test"); -const loadSource = require("../helpers/load-source.cjs"); -const { RTerminal } = loadSource("src/Terminal/rTerminal.ts", { - "./rTerminal/lang": {}, - vscode: { EventEmitter: class {} }, -}); -const runtime = loadSource("src/Terminal/rTerminal/runtime.ts", { vscode: {} }); -const { InputState } = loadSource("src/Terminal/inputState.ts"); -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) }; -} +const { createTerminal, nextPrompt, deferred, parser } = 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)); -function createTerminal() { - const commands = []; - const history = []; - const output = []; - const terminal = Object.assign(Object.create(RTerminal.prototype), { - 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, - inputState: new InputState(), keyProcessor: new KeyProcessor(), - dimensions: { columns: 80, rows: 24 }, lastWriteEndedWithNewline: true, - lang: { refreshCompletionContextDocument: async () => {}, updateInput() {} }, - rHistory: { resetIndex() {}, push(code) { history.push(code); } }, - renderer: new Renderer(() => {}), - syntax: { setSource() {}, highlightLines(lines) { return lines; } }, - runtimeBackend: { - isAlive: () => true, - canUseSessionCommands: () => true, - sendSessionCommand(session, command) { commands.push(command); return true; }, - }, - writeEmitter: { fire(text) { output.push(text); } }, - ensureReadyPromptVisibleForInput() {}, - renderInput() { this.promptVisible = true; }, - clearInputRender() { this.promptVisible = false; }, - clearPromptRenderTimer() {}, clearReplyPromptRenderTimer() {}, - schedulePrompt() {}, scheduleReplyPrompt() {}, - getProgrammaticSubmissionFlushDelay() { return 1; }, +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, []); }); - 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"); + +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 = []; + parser.setNativeParseCallback(code => { + checked.push(code); + return code === "if" ? first.promise : Promise.resolve(1); + }); + t.after(() => parser.setNativeParseCallback(null)); + 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"); + 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(); From 96c7a1c7491aa92e46cff119b54301cf63931743 Mon Sep 17 00:00:00 2001 From: Fred Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:38:04 +0000 Subject: [PATCH 3/6] fix: preserve continuation edits and submitted history --- CHANGELOG.md | 4 + docs/IMPLEMENTATION.md | 4 + src/Terminal/rTerminal.ts | 29 ++++--- src/Terminal/rTerminal/runtime.ts | 67 ++++++++++++---- tests/submission-runtime.test.cjs | 42 ++++++++++ tests/unit/submission.test.cjs | 128 ++++++++++++++++++++++++++++++ 6 files changed, 243 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a171949..a51808d 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 ce27902..1dcc311 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -823,6 +823,10 @@ 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 time out after 150 ms and reject so the local diff --git a/src/Terminal/rTerminal.ts b/src/Terminal/rTerminal.ts index da4a25d..8ed1dcd 100644 --- a/src/Terminal/rTerminal.ts +++ b/src/Terminal/rTerminal.ts @@ -986,8 +986,7 @@ export class RTerminal implements vscode.Pseudoterminal { this.historyCollapsed = true; this.escPendingClear = false; this.rHistory.resetIndex(); - const blocks = await this.enqueueRSubmission(submission, false, slot); - if (generation === this.submissionGeneration) this.recordSubmissionHistory(blocks); + await this.enqueueRSubmission(submission, false, slot); } private handleNestedReplyPaste(content: string): boolean { @@ -1034,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; } @@ -1553,15 +1550,13 @@ export class RTerminal implements vscode.Pseudoterminal { const submitted = this.inputState.text + trimmed; const slot = reserveRuntimeSubmission(this.runtimeHost(), submitted); - const generation = this.submissionGeneration; this.inputState.reset(); this.lang.updateInput(""); if (this.promptVisible) { this.clearInputRender(); this.promptVisible = false; } - const blocks = await this.enqueueRSubmission(submitted, false, slot); - if (generation === this.submissionGeneration) this.recordSubmissionHistory(blocks); + await this.enqueueRSubmission(submitted, false, slot); } private handleReplyInputAction(action: KeyAction): void { @@ -1675,10 +1670,14 @@ export class RTerminal implements vscode.Pseudoterminal { return; } - const sanitized = stripBracketedPasteMarkers(fullText).trimEnd(); - const submitted = sanitized + "\n" + indent; - const slot = reserveRuntimeSubmission(this.runtimeHost(), submitted); - const generation = this.submissionGeneration; + 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) { this.clearInputRender(); this.promptVisible = false; @@ -1691,8 +1690,7 @@ export class RTerminal implements vscode.Pseudoterminal { this.rHistory.resetIndex(); this.lang.updateInput(""); - const blocks = await this.enqueueRSubmission(submitted, false, slot); - if (generation === this.submissionGeneration) this.recordSubmissionHistory(blocks); + await this.enqueueRSubmission(submitted, false, slot); } private reverseSearch(): void { @@ -2145,7 +2143,8 @@ export class RTerminal implements vscode.Pseudoterminal { skipSplit: boolean = false, slot?: Submission ): Promise { - return await enqueueRuntimeSubmission(this.runtimeHost(), code, skipSplit, slot); + return await enqueueRuntimeSubmission(this.runtimeHost(), code, skipSplit, slot, + (block) => this.recordSubmissionHistory([block])); } private recordSubmissionHistory(blocks: string[]): void { diff --git a/src/Terminal/rTerminal/runtime.ts b/src/Terminal/rTerminal/runtime.ts index 6cd7797..f739367 100644 --- a/src/Terminal/rTerminal/runtime.ts +++ b/src/Terminal/rTerminal/runtime.ts @@ -61,6 +61,8 @@ export type Submission = { kind: "execute" | "parse" | "edit"; code: string; initialPromptKind: "main" | "cont"; + continuationEdit?: { cursor: number; text: string }; + editCursor?: number; parsing?: { generation: number; previous?: Promise; @@ -1114,8 +1116,11 @@ export function startNextRuntimeSubmission(host: RuntimeHost): void { if (next.kind === "edit") { const draft = host.inputState.text; const cursor = host.inputState.cursorPosition; - const prefix = next.code + (draft && !/\n[ \t]*$/.test(next.code) ? "\n" : ""); - host.inputState.text = prefix + draft; + 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(); @@ -1126,15 +1131,18 @@ export function startNextRuntimeSubmission(host: RuntimeHost): void { } /** Reserve the accepted batch before asynchronous parsing begins. */ -export function reserveRuntimeSubmission(host: RuntimeHost, code: string): Submission { - const slot: Submission = { kind: "parse", code, initialPromptKind: host.promptKind }; +export function reserveRuntimeSubmission( + host: RuntimeHost, + code: string, + 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(); - slot.code = appendContinuation(tail.code, code); - slot.initialPromptKind = tail.initialPromptKind; + continueSubmission(slot, tail.code, tail.initialPromptKind, tail.editCursor); } const previous = latestParsing.get(host); let previousDone: Promise | undefined; @@ -1152,28 +1160,46 @@ export function reserveRuntimeSubmission(host: RuntimeHost, code: string): Submi return slot; } -function appendContinuation(prefix: string, code: string): string { - // Enter already includes its newline and optional indentation. Preserve both, - // including inside a multiline string; only supply a missing line boundary. - return prefix + (/\n[ \t]*$/.test(prefix) ? "" : "\n") + code; +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; + slot.code = prefix + slot.code + suffix; + if (slot.continuationEdit) slot.continuationEdit.cursor += prefix.length; + slot.initialPromptKind = promptKind; } export async function enqueueRuntimeSubmission( host: RuntimeHost, code: string, skipSplit: boolean = false, - slot: Submission = reserveRuntimeSubmission(host, code) + slot: Submission = reserveRuntimeSubmission(host, code), + onAcceptedBlock?: (block: string) => void ): Promise { const generation = host.submissionGeneration; const blocks: string[] = []; - const publish = (block: string, kind: Submission["kind"]): void => { + 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, + 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 { @@ -1192,8 +1218,10 @@ export async function enqueueRuntimeSubmission( 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); @@ -1208,17 +1236,24 @@ export async function enqueueRuntimeSubmission( } } 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. - next.code = appendContinuation(pending, next.code); - next.initialPromptKind = slot.initialPromptKind; + 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"); + publish(pending, "edit", editCursor); } } } diff --git a/tests/submission-runtime.test.cjs b/tests/submission-runtime.test.cjs index 8f23508..639164f 100644 --- a/tests/submission-runtime.test.cjs +++ b/tests/submission-runtime.test.cjs @@ -151,5 +151,47 @@ test("frontend continuations execute in order with real R parsing and evaluation 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 <- 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); assert.doesNotMatch(output, /Error/); }); diff --git a/tests/unit/submission.test.cjs b/tests/unit/submission.test.cjs index bc1a11b..3720ffe 100644 --- a/tests/unit/submission.test.cjs +++ b/tests/unit/submission.test.cjs @@ -6,6 +6,134 @@ const { createTerminal, nextPrompt, deferred, parser } = require("../helpers/sub // exposed promise. Tests control parser completion explicitly, never by sleeps. const settle = () => new Promise(resolve => setImmediate(resolve)); +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(); From 9e56ff48b9ef2467348f40c160797172a1df33a4 Mon Sep 17 00:00:00 2001 From: Fred Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:18:57 +0000 Subject: [PATCH 4/6] fix: restore idle prompts and preserve queued blank input --- src/Terminal/rTerminal.ts | 4 +- src/Terminal/rTerminal/runtime.ts | 8 +++ tests/helpers/submission-terminal.cjs | 37 +++++++++++++- tests/submission-runtime.test.cjs | 9 ++++ tests/unit/submission.test.cjs | 74 ++++++++++++++++++++++++++- 5 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/Terminal/rTerminal.ts b/src/Terminal/rTerminal.ts index 8ed1dcd..a417192 100644 --- a/src/Terminal/rTerminal.ts +++ b/src/Terminal/rTerminal.ts @@ -1649,7 +1649,9 @@ export class RTerminal implements vscode.Pseudoterminal { // Enter consumes only the current editable snapshot. Subsequent typing is independent. const indent = this.inputState.cursorCol === 0 ? "" : " ".repeat(this.inputState.calculateNewLineIndent(this.tabSize)); - if (!fullText.trim()) { + // 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(); diff --git a/src/Terminal/rTerminal/runtime.ts b/src/Terminal/rTerminal/runtime.ts index f739367..eab0638 100644 --- a/src/Terminal/rTerminal/runtime.ts +++ b/src/Terminal/rTerminal/runtime.ts @@ -1262,6 +1262,14 @@ export async function enqueueRuntimeSubmission( 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); diff --git a/tests/helpers/submission-terminal.cjs b/tests/helpers/submission-terminal.cjs index ddc05fe..e32e8de 100644 --- a/tests/helpers/submission-terminal.cjs +++ b/tests/helpers/submission-terminal.cjs @@ -1,7 +1,10 @@ const loadSource = require("./load-source.cjs"); const { RTerminal } = loadSource("src/Terminal/rTerminal.ts", { "./rTerminal/lang": {}, - vscode: { EventEmitter: class {} }, + vscode: { + EventEmitter: class {}, window: {}, + workspace: { getConfiguration: () => ({ get: (_key, fallback) => fallback }) }, + }, }); const runtime = loadSource("src/Terminal/rTerminal/runtime.ts", { vscode: {} }); const parser = loadSource("src/Language/parser.ts"); @@ -54,4 +57,34 @@ function nextPrompt(terminal) { runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); } -module.exports = { createTerminal, nextPrompt, deferred, runtime, parser }; +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 index 639164f..8ed9391 100644 --- a/tests/submission-runtime.test.cjs +++ b/tests/submission-runtime.test.cjs @@ -167,6 +167,15 @@ test("frontend continuations execute in order with real R parsing and evaluation 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(); diff --git a/tests/unit/submission.test.cjs b/tests/unit/submission.test.cjs index 3720ffe..acd1728 100644 --- a/tests/unit/submission.test.cjs +++ b/tests/unit/submission.test.cjs @@ -1,11 +1,83 @@ const assert = require("node:assert/strict"); const { test } = require("node:test"); -const { createTerminal, nextPrompt, deferred, parser } = require("../helpers/submission-terminal.cjs"); +const { createTerminal, createTerminalWithScreen, nextPrompt, deferred, parser } = 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 () => { From 78ba69ad85d8eb5442f699b70ae044c48831d435 Mon Sep 17 00:00:00 2001 From: Fred Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:41:19 +0000 Subject: [PATCH 5/6] fix: gate R work on readiness and preserve recovery source --- docs/IMPLEMENTATION.md | 11 +- src/Terminal/rTerminal.ts | 4 + src/Terminal/rTerminal/runtime.ts | 86 ++++++++-- tests/helpers/submission-terminal.cjs | 10 +- tests/submission-runtime.test.cjs | 127 ++++++++++++--- tests/unit/history-worker.test.cjs | 25 +-- tests/unit/submission.test.cjs | 225 +++++++++++++++++++++++++- 7 files changed, 426 insertions(+), 62 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 1dcc311..8ee91ae 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -829,10 +829,15 @@ 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 time out after 150 ms and reject so the local -fallback can run. Cancellation invalidates old reservations by generation; fresh +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. +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. +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 diff --git a/src/Terminal/rTerminal.ts b/src/Terminal/rTerminal.ts index a417192..99c382a 100644 --- a/src/Terminal/rTerminal.ts +++ b/src/Terminal/rTerminal.ts @@ -45,6 +45,7 @@ import { closeRuntimeVscodeRIntegration, enqueueRuntimeSubmission, reserveRuntimeSubmission, + wakeRuntimeParser, finishRuntimeSubmission, getCachedRuntimeWorkspaceData, getRuntimeTerminalName, @@ -2164,6 +2165,7 @@ export class RTerminal implements vscode.Pseudoterminal { this.pendingProgrammaticInput = ""; this.clearPendingInputFlushTimer(); this.clearPendingConsoleInput(); + wakeRuntimeParser(this.runtimeHost()); } private startNextSubmission(): void { @@ -2876,6 +2878,7 @@ export class RTerminal implements vscode.Pseudoterminal { if (this.extensionResourcesDisposePromise) { return this.extensionResourcesDisposePromise; } + this.cancelPendingSubmissions(); this.rHistory.dispose(); disposeRuntimeVscodeRIntegrationUi(this.runtimeHost()); this.syntax.dispose(); @@ -2934,6 +2937,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 eab0638..21d87fa 100644 --- a/src/Terminal/rTerminal/runtime.ts +++ b/src/Terminal/rTerminal/runtime.ts @@ -65,6 +65,7 @@ export type Submission = { editCursor?: number; parsing?: { generation: number; + sourceOffset?: number; previous?: Promise; done?: Promise; finish?(): void; @@ -73,6 +74,39 @@ export type Submission = { 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; + for (;;) { + if (generation !== host.submissionGeneration || process !== host.rProcess || 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; + } + return await (host.runtimeBackend?.requestParseStatus(process, code) ?? 1); +} export type RuntimeHost = { options: RTerminalOptions; @@ -236,7 +270,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; @@ -365,6 +399,7 @@ export function handleRuntimeControl( return; case "busy": if (event.value) { + host.promptReady = false; if (host.awaitingExecutionStart && host.activeSubmission) { host.awaitingExecutionStart = false; } @@ -463,6 +498,8 @@ function applyRuntimeSessionState( if (event.wait.prompt === "main") { vscodeRIntegration(host).handleMainPrompt(); } + wakeRuntimeParser(host); + host.startNextSubmission(); return; case "nested": host.promptReady = false; @@ -486,6 +523,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) { @@ -530,6 +570,8 @@ export function handleBackendPrompt( host.mode = "ready"; } + wakeRuntimeParser(host); + if (host.submissionPending) { host.pendingPromptToken = false; if (kind === "main" && host.mode === "ready" && host.activeSubmission === null) { @@ -1074,15 +1116,15 @@ export function startRuntimeSubmission(host: RuntimeHost, task: Submission): voi code: task.code, }) ?? false; if (!sent) { - const unsent = [task, ...host.submissionQueue].map((item) => item.code).filter(Boolean).join("\n").replace(/\n+$/, ""); + 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"; - const draft = host.inputState.text; - host.inputState.text = unsent + (draft ? "\n" + draft : ""); - host.inputState.cursorToEnd(); + host.inputState.text = restored.code; + host.inputState.cursorPosition = restored.cursor; if (host.promptVisible) { host.clearInputRender(); host.promptVisible = false; @@ -1097,6 +1139,7 @@ export function startRuntimeSubmission(host: RuntimeHost, task: Submission): voi host.awaitingExecutionStart = true; host.mode = "executing"; writeRuntimeSubmissionEcho(host, task); + host.promptReady = false; host.clearPromptRenderTimer(); } @@ -1108,8 +1151,7 @@ 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; + if (!runtimeIsReady(host)) return; const next = host.submissionQueue[0]; if (!next || next.kind === "parse") return; host.submissionQueue.shift(); @@ -1174,11 +1216,31 @@ function continueSubmission( : 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 }; +} + export async function enqueueRuntimeSubmission( host: RuntimeHost, code: string, @@ -1231,6 +1293,8 @@ export async function enqueueRuntimeSubmission( 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 = ""; } @@ -1456,11 +1520,5 @@ function updateNativeParseCallback(host: RuntimeHost): void { return; } - setNativeParseCallback(async (code: string) => { - const request = host.runtimeBackend?.requestParseStatus(host.rProcess, code); - if (!request) { - return 1; - } - return await request; - }); + setNativeParseCallback((code: string) => requestRuntimeParseStatus(host, code)); } diff --git a/tests/helpers/submission-terminal.cjs b/tests/helpers/submission-terminal.cjs index e32e8de..a7055d4 100644 --- a/tests/helpers/submission-terminal.cjs +++ b/tests/helpers/submission-terminal.cjs @@ -1,13 +1,14 @@ 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 runtime = loadSource("src/Terminal/rTerminal/runtime.ts", { vscode: {} }); -const parser = loadSource("src/Language/parser.ts"); const { InputState } = loadSource("src/Terminal/inputState.ts", { "../Language/parser": parser }); const { Renderer } = loadSource("src/Terminal/renderer.ts"); const { KeyProcessor } = loadSource("src/Terminal/keyProcessor.ts"); @@ -23,6 +24,7 @@ function createTerminal() { 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, @@ -30,9 +32,10 @@ function createTerminal() { 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() {} }, + lang: { refreshCompletionContextDocument: async () => {}, updateInput() {}, clearSessionState() {} }, rHistory: { resetIndex() {}, push(code) { history.push(code); } }, renderer: new Renderer(() => {}), syntax: { setSource() {}, highlightLines(lines) { return lines; } }, @@ -43,6 +46,7 @@ function createTerminal() { }, writeEmitter: { fire(text) { output.push(text); } }, ensureReadyPromptVisibleForInput() {}, + captureVisibleInputForReplay() {}, renderInput() { this.promptVisible = true; }, clearInputRender() { this.promptVisible = false; }, clearPromptRenderTimer() {}, clearReplyPromptRenderTimer() {}, diff --git a/tests/submission-runtime.test.cjs b/tests/submission-runtime.test.cjs index 8ed9391..e67c097 100644 --- a/tests/submission-runtime.test.cjs +++ b/tests/submission-runtime.test.cjs @@ -32,30 +32,39 @@ test("frontend continuations execute in order with real R parsing and evaluation assert.ok(options); const binary = getBundledRustSidecarPath(root); assert.ok(fs.existsSync(binary), "Build and stage the sidecar before running test:runtime"); - const backend = new RustSidecarRuntimeBackend(binary); - const { terminal, history } = createTerminal(); - const sent = [], checked = [], nativeResults = []; + let backend = new RustSidecarRuntimeBackend(binary); + let { terminal, history } = createTerminal(); + const sent = [], checked = [], nativeResults = [], nativeFailures = [], sessionStates = []; let output = "", failure, ready = false; - const send = backend.sendSessionCommand.bind(backend); - backend.sendSessionCommand = (session, command) => { - sent.push(command); - return send(session, command); + 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; + }); + }; }; - const session = backend.start([rPath, ...options.rArgs], { + 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; - parser.setNativeParseCallback(code => { - checked.push(code); - const result = backend.requestParseStatus(session, code); - if (!result) { - failure = new Error("real native parser must be available"); - throw failure; - } - return result.then(status => { nativeResults.push({ code, status }); return status; }); - }); + parser.setNativeParseCallback(code => runtime.requestRuntimeParseStatus(terminal.runtimeHostAdapter, code)); t.after(async () => { parser.setNativeParseCallback(null); terminal.cancelPendingSubmissions(); @@ -82,7 +91,7 @@ test("frontend continuations execute in order with real R parsing and evaluation // Match handleRuntimeOutput's execution acknowledgement; rendering is stubbed. if (terminal.activeSubmission) terminal.awaitingExecutionStart = false; }; - backend.attach(session, { + const handlers = { onStdout: receiveOutput, onStderr: receiveOutput, onError: error => { failure = error; }, onControl: event => { @@ -91,11 +100,14 @@ test("frontend continuations execute in order with real R parsing and evaluation ready = true; } else if (["busy", "input-request", "input-end"].includes(event.type)) { runtime.handleRuntimeControl(terminal.runtimeHostAdapter, event); - } else if (event.type === "session-state" && event.wait.kind === "top-level") { - ready = true; + } 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]) { @@ -111,6 +123,28 @@ test("frontend continuations execute in order with real R parsing and evaluation 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"); @@ -202,5 +236,56 @@ test("frontend continuations execute in order with real R parsing and evaluation // 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/); }); diff --git a/tests/unit/history-worker.test.cjs b/tests/unit/history-worker.test.cjs index 5cfea1d..920522c 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/submission.test.cjs b/tests/unit/submission.test.cjs index acd1728..ff4b8d9 100644 --- a/tests/unit/submission.test.cjs +++ b/tests/unit/submission.test.cjs @@ -1,6 +1,6 @@ const assert = require("node:assert/strict"); const { test } = require("node:test"); -const { createTerminal, createTerminalWithScreen, nextPrompt, deferred, parser } = require("../helpers/submission-terminal.cjs"); +const { createTerminal, createTerminalWithScreen, nextPrompt, deferred, parser, 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. @@ -341,7 +341,7 @@ test("delivery failure restores combined continuation and later reservations onc terminal.handleInput("3\n"); await terminal.programmaticSubmissionQueue; assert.deepEqual(commands, []); - assert.equal(terminal.inputState.text, "x <- (\n2)\n3"); + assert.equal(terminal.inputState.text, "x <- (\n2)\n3\n"); assert.deepEqual(terminal.submissionQueue, []); assert.equal(terminal.mode, "ready"); }); @@ -504,7 +504,7 @@ test("failed backend delivery restores unsent blocks and does not strand the con 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"); + assert.equal(terminal.inputState.text, "a <- 1\nb <- 2\n"); assert.match(output.join(""), /submission was not sent/i); }); @@ -529,3 +529,222 @@ test("rejected interrupt does not discard queued source", async () => { 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); + }; + parser.setNativeParseCallback(code => runtime.requestRuntimeParseStatus(terminal.runtimeHostAdapter, code)); + t.after(() => parser.setNativeParseCallback(null)); + 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); +}); From 90a039e53b117f2b7503cf8c853b9c802850bc35 Mon Sep 17 00:00:00 2001 From: Fred Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:35:58 +0000 Subject: [PATCH 6/6] fix: isolate submission parsing and session lifecycle per console --- docs/IMPLEMENTATION.md | 4 + src/Language/parser.ts | 19 ++- src/Runtime/runtimeBackend.ts | 3 + src/Terminal/inputState.ts | 5 +- src/Terminal/rTerminal.ts | 2 - src/Terminal/rTerminal/runtime.ts | 53 ++++---- tests/helpers/submission-terminal.cjs | 1 + tests/submission-runtime.test.cjs | 122 +++++++++++++++++- tests/unit/backend-parse.test.cjs | 54 +++++++- tests/unit/parser.test.cjs | 15 +-- tests/unit/submission-sessions.test.cjs | 156 ++++++++++++++++++++++++ tests/unit/submission.test.cjs | 11 +- 12 files changed, 378 insertions(+), 67 deletions(-) create mode 100644 tests/unit/submission-sessions.test.cjs diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 8ee91ae..79fe0b2 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -832,6 +832,10 @@ 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 diff --git a/src/Language/parser.ts b/src/Language/parser.ts index a5b9dd9..7e58bd3 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 e251612..b76ff8a 100644 --- a/src/Runtime/runtimeBackend.ts +++ b/src/Runtime/runtimeBackend.ts @@ -497,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 0ee77fc..095db8b 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 99c382a..c3483e2 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, @@ -2887,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; } diff --git a/src/Terminal/rTerminal/runtime.ts b/src/Terminal/rTerminal/runtime.ts index 21d87fa..65fdd11 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, @@ -91,8 +91,10 @@ export function wakeRuntimeParser(host: RuntimeHost): void { 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 || host.mode === "closed") { + if (generation !== host.submissionGeneration || process !== host.rProcess || + backend !== host.runtimeBackend || host.mode === "closed") { throw new Error("R parse check cancelled"); } if (runtimeIsReady(host)) break; @@ -105,7 +107,9 @@ export async function requestRuntimeParseStatus(host: RuntimeHost, code: string) } await waiting.promise; } - return await (host.runtimeBackend?.requestParseStatus(process, code) ?? 1); + const result = backend?.requestParseStatus(process, code); + if (!result) throw new Error("R native parser unavailable"); + return await result; } export type RuntimeHost = { @@ -309,7 +313,6 @@ export async function startRuntime(host: RuntimeHost): Promise { }); integration.afterRuntimeStarted(); primeRuntimeAttach(host); - setNativeParseCallback(null); attachRuntimeSession(host, true); updateRuntimeTerminalName(host); @@ -334,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); }, }); @@ -389,7 +395,6 @@ export function handleRuntimeControl( case "host-connected": host.sessionHostConnected = true; vscodeRIntegration(host).handleHostConnected(); - updateNativeParseCallback(host); return; case "prompt": handleBackendPrompt(host, event.kind); @@ -467,7 +472,6 @@ function applyRuntimeSessionState( } host.sessionHostConnected = true; - updateNativeParseCallback(host); if (event.busy || event.wait.kind === "none") { host.clearPromptRenderTimer(); @@ -1270,6 +1274,10 @@ export async function enqueueRuntimeSubmission( 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) { @@ -1288,7 +1296,7 @@ export async function enqueueRuntimeSubmission( pending = pending ? pending + "\n" + line : line; const block = normalizeSubmissionBlock(pending); if (!block.trim()) continue; - const complete = await host.inputState.isExpressionCompleteAsync(block); + 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. @@ -1487,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; @@ -1508,17 +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((code: string) => requestRuntimeParseStatus(host, code)); -} diff --git a/tests/helpers/submission-terminal.cjs b/tests/helpers/submission-terminal.cjs index a7055d4..94e2e69 100644 --- a/tests/helpers/submission-terminal.cjs +++ b/tests/helpers/submission-terminal.cjs @@ -41,6 +41,7 @@ function createTerminal() { syntax: { setSource() {}, highlightLines(lines) { return lines; } }, runtimeBackend: { isAlive: () => true, + hasCapability: () => false, canUseSessionCommands: () => true, sendSessionCommand(session, command) { commands.push(command); return true; }, }, diff --git a/tests/submission-runtime.test.cjs b/tests/submission-runtime.test.cjs index e67c097..c811462 100644 --- a/tests/submission-runtime.test.cjs +++ b/tests/submission-runtime.test.cjs @@ -6,7 +6,7 @@ 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, parser } = require("./helpers/submission-terminal.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 => { @@ -64,9 +64,7 @@ test("frontend continuations execute in order with real R parsing and evaluation }); terminal.runtimeBackend = backend; terminal.rProcess = session; - parser.setNativeParseCallback(code => runtime.requestRuntimeParseStatus(terminal.runtimeHostAdapter, code)); t.after(async () => { - parser.setNativeParseCallback(null); terminal.cancelPendingSubmissions(); const pid = backend.getPid(session); backend.close(session); @@ -289,3 +287,121 @@ test("frontend continuations execute in order with real R parsing and evaluation 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 index 21aa3d4..1cc1da1 100644 --- a/tests/unit/backend-parse.test.cjs +++ b/tests/unit/backend-parse.test.cjs @@ -20,9 +20,7 @@ test("native parse timeout settles the request, permits fallback, and ignores la await rejected; assert.equal(state.pendingParseRequests.size, 0); - parser.setNativeParseCallback(code => backend.requestParseStatus(session, code)); - t.after(() => parser.setNativeParseCallback(null)); - const fallback = parser.isExpressionCompleteAsync("42"); + 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"); @@ -34,3 +32,53 @@ test("native parse timeout settles the request, permits fallback, and ignores la 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/parser.test.cjs b/tests/unit/parser.test.cjs index 8678be1..696a2dd 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 0000000..53300cd --- /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 index ff4b8d9..974b5ab 100644 --- a/tests/unit/submission.test.cjs +++ b/tests/unit/submission.test.cjs @@ -1,6 +1,6 @@ const assert = require("node:assert/strict"); const { test } = require("node:test"); -const { createTerminal, createTerminalWithScreen, nextPrompt, deferred, parser, runtime } = require("../helpers/submission-terminal.cjs"); +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. @@ -230,11 +230,11 @@ test("Enter waits for the previous native check and checks only the combined con const { terminal, commands } = createTerminal(); const first = deferred(); const checked = []; - parser.setNativeParseCallback(code => { + terminal.runtimeBackend.hasCapability = () => true; + terminal.runtimeBackend.requestParseStatus = (_session, code) => { checked.push(code); return code === "if" ? first.promise : Promise.resolve(1); - }); - t.after(() => parser.setNativeParseCallback(null)); + }; terminal.inputState.insertText("if"); const a = terminal.handleEnterKey(); terminal.inputState.insertText("(TRUE) 1"); @@ -536,8 +536,7 @@ function nativeChecks(t, terminal) { checked.push(code); return Promise.resolve(code === "if" ? 2 : 1); }; - parser.setNativeParseCallback(code => runtime.requestRuntimeParseStatus(terminal.runtimeHostAdapter, code)); - t.after(() => parser.setNativeParseCallback(null)); + terminal.runtimeBackend.hasCapability = () => true; return checked; }