diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 3218eb4ba8..85130aab74 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -6,6 +6,8 @@ Zoo Code checks its persisted task delegation lifecycle with a bounded, exhausti pnpm lifecycle:model-check ``` +`pnpm lifecycle:model` runs the same checks directly; `lifecycle:model-check` is the CI-facing alias. + The check runs in the `compile` CI job after type checking. It fails if it finds an invariant violation, a modeled action becomes unreachable, or exploration exceeds its declared state budget. A violation includes the shortest breadth-first event trace, every intermediate state, and the active bounds so the sequence can be replayed as a focused regression test. ## Why an executable TypeScript model @@ -37,6 +39,12 @@ The model has three fixed task slots, enough to cover competing siblings and a n Production completion also accepts a recovery-compatible `active` parent that still awaits the returning child, then clears the stale pointers. Normal model transitions never create that intermediate state, so it is covered by a focused reducer test rather than admitted as a generally valid reachable state. +## Terminal command lifecycle model + +The same command runs a bounded terminal lifecycle explorer for issue #1362. It models command startup, shell activation, streamed output, normal completion, and terminal closure. Its invariants require completion to remain at-most-once, closure to detach the process, buffered output to be delivered, and an active stream iterator to be released. Named landmarks retain the important interleavings: closure before command submission, closure after output, closure after a normal end event, and duplicate closure. + +This terminal model is intentionally separate from persisted task delegation state because VS Code terminal events are an extension-host adapter protocol rather than `HistoryItem` transitions. Focused `TerminalRegistry` tests bind the abstract properties to production behavior, including omitted `onDidEndTerminalShellExecution` events and an undefined `exitStatus` during the close callback. + ## Shared-store concurrency model The same `pnpm lifecycle:model-check` command also runs a second bounded explorer over two `TaskHistoryStore` hosts. It imports the production `computeHistoryDelta` and `mergeHistoryDelta` functions, so its semantics match the store rather than assuming coherent caches or transactional pair writes: diff --git a/package.json b/package.json index 1a44a12680..3bd54ed1ec 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check", + "lifecycle:model": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check && tsx scripts/check-terminal-lifecycle.ts", + "lifecycle:model-check": "pnpm lifecycle:model", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "test:coverage": "turbo test:coverage --log-order grouped --output-logs new-only", "format": "turbo format --log-order grouped --output-logs new-only", diff --git a/scripts/check-terminal-lifecycle.ts b/scripts/check-terminal-lifecycle.ts new file mode 100644 index 0000000000..f9a14afacc --- /dev/null +++ b/scripts/check-terminal-lifecycle.ts @@ -0,0 +1,141 @@ +type Phase = "idle" | "waiting" | "running" | "completed" | "closed" +type Action = "run" | "activate" | "output" | "end" | "close" + +interface ModelState { + phase: Phase + processAttached: boolean + commandSubmitted: boolean + completionCount: number + output: string + deliveredOutput: string + iteratorReleased: boolean +} + +interface TraceStep { + action: Action | "initial" + state: ModelState +} + +const actions: Action[] = ["run", "activate", "output", "end", "close"] +const MAX_DEPTH = 7 +const MAX_STATES = 100 + +function initialState(): ModelState { + return { + phase: "idle", + processAttached: false, + commandSubmitted: false, + completionCount: 0, + output: "", + deliveredOutput: "", + iteratorReleased: false, + } +} + +function complete(state: ModelState, phase: "completed" | "closed"): ModelState { + return { + ...state, + phase, + processAttached: false, + completionCount: state.processAttached ? state.completionCount + 1 : state.completionCount, + deliveredOutput: state.output, + iteratorReleased: state.iteratorReleased || state.phase === "running", + } +} + +function transition(state: ModelState, action: Action): ModelState { + switch (action) { + case "run": + return state.phase === "idle" ? { ...state, phase: "waiting", processAttached: true } : state + case "activate": + return state.phase === "waiting" ? { ...state, phase: "running", commandSubmitted: true } : state + case "output": + return state.phase === "running" ? { ...state, output: `${state.output}chunk` } : state + case "end": + return state.phase === "waiting" || state.phase === "running" ? complete(state, "completed") : state + case "close": + return state.phase === "closed" ? state : complete(state, "closed") + } +} + +function violations(state: ModelState): string[] { + const result: string[] = [] + if (state.completionCount > 1) result.push("a command completed more than once") + if (state.phase === "closed" && state.processAttached) result.push("a closed terminal retained its process") + if (state.phase === "closed" && state.commandSubmitted && !state.iteratorReleased) { + result.push("closing a submitted command did not release its stream iterator") + } + if ((state.phase === "completed" || state.phase === "closed") && state.deliveredOutput !== state.output) { + result.push("completion did not deliver all buffered output") + } + return result +} + +function formatCounterexample(message: string, trace: TraceStep[]): string { + return [ + `Terminal lifecycle invariant failed: ${message}`, + `Bounds: depth=${MAX_DEPTH}, states=${MAX_STATES}`, + ...trace.map((step, index) => `${index}. ${step.action}: ${JSON.stringify(step.state)}`), + ].join("\n") +} + +const landmarks = { + "waiting-close-without-submit": (trace: TraceStep[]) => + trace.some((step) => step.action === "run") && + trace.at(-1)?.action === "close" && + trace.at(-1)?.state.commandSubmitted === false && + trace.at(-1)?.state.completionCount === 1, + "running-close-after-output": (trace: TraceStep[]) => + trace.some((step) => step.action === "output") && + trace.at(-1)?.action === "close" && + trace.at(-1)?.state.deliveredOutput === "chunk" && + trace.at(-1)?.state.iteratorReleased === true, + "end-then-close": (trace: TraceStep[]) => + trace.some((step) => step.action === "end") && + trace.at(-1)?.action === "close" && + trace.at(-1)?.state.completionCount === 1, + "duplicate-close": (trace: TraceStep[]) => trace.filter((step) => step.action === "close").length >= 2, +} satisfies Record boolean> + +const start = initialState() +const queue: Array<{ state: ModelState; trace: TraceStep[] }> = [ + { state: start, trace: [{ action: "initial", state: start }] }, +] +const visited = new Set([JSON.stringify(start)]) +const reachedActions = new Set() +const reachedLandmarks = new Set() + +for (let index = 0; index < queue.length; index++) { + const node = queue[index]! + const stateViolations = violations(node.state) + if (stateViolations.length) throw new Error(formatCounterexample(stateViolations.join("; "), node.trace)) + for (const [name, predicate] of Object.entries(landmarks)) { + if (predicate(node.trace)) reachedLandmarks.add(name) + } + if (node.trace.length - 1 === MAX_DEPTH) continue + + for (const action of actions) { + const next = transition(node.state, action) + const trace = [...node.trace, { action, state: next }] + for (const [name, predicate] of Object.entries(landmarks)) { + if (predicate(trace)) reachedLandmarks.add(name) + } + if (next === node.state) continue + reachedActions.add(action) + const key = JSON.stringify(next) + if (visited.has(key)) continue + visited.add(key) + queue.push({ state: next, trace }) + if (visited.size > MAX_STATES) throw new Error(`Terminal lifecycle exceeded its ${MAX_STATES}-state budget`) + } +} + +const missingActions = actions.filter((action) => !reachedActions.has(action)) +if (missingActions.length) throw new Error(`Terminal lifecycle has unreachable actions: ${missingActions.join(", ")}`) +const missingLandmarks = Object.keys(landmarks).filter((name) => !reachedLandmarks.has(name)) +if (missingLandmarks.length) + throw new Error(`Terminal lifecycle has unreachable landmarks: ${missingLandmarks.join(", ")}`) + +console.log( + `Terminal lifecycle model check passed: ${visited.size} reachable states, ${actions.length}/${actions.length} actions reachable, ${Object.keys(landmarks).length}/${Object.keys(landmarks).length} landmarks reached, depth <= ${MAX_DEPTH}`, +) diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 21f98b86c6..de68c4284c 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -11,6 +11,8 @@ import { mergePromise } from "./mergePromise" export class Terminal extends BaseTerminal { public terminal: vscode.Terminal + private closed = false + private cancelShellIntegrationWait?: () => void public cmdCounter: number = 0 @@ -74,7 +76,24 @@ export class Terminal extends BaseTerminal { * active. (This value is set when onDidCloseTerminal is fired.) */ public override isClosed(): boolean { - return this.terminal.exitStatus !== undefined + return this.closed || this.terminal.exitStatus !== undefined + } + + /** Finalizes any attached command when VS Code disposes this terminal. */ + public handleClose(): void { + if (this.closed) { + return + } + + this.closed = true + this.cancelShellIntegrationWait?.() + this.cancelShellIntegrationWait = undefined + + if (this.process instanceof TerminalProcess) { + this.process.handleTerminalClosed() + } else { + this.shellExecutionComplete({ exitCode: undefined }) + } } public override runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise { @@ -104,6 +123,11 @@ export class Terminal extends BaseTerminal { reject(error) }) + if (this.isClosed()) { + process.handleTerminalClosed() + return + } + if (Terminal.isActiveShellCmdExe()) { // Keep this defensive fallback for callers that invoke Terminal.runCommand() // directly instead of routing through executeCommandInTerminal(). @@ -123,6 +147,10 @@ export class Terminal extends BaseTerminal { // customised startup that suppresses the OSC 633;A marker). this.waitForShellIntegration(Terminal.getShellIntegrationTimeout()) .then(() => { + if (this.isClosed()) { + return + } + // Clean up temporary directory if shell integration is available, zsh did its job: ShellIntegrationManager.zshCleanupTmpDir(this.id) @@ -130,6 +158,10 @@ export class Terminal extends BaseTerminal { void process.run(command).catch((error) => process.emit("error", error)) }) .catch(() => { + if (this.isClosed()) { + return + } + console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`) // Clean up temporary directory if shell integration is not available @@ -153,22 +185,43 @@ export class Terminal extends BaseTerminal { * than polling — important for slow-starting shells (heavy .zshrc, nvm, etc.). */ private waitForShellIntegration(timeoutMs: number): Promise { + if (this.isClosed()) { + return Promise.reject(new Error("Terminal closed before shell integration became available")) + } + if (this.terminal.shellIntegration) { return Promise.resolve() } return new Promise((resolve, reject) => { const ref = { disposable: null as vscode.Disposable | null } - const timer = setTimeout(() => { + let settled = false + let cancel = () => {} + const finish = (callback: () => void) => { + if (settled) { + return + } + + settled = true + clearTimeout(timer) ref.disposable?.dispose() - reject(new Error(`Shell integration did not activate within ${timeoutMs / 1000}s`)) + + if (this.cancelShellIntegrationWait === cancel) { + this.cancelShellIntegrationWait = undefined + } + + callback() + } + const timer = setTimeout(() => { + finish(() => reject(new Error(`Shell integration did not activate within ${timeoutMs / 1000}s`))) }, timeoutMs) + cancel = () => finish(() => reject(new Error("Terminal closed before shell integration became available"))) + this.cancelShellIntegrationWait = cancel + ref.disposable = vscode.window.onDidChangeTerminalShellIntegration((e) => { if (e.terminal === this.terminal) { - clearTimeout(timer) - ref.disposable?.dispose() - resolve() + finish(resolve) } }) }) diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index d1643dec3a..c32805b53d 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -58,6 +58,21 @@ export class TerminalProcess extends BaseTerminalProcess { return terminal } + /** Completes this process when its terminal closes without an execution-end event. */ + public handleTerminalClosed(): void { + const executionStarted = this.ownExecution !== undefined + this.terminal.shellExecutionComplete({ exitCode: undefined }) + + if (executionStarted) { + return + } + + // run() has not installed its completion listener yet, so finish the + // startup-wait path directly instead of leaving runCommand() pending. + this.emit("completed", "") + this.emit("continue") + } + public override async run(command: string) { this.command = command diff --git a/src/integrations/terminal/TerminalRegistry.ts b/src/integrations/terminal/TerminalRegistry.ts index da4b3dd16d..d7385af1b5 100644 --- a/src/integrations/terminal/TerminalRegistry.ts +++ b/src/integrations/terminal/TerminalRegistry.ts @@ -33,13 +33,16 @@ export class TerminalRegistry { // TODO: This initialization code is VSCode specific, and therefore // should probably live elsewhere. - // Register handler for terminal close events to clean up temporary - // directories. + // Treat terminal closure as a completion path because VS Code may not emit + // onDidEndTerminalShellExecution after the terminal is disposed. const closeDisposable = vscode.window.onDidCloseTerminal((vsceTerminal) => { - const terminal = this.getTerminalByVSCETerminal(vsceTerminal) + // Do not use getTerminalByVSCETerminal here: exitStatus is already set when + // this event fires, so that helper removes closed terminals before returning. + const terminal = this.terminals.find((t) => t instanceof Terminal && t.terminal === vsceTerminal) - if (terminal) { - ShellIntegrationManager.zshCleanupTmpDir(terminal.id) + if (terminal instanceof Terminal) { + terminal.handleClose() + this.removeTerminal(terminal.id) } }) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index f60c0d0722..36a0468c54 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -11,6 +11,13 @@ import { TerminalRegistry } from "../TerminalRegistry" const PAGER = process.platform === "win32" ? "" : "cat" +function settleWithin(promise: PromiseLike): Promise { + return Promise.race([ + Promise.resolve(promise), + new Promise((_, reject) => setTimeout(() => reject(new Error("terminal lifecycle did not settle")), 250)), + ]) +} + vi.mock("execa", () => ({ execa: vi.fn(), })) @@ -209,6 +216,8 @@ describe("TerminalRegistry", () => { }) describe("onDidEndTerminalShellExecution race condition (#489, #622)", () => { + let closeHandler: (terminal: vscode.Terminal) => void + let shellIntegrationHandler: (event: vscode.TerminalShellIntegrationChangeEvent) => void let startHandler: (e: any) => Promise let endHandler: (e: any) => Promise @@ -221,6 +230,15 @@ describe("TerminalRegistry", () => { ;(vscode.window as any).onDidStartTerminalShellExecution ??= () => ({ dispose: () => {} }) ;(vscode.window as any).onDidEndTerminalShellExecution ??= () => ({ dispose: () => {} }) + vi.spyOn(vscode.window, "onDidCloseTerminal").mockImplementation((handler) => { + closeHandler = handler + return { dispose: vi.fn() } + }) + vi.spyOn(vscode.window, "onDidChangeTerminalShellIntegration").mockImplementation((handler) => { + shellIntegrationHandler = handler + return { dispose: vi.fn() } + }) + vi.spyOn(vscode.window, "onDidStartTerminalShellExecution" as any).mockImplementation((handler: any) => { startHandler = handler return { dispose: vi.fn() } @@ -291,6 +309,405 @@ describe("TerminalRegistry", () => { expect(completeSpy).not.toHaveBeenCalled() }) + it("finalizes an active process when its terminal closes (#1362)", () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const process = new TerminalProcess(terminal) + process.ownExecution = { commandLine: { value: "git status" } } as vscode.TerminalShellExecution + terminal.process = process + terminal.busy = true + terminal.running = true + const completionSpy = vi.fn() + process.on("shell_execution_complete", completionSpy) + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: undefined, reason: 3 }, + configurable: true, + }) + + closeHandler(terminal.terminal) + + expect(completionSpy).toHaveBeenCalledOnce() + expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }) + expect(Object.hasOwn(completionSpy.mock.calls[0][0], "exitCode")).toBe(true) + expect(terminal.process).toBeUndefined() + expect(terminal.busy).toBe(false) + expect(terminal.running).toBe(false) + }) + + it("removes only the closed registered terminal when no process is attached", () => { + const closed = TerminalRegistry.createTerminal("/closed", "vscode") as Terminal + const open = TerminalRegistry.createTerminal("/open", "vscode") as Terminal + closed.busy = true + closed.running = true + const completionSpy = vi.spyOn(closed, "shellExecutionComplete") + const cleanupSpy = vi.spyOn(ShellIntegrationManager, "zshCleanupTmpDir") + + closeHandler(closed.terminal) + + expect(completionSpy).toHaveBeenCalledOnce() + expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }) + expect(Object.hasOwn(completionSpy.mock.calls[0][0], "exitCode")).toBe(true) + expect(closed.isClosed()).toBe(true) + expect(closed.busy).toBe(false) + expect(closed.running).toBe(false) + expect(cleanupSpy).toHaveBeenCalledWith(closed.id) + expect(TerminalRegistry["terminals"]).toEqual([open]) + }) + + it("ignores close events from unregistered terminals", () => { + const registered = TerminalRegistry.createTerminal("/registered", "vscode") as Terminal + const foreign = { name: "foreign" } as vscode.Terminal + const closeSpy = vi.spyOn(registered, "handleClose") + + closeHandler(foreign) + + expect(closeSpy).not.toHaveBeenCalled() + expect(TerminalRegistry["terminals"]).toEqual([registered]) + }) + + it("delivers buffered output and releases the stream iterator when an active terminal closes", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + let nextCall = 0 + let signalWaitingForNext: () => void = () => {} + const waitingForNext = new Promise((resolve) => { + signalWaitingForNext = resolve + }) + const returnSpy = vi.fn().mockResolvedValue({ done: true, value: undefined }) + const stream: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: vi.fn(() => { + nextCall++ + if (nextCall === 1) { + return Promise.resolve({ done: false, value: "\x1b]633;C\x07hello\n" }) + } + + signalWaitingForNext() + return new Promise>(() => {}) + }), + return: returnSpy, + } + }, + } + const execution = { + commandLine: { value: "printf hello" }, + read: vi.fn().mockReturnValue(stream), + } as unknown as vscode.TerminalShellExecution + const executeCommand = vi.fn().mockReturnValue(execution) + Object.defineProperty(terminal.terminal, "shellIntegration", { + value: { executeCommand }, + configurable: true, + }) + const completedSpy = vi.fn() + const result = terminal.runCommand("printf hello", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: vi.fn(), + }) + const process = terminal.process + expect(process).toBeInstanceOf(TerminalProcess) + + await vi.waitFor(() => expect(executeCommand).toHaveBeenCalledOnce()) + await startHandler({ terminal: terminal.terminal, execution }) + await waitingForNext + closeHandler(terminal.terminal) + await settleWithin(result) + + expect(completedSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledWith("hello\n", process) + expect(returnSpy).toHaveBeenCalledOnce() + expect(terminal.process).toBeUndefined() + expect(terminal.busy).toBe(false) + expect(terminal.running).toBe(false) + }) + + it("unblocks a process when its terminal closes while shell integration is initializing (#1362)", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const completedSpy = vi.fn() + const completionSpy = vi.fn() + const noShellIntegrationSpy = vi.fn() + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const result = terminal.runCommand("git status", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: completionSpy, + onNoShellIntegration: noShellIntegrationSpy, + }) + const process = terminal.process + expect(process).toBeInstanceOf(TerminalProcess) + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: undefined, reason: 3 }, + configurable: true, + }) + + closeHandler(terminal.terminal) + await settleWithin(result) + + expect(completionSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledWith("", process) + expect(noShellIntegrationSpy).not.toHaveBeenCalled() + expect(terminal.process).toBeUndefined() + expect(terminal.busy).toBe(false) + }) + + it("does not submit a command when shell integration resolves immediately before terminal closure", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const executeCommand = vi.fn(() => { + throw new Error("command should not execute after terminal closure") + }) + const completedSpy = vi.fn() + const completionSpy = vi.fn() + const noShellIntegrationSpy = vi.fn() + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const result = terminal.runCommand("git status", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: completionSpy, + onNoShellIntegration: noShellIntegrationSpy, + }) + const process = terminal.process + expect(process).toBeInstanceOf(TerminalProcess) + Object.defineProperty(terminal.terminal, "shellIntegration", { + value: { executeCommand }, + configurable: true, + }) + + shellIntegrationHandler({ + terminal: terminal.terminal, + shellIntegration: terminal.terminal.shellIntegration!, + }) + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: undefined, reason: 3 }, + configurable: true, + }) + closeHandler(terminal.terminal) + await settleWithin(result) + + expect(executeCommand).not.toHaveBeenCalled() + expect(completionSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledWith("", process) + expect(noShellIntegrationSpy).not.toHaveBeenCalled() + }) + + it("marks closure explicitly and completes only once when exitStatus remains undefined", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const completedSpy = vi.fn() + const completionSpy = vi.fn() + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const result = terminal.runCommand("git status", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: completionSpy, + }) + const process = terminal.process + expect(process).toBeInstanceOf(TerminalProcess) + + expect(terminal.terminal.exitStatus).toBeUndefined() + const shellCompleteSpy = vi.spyOn(terminal, "shellExecutionComplete") + terminal.handleClose() + terminal.handleClose() + await settleWithin(result) + + expect(terminal.isClosed()).toBe(true) + expect(completionSpy).toHaveBeenCalledOnce() + expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }, process) + expect(completedSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledWith("", process) + expect(terminal.process).toBeUndefined() + expect(terminal.busy).toBe(false) + expect(terminal.running).toBe(false) + expect(shellCompleteSpy).toHaveBeenCalledOnce() + }) + + it("does not start a command invoked after the terminal has already closed", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const executeCommand = vi.fn() + Object.defineProperty(terminal.terminal, "shellIntegration", { + value: { executeCommand }, + configurable: true, + }) + terminal.handleClose() + const completedSpy = vi.fn() + const completionSpy = vi.fn() + + const result = terminal.runCommand("git status", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: completionSpy, + }) + await settleWithin(result) + const completedProcess = completedSpy.mock.calls[0][1] + + expect(executeCommand).not.toHaveBeenCalled() + expect(completionSpy).toHaveBeenCalledOnce() + expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }, completedProcess) + expect(completedSpy).toHaveBeenCalledWith("", completedProcess) + expect(completedProcess).toBeInstanceOf(TerminalProcess) + expect(terminal.busy).toBe(false) + }) + + it("settles a shell-integration wait once and ignores unrelated terminal events", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const disposeSpy = vi.fn() + let waitHandler: (event: vscode.TerminalShellIntegrationChangeEvent) => void = () => {} + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementationOnce((handler) => { + waitHandler = handler + return { dispose: disposeSpy } + }) + const wait = terminal["waitForShellIntegration"](100) + const settledSpy = vi.fn() + void wait.then(settledSpy) + + waitHandler({ terminal: { name: "foreign" } as vscode.Terminal, shellIntegration: {} as never }) + await Promise.resolve() + expect(settledSpy).not.toHaveBeenCalled() + + const event = { terminal: terminal.terminal, shellIntegration: {} as never } + waitHandler(event) + waitHandler(event) + await settleWithin(wait) + + expect(settledSpy).toHaveBeenCalledOnce() + expect(disposeSpy).toHaveBeenCalledOnce() + expect(terminal["cancelShellIntegrationWait"]).toBeUndefined() + }) + + it("does not let an older shell-integration wait clear a newer cancellation", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const handlers: Array<(event: vscode.TerminalShellIntegrationChangeEvent) => void> = [] + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementation((handler) => { + handlers.push(handler) + return { dispose: vi.fn() } + }) + const first = terminal["waitForShellIntegration"](100) + const firstCancel = terminal["cancelShellIntegrationWait"] + const second = terminal["waitForShellIntegration"](100) + const secondCancel = terminal["cancelShellIntegrationWait"] + + expect(firstCancel).not.toBe(secondCancel) + handlers[0]({ terminal: terminal.terminal, shellIntegration: {} as never }) + await settleWithin(first) + expect(terminal["cancelShellIntegrationWait"]).toBe(secondCancel) + + handlers[1]({ terminal: terminal.terminal, shellIntegration: {} as never }) + await settleWithin(second) + expect(terminal["cancelShellIntegrationWait"]).toBeUndefined() + }) + + it("rejects a direct shell-integration wait when the terminal is already closed", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + terminal.handleClose() + + await expect(terminal["waitForShellIntegration"](100)).rejects.toThrow( + "Terminal closed before shell integration became available", + ) + }) + + it("clears the timeout and disposes the listener when shell integration activates", async () => { + vi.useFakeTimers() + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const disposeSpy = vi.fn() + let waitHandler: (event: vscode.TerminalShellIntegrationChangeEvent) => void = () => {} + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementationOnce((handler) => { + waitHandler = handler + return { dispose: disposeSpy } + }) + const wait = terminal["waitForShellIntegration"](1_000) + + waitHandler({ terminal: terminal.terminal, shellIntegration: {} as never }) + await wait + + expect(disposeSpy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it("reports the configured timeout and releases wait resources", async () => { + vi.useFakeTimers() + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const disposeSpy = vi.fn() + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementationOnce(() => ({ + dispose: disposeSpy, + })) + const rejectedSpy = vi.fn() + void terminal["waitForShellIntegration"](1_500).catch(rejectedSpy) + + await vi.advanceTimersByTimeAsync(1_500) + + expect(rejectedSpy).toHaveBeenCalledOnce() + expect(rejectedSpy.mock.calls[0][0]).toEqual(new Error("Shell integration did not activate within 1.5s")) + expect(disposeSpy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it("cancels a pending shell-integration wait with the terminal-close reason", async () => { + vi.useFakeTimers() + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const disposeSpy = vi.fn() + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementationOnce(() => ({ + dispose: disposeSpy, + })) + const rejectedSpy = vi.fn() + void terminal["waitForShellIntegration"](1_000).catch(rejectedSpy) + + terminal["cancelShellIntegrationWait"]?.() + await Promise.resolve() + + expect(rejectedSpy).toHaveBeenCalledOnce() + expect(rejectedSpy.mock.calls[0][0]).toEqual( + new Error("Terminal closed before shell integration became available"), + ) + expect(disposeSpy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it("uses the native exit status to recognize closure before the close event is handled", () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + expect(terminal.isClosed()).toBe(false) + + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: 0, reason: 2 }, + configurable: true, + }) + + expect(terminal.isClosed()).toBe(true) + }) + + it("does not finalize a process twice when its terminal closes after the end event", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const execution = { commandLine: { value: "git status" } } as vscode.TerminalShellExecution + const process = new TerminalProcess(terminal) + process.ownExecution = execution + terminal.process = process + terminal.busy = true + terminal.running = true + const completionSpy = vi.fn() + process.on("shell_execution_complete", completionSpy) + + await endHandler({ terminal: terminal.terminal, execution, exitCode: 0 }) + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: 0, reason: 2 }, + configurable: true, + }) + closeHandler(terminal.terminal) + + expect(completionSpy).toHaveBeenCalledOnce() + expect(completionSpy).toHaveBeenCalledWith(expect.objectContaining({ exitCode: 0 })) + }) + it( "ignores a late end event for a superseded execution instead of completing " + "the next command on the same reused terminal",