diff --git a/js/terminal-ext.js b/js/terminal-ext.js index 3ecac058..8a1a6b58 100644 --- a/js/terminal-ext.js +++ b/js/terminal-ext.js @@ -21,6 +21,11 @@ const extend = (term) => { term.history = []; term.historyCursor = -1; term.busy = false; + term.locked = false; + term._collectingInput = false; + term._replaying = false; + term._replayPromise = null; + term._execution = null; // Tab completion state — reset on any non-tab keypress. term.tabIndex = 0; @@ -166,15 +171,93 @@ const extend = (term) => { // ── Animation Helpers ────────────────────────────────────────────────────── - term.timer = (ms) => new Promise((res) => setTimeout(res, ms)); + // An execution owns its pending waits. Keeping the cancellation machinery + // private means commands continue to use the same public command API. + const makeAbortError = () => { + const error = new Error("Command interrupted"); + error._terminalAbort = true; + return error; + }; + + const isAbortError = (error) => Boolean(error && error._terminalAbort); + + const createExecution = () => { + const execution = { + aborted: false, + abortRequested: false, + abortError: makeAbortError(), + listeners: new Set(), + abort() { + if (execution.aborted) { + return; + } + execution.aborted = true; + for (const listener of [...execution.listeners]) { + listener(); + } + execution.listeners.clear(); + }, + }; + return execution; + }; + + const cancellableWait = (ms, execution = term._execution) => + new Promise((resolve, reject) => { + if (execution?.aborted) { + reject(execution.abortError); + return; + } + + let settled = false; + let removeListener = () => {}; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + if (removeListener) removeListener(); + resolve(); + }, ms); + const onAbort = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (removeListener) removeListener(); + reject(execution.abortError); + }; + if (execution) { + execution.listeners.add(onAbort); + removeListener = () => execution.listeners.delete(onAbort); + } + }); + + term.timer = (ms) => cancellableWait(ms); + + term._requestInterrupt = () => { + const execution = term._execution; + if ( + !term.busy || + term._replaying || + term._collectingInput || + !execution || + execution.abortRequested + ) { + return false; + } + execution.abortRequested = true; + execution.abort(); + return true; + }; // Prints phrase followed by n dots at 1-second intervals. term.dottedPrint = async (phrase, n, newline = true) => { + const execution = term._execution; term.write(phrase); for (let i = 0; i < n; i++) { await term.delayPrint(".", 1000); } + if (execution?.aborted) { + throw execution.abortError; + } if (newline) { term.write("\r\n"); } @@ -183,6 +266,7 @@ const extend = (term) => { // Renders an animated progress bar that fills over time t (ms). // Randomizes the fill speed to look more authentic. term.progressBar = async (t, msg) => { + const execution = term._execution; var r; if (msg) { @@ -195,16 +279,27 @@ const extend = (term) => { t = t - r; await term.delayPrint("█", r); } + if (execution?.aborted) { + throw execution.abortError; + } term.write("]\r\n"); }; term.delayPrint = async (str, t) => { + const execution = term._execution; await term.timer(t); + if (execution?.aborted) { + throw execution.abortError; + } term.write(str); }; term.delayStylePrint = async (str, t, wrap) => { + const execution = term._execution; await term.timer(t); + if (execution?.aborted) { + throw execution.abortError; + } term.stylePrint(str, wrap); }; @@ -294,14 +389,21 @@ const extend = (term) => { }; const parsed = term.parseCommandLine(line); let exitStatus; - + const ownsBusy = settings.manageBusy && !term._replaying; + const execution = ownsBusy ? createExecution() : null; + let interrupted = false; try { - if (settings.manageBusy) { + if (ownsBusy) { term.busy = true; + term._execution = execution; } await term.preloadCommandAssets(parsed.line); + if (execution?.aborted) { + throw execution.abortError; + } + if (settings.showLeadingNewline && parsed.cmd != "upgrade") { term.writeln(""); } @@ -311,7 +413,11 @@ const extend = (term) => { term.history.push(parsed.line); } - exitStatus = term.command(parsed.line); + exitStatus = await term.command(parsed.line); + + if (execution?.aborted) { + throw execution.abortError; + } if (settings.trackAnalytics) { window.dataLayer = window.dataLayer || []; @@ -323,16 +429,35 @@ const extend = (term) => { } } } catch (error) { - console.error("Command preparation failed", error); - term.stylePrint("Command failed to load required assets. Please try again."); + if (isAbortError(error)) { + // A resize or replacement may have displaced this execution by the + // time its rejected wait settles. Such an old owner must be silent; + // only its current owner performs interruption cleanup. + interrupted = term._execution === execution; + } else { + console.error("Command preparation failed", error); + term.stylePrint("Command failed to load required assets. Please try again."); + } } finally { - if (settings.promptAfter && exitStatus != 1 && parsed.cmd != "upgrade") { + const ownsExecution = !execution || term._execution === execution; + if (interrupted && ownsExecution) { + term.locked = false; + term.write("\r\n"); + term.writeln("^C"); + term.clearCurrentLine(true); + } else if ( + settings.promptAfter && + exitStatus != 1 && + parsed.cmd != "upgrade" && + ownsExecution + ) { term.prompt(); term.clearCurrentLine(true); } - if (settings.manageBusy) { + if (ownsBusy && ownsExecution) { term.busy = false; + term._execution = null; } term.scrollToBottom(); @@ -347,19 +472,65 @@ const extend = (term) => { // reinitialize the terminal and replay the entire command history to restore // the visible output, then re-render the prompt at the bottom. term.resizeListener = () => { - term._initialized = false; - term.init(term.user, true); - if (typeof preloadASCIIArt === "function") { - window.scheduleIdleTask(() => preloadASCIIArt(), 1500); + if (term._replayPromise) { + return term._replayPromise; } - term.runDeepLink({ replay: true }); - for (const c of term.history) { - term.prompt("\r\n", ` ${c}\r\n`); - term.command(c); + + // Publish the replay promise and ownership state before starting any + // replay work. init/command hooks can synchronously cause another resize; + // that call must join this replay rather than create a second owner. + const previousExecution = term._execution; + if (previousExecution) { + previousExecution.abort(); } - term.prompt(); - term.scrollToBottom(); - term._initialized = true; + term._replaying = true; + term._execution = null; + term.busy = true; + term._initialized = false; + + let resolveReplay; + let rejectReplay; + const replay = new Promise((resolve, reject) => { + resolveReplay = resolve; + rejectReplay = reject; + }); + term._replayPromise = replay; + + (async () => { + try { + term.init(term.user, true); + if (typeof preloadASCIIArt === "function") { + window.scheduleIdleTask(() => preloadASCIIArt(), 1500); + } + await term.runDeepLink({ replay: true }); + for (const c of term.history) { + term.prompt("\r\n", ` ${c}\r\n`); + await term.command(c); + } + term.prompt(); + term.scrollToBottom(); + } finally { + // Only the replay owner may release shared terminal state. Calls made + // while a replay is pending join the same promise above instead of + // allowing an older callback to finalize a newer replay. + if (term._replayPromise === replay) { + term._replaying = false; + term.busy = false; + term.locked = false; + term._execution = null; + term._initialized = true; + } + } + })().then(resolveReplay, rejectReplay); + replay.then( + () => { + if (term._replayPromise === replay) term._replayPromise = null; + }, + () => { + if (term._replayPromise === replay) term._replayPromise = null; + } + ); + return replay; }; // Resets the terminal to its initial state. If VERSION < 4, shows an upgrade @@ -413,9 +584,9 @@ const extend = (term) => { // buffer xterm cleared. That is the same visit, not a new arrival, so it must // not be counted again — the history replay right below it calls term.command // directly rather than executeCommandLine for exactly this reason. - term.runDeepLink = ({ replay = false } = {}) => { + term.runDeepLink = async ({ replay = false } = {}) => { if (term.deepLink != "") { - term.executeCommandLine(term.deepLink, { + await term.executeCommandLine(term.deepLink, { addToHistory: false, promptAfter: false, showLeadingNewline: false, @@ -423,8 +594,6 @@ const extend = (term) => { // so these are the arrivals worth counting. Fragments never fire a // pageview of their own, so without this they would be invisible. trackAnalytics: !replay, - }).catch((error) => { - console.error("Deep link failed", error); }); } }; @@ -437,21 +606,30 @@ const extend = (term) => { // - null if the user pressed Ctrl+C (cancelled) term.collectInput = (prompt, isOptional = false) => { return new Promise((resolve) => { + term._collectingInput = true; term.locked = true; term.write(`\r\n${prompt}${isOptional ? ' (optional)' : ''}: `); let inputBuffer = ''; + let settled = false; const inputHandler = term.onData((e) => { + if (settled) { + return; + } switch (e) { case '\r': // Enter — submit + settled = true; term.write('\r\n'); inputHandler.dispose(); + term._collectingInput = false; term.locked = false; resolve(inputBuffer.trim()); break; case '\u0003': // Ctrl+C — cancel, resolves to null + settled = true; term.write('^C\r\n'); inputHandler.dispose(); + term._collectingInput = false; term.locked = false; resolve(null); break; diff --git a/js/terminal.js b/js/terminal.js index f8014ba1..2ab30d8e 100644 --- a/js/terminal.js +++ b/js/terminal.js @@ -8,7 +8,9 @@ function runRootTerminal(term) { term.locked = false; term.prompt(); - term.runDeepLink(); + Promise.resolve(term.runDeepLink()).catch((error) => { + console.error("Deep link failed", error); + }); let resizeQueued = false; window.addEventListener( @@ -20,14 +22,45 @@ function runRootTerminal(term) { resizeQueued = true; window.requestAnimationFrame(() => { - resizeQueued = false; - term.resizeListener(); + Promise.resolve(term.resizeListener()).then( + () => { + resizeQueued = false; + }, + () => { + resizeQueued = false; + } + ); }); }, { passive: true } ); term.onData((e) => { + // Ctrl+C is the one input that remains meaningful while an async command + // owns the terminal. Let collectInput's handler keep exclusive ownership + // of its cancellation, and never let input interrupt transcript replay. + if (e === "\u0003") { + if (term._collectingInput || term._replaying) { + return; + } + if (term.busy) { + if (term._execution && !term._execution.abortRequested) { + term._requestInterrupt(); + } + return; + } + if (term._initialized && !term.locked) { + // Reset tab state + term.tabIndex = 0; + term.tabOptions = []; + term.tabBase = ""; + + term.prompt(); + term.clearCurrentLine(true); + } + return; + } + if (term._initialized && !term.locked && !term.busy) { switch (e) { case "\r": // Enter @@ -45,15 +78,6 @@ function runRootTerminal(term) { term.write("\x1b[C".repeat(term.currentLine.length - term.pos())); } break; - case "\u0003": // Ctrl+C - // Reset tab state - term.tabIndex = 0; - term.tabOptions = []; - term.tabBase = ""; - - term.prompt(); - term.clearCurrentLine(true); - break; case "\u0008": // Ctrl+H case "\u007F": // Backspace (DEL) // Reset tab state diff --git a/tests/terminal-ext.test.js b/tests/terminal-ext.test.js index 3162b37f..4722a378 100644 --- a/tests/terminal-ext.test.js +++ b/tests/terminal-ext.test.js @@ -28,6 +28,8 @@ function loadTerminalExt(globals = {}) { function createTerm(overrides = {}) { const term = { VERSION: 4, + busy: false, + locked: false, _core: { buffer: { x: 0 } }, cols: 80, command: vi.fn(() => 0), @@ -42,6 +44,17 @@ function createTerm(overrides = {}) { writeln: vi.fn(), }; + term.onData = vi.fn((handler) => { + term._inputHandler = handler; + return { + dispose: vi.fn(() => { + if (term._inputHandler === handler) { + term._inputHandler = null; + } + }), + }; + }); + return Object.assign(term, overrides); } @@ -179,4 +192,246 @@ describe("terminal-ext", () => { expect(term.writeln).toHaveBeenCalledWith("\r\nASCII\r\n"); expect(env.window.ensureASCIIArt).toHaveBeenCalledWith("rootvc-square"); }); + + it("stops an async command at its next primitive await and restores the prompt", async () => { + vi.useFakeTimers(); + let term; + const { extend } = loadTerminalExt({ + commands: { + animate: async () => { + term.write("before"); + await term.delayPrint("middle", 10); + await term.delayStylePrint("late", 100); + }, + }, + }); + term = createTerm(); + extend(term); + vi.spyOn(term, "prompt"); + + const command = term.executeCommandLine("animate"); + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(10); + expect(term.write).toHaveBeenCalledWith("middle"); + + term._requestInterrupt(); + term._requestInterrupt(); + await command; + + expect(term.write).toHaveBeenCalledWith("before"); + expect(term.write).toHaveBeenCalledWith("middle"); + expect(term.write).toHaveBeenCalledWith("\r\n"); + expect(term.writeln).toHaveBeenCalledWith("^C"); + expect( + term.write.mock.invocationCallOrder.find( + (callOrder, index) => term.write.mock.calls[index][0] === "\r\n" + ) + ).toBeLessThan(term.writeln.mock.invocationCallOrder[term.writeln.mock.calls.findIndex(([value]) => value === "^C")]); + expect(term.prompt).toHaveBeenCalledTimes(1); + expect(term.prompt.mock.invocationCallOrder[0]).toBeGreaterThan( + term.writeln.mock.invocationCallOrder[term.writeln.mock.calls.findIndex(([value]) => value === "^C")] + ); + expect(term.writeln).not.toHaveBeenCalledWith("late"); + expect(term.busy).toBe(false); + expect(term.locked).toBe(false); + vi.useRealTimers(); + }); + + it("unlocks an interrupted command without rolling back completed mutations", async () => { + vi.useFakeTimers(); + let term; + const { extend } = loadTerminalExt({ + commands: { + mutate: async () => { + term.VERSION = 9; + term.locked = true; + await term.delayPrint("never", 100); + }, + }, + }); + term = createTerm(); + extend(term); + + const command = term.executeCommandLine("mutate"); + await Promise.resolve(); + await Promise.resolve(); + term._requestInterrupt(); + await command; + + expect(term.VERSION).toBe(9); + expect(term.locked).toBe(false); + expect(term.busy).toBe(false); + expect(term.writeln).toHaveBeenCalledWith("^C"); + vi.useRealTimers(); + }); + + it("accepts the next command after an interrupted command settles", async () => { + vi.useFakeTimers(); + let term; + const { extend } = loadTerminalExt({ + commands: { + interruptible: async () => { + await term.delayPrint("not-after-interrupt", 100); + }, + next: () => { + term.write("next-command"); + }, + }, + }); + term = createTerm(); + extend(term); + + const interrupted = term.executeCommandLine("interruptible"); + await Promise.resolve(); + await Promise.resolve(); + term._requestInterrupt(); + await interrupted; + + await term.executeCommandLine("next"); + + expect(term.write).toHaveBeenCalledWith("next-command"); + expect(term.busy).toBe(false); + expect(term.locked).toBe(false); + vi.useRealTimers(); + }); + + it.each(["delayPrint", "delayStylePrint", "dottedPrint", "progressBar"])( + "%s rejects its pending wait with the private abort error", + async (primitive) => { + vi.useFakeTimers(); + let term; + const { extend } = loadTerminalExt({ + commands: { + probe: async () => { + try { + if (primitive === "delayPrint") { + await term.delayPrint("late", 100); + } else if (primitive === "delayStylePrint") { + await term.delayStylePrint("late", 100); + } else if (primitive === "dottedPrint") { + await term.dottedPrint("dots", 1); + } else { + await term.progressBar(100, "progress"); + } + } catch (error) { + term.caughtAbort = error; + throw error; + } + }, + }, + }); + term = createTerm(); + extend(term); + + const command = term.executeCommandLine("probe"); + await Promise.resolve(); + await Promise.resolve(); + term._requestInterrupt(); + await command; + + expect(term.caughtAbort).toMatchObject({ _terminalAbort: true }); + expect(term.busy).toBe(false); + expect(term.locked).toBe(false); + vi.useRealTimers(); + } + ); + + it("keeps collectInput Ctrl+C single-fire and exclusive", async () => { + const { extend } = loadTerminalExt(); + const term = createTerm(); + extend(term); + + const input = term.collectInput("Name"); + const inputHandler = term._inputHandler; + inputHandler("\u0003"); + inputHandler("\u0003"); + + await expect(input).resolves.toBe(null); + expect(term.locked).toBe(false); + expect(term._collectingInput).toBe(false); + expect(term.write).toHaveBeenCalledWith("^C\r\n"); + expect(term.write.mock.calls.filter(([value]) => value === "^C\r\n")).toHaveLength(1); + }); + + it("preserves a non-interrupt command failure instead of printing Ctrl+C", async () => { + const error = new Error("boom"); + let term; + const { extend } = loadTerminalExt({ + commands: { + fail: () => Promise.reject(error), + }, + }); + term = createTerm(); + extend(term); + + await term.executeCommandLine("fail"); + + expect(term.writeln).not.toHaveBeenCalledWith("^C"); + expect(term.busy).toBe(false); + expect(term.locked).toBe(false); + }); + + it("awaits a complete, non-interruptible resize replay", async () => { + vi.useFakeTimers(); + let term; + const { extend } = loadTerminalExt({ + commands: { + replay: async () => { + term.write("replay-start"); + await term.delayPrint("replay-end", 25); + }, + }, + }); + term = createTerm(); + extend(term); + term.history = ["replay"]; + + const replay = term.resizeListener(); + expect(term._replaying).toBe(true); + term._requestInterrupt(); + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(25); + await replay; + + expect(term.write).toHaveBeenCalledWith("replay-end"); + expect(term._replaying).toBe(false); + expect(term.busy).toBe(false); + expect(term.locked).toBe(false); + vi.useRealTimers(); + }); + + it("joins overlapping resize requests so stale replay cleanup cannot release ownership", async () => { + vi.useFakeTimers(); + let term; + const { extend } = loadTerminalExt({ + commands: { + replay: async () => { + await term.delayPrint("replay-end", 25); + }, + }, + }); + term = createTerm(); + extend(term); + term.history = ["replay"]; + + const firstReplay = term.resizeListener(); + const joinedReplay = term.resizeListener(); + + expect(joinedReplay).toBe(firstReplay); + expect(term._replaying).toBe(true); + expect(term.busy).toBe(true); + expect(term._requestInterrupt()).toBe(false); + expect(term._replaying).toBe(true); + expect(term.busy).toBe(true); + + await vi.advanceTimersByTimeAsync(25); + await firstReplay; + + expect(term._replaying).toBe(false); + expect(term.busy).toBe(false); + expect(term._replayPromise).toBe(null); + vi.useRealTimers(); + }); }); diff --git a/tests/terminal.test.js b/tests/terminal.test.js index 1a046e51..cacb1cda 100644 --- a/tests/terminal.test.js +++ b/tests/terminal.test.js @@ -38,6 +38,14 @@ function createTerm(overrides = {}) { resizeListener: vi.fn(), runDeepLink: vi.fn(), scrollToBottom: vi.fn(), + _collectingInput: false, + _execution: null, + _replaying: false, + _requestInterrupt: vi.fn(() => { + if (term._execution) { + term._execution.abortRequested = true; + } + }), setCurrentLine: vi.fn((line) => { term.currentLine = line; }), @@ -126,4 +134,40 @@ describe("runRootTerminal", () => { expect(term.prompt).not.toHaveBeenCalled(); expect(term.onData).not.toHaveBeenCalled(); }); + + it("routes busy Ctrl+C to the active execution once and preserves idle behavior", () => { + const { runRootTerminal } = loadTerminalScript(); + const term = createTerm(); + + runRootTerminal(term); + term.busy = true; + term._execution = { abortRequested: false }; + term._onData("\u0003"); + term._onData("\u0003"); + + expect(term._requestInterrupt).toHaveBeenCalledTimes(1); + expect(term.prompt).toHaveBeenCalledTimes(1); + + term.busy = false; + term._onData("\u0003"); + + expect(term.prompt).toHaveBeenCalledTimes(2); + expect(term.clearCurrentLine).toHaveBeenCalledTimes(1); + }); + + it("does not take Ctrl+C ownership from collectInput or resize replay", () => { + const { runRootTerminal } = loadTerminalScript(); + const term = createTerm(); + + runRootTerminal(term); + term.busy = true; + term._collectingInput = true; + term._onData("\u0003"); + term._collectingInput = false; + term._replaying = true; + term._onData("\u0003"); + + expect(term._requestInterrupt).not.toHaveBeenCalled(); + expect(term.prompt).toHaveBeenCalledTimes(1); + }); });