From ef7a11fca5098892254a70c8587541973c90b001 Mon Sep 17 00:00:00 2001 From: neriousy Date: Sat, 21 Feb 2026 15:53:05 +0100 Subject: [PATCH 1/3] fix: reject queued loop callbacks on cancel --- packages/opencode/src/session/prompt.ts | 18 +++++- .../test/session/prompt-cancel.test.ts | 58 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/session/prompt-cancel.test.ts diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 75bd3c9dfaca..b207289f2eb3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -78,6 +78,12 @@ export namespace SessionPrompt { }, async (current) => { for (const item of Object.values(current)) { + const error = new Error("Instance disposed") + const queued = item.callbacks + item.callbacks = [] + for (const q of queued) { + q.reject(error) + } item.abort.abort() } }, @@ -261,6 +267,14 @@ export namespace SessionPrompt { SessionStatus.set(sessionID, { type: "idle" }) return } + + const error = new Error("Session cancelled") + const queued = match.callbacks + match.callbacks = [] + for (const q of queued) { + q.reject(error) + } + match.abort.abort() delete s[sessionID] SessionStatus.set(sessionID, { type: "idle" }) @@ -714,7 +728,9 @@ export namespace SessionPrompt { SessionCompaction.prune({ sessionID }) for await (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user") continue - const queued = state()[sessionID]?.callbacks ?? [] + const match = state()[sessionID] + const queued = match?.callbacks ?? [] + if (match) match.callbacks = [] for (const q of queued) { q.resolve(item) } diff --git a/packages/opencode/test/session/prompt-cancel.test.ts b/packages/opencode/test/session/prompt-cancel.test.ts new file mode 100644 index 000000000000..796ddc01caa4 --- /dev/null +++ b/packages/opencode/test/session/prompt-cancel.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("session.prompt cancel", () => { + test("rejects queued loop callbacks", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + + const command = process.platform === "win32" ? "ping -n 6 127.0.0.1 >nul" : "sleep 5" + + const running = SessionPrompt.shell({ + sessionID: session.id, + agent: "build", + command, + }) + void running.catch(() => {}) + + const queued = SessionPrompt.loop({ sessionID: session.id }) + SessionPrompt.cancel(session.id) + + const result = await Promise.race([ + queued.then( + () => ({ type: "resolved" as const }), + (error) => ({ type: "rejected" as const, error }), + ), + Bun.sleep(200).then(() => ({ type: "timeout" as const })), + ]) + + expect(result.type).toBe("rejected") + if (result.type === "rejected") { + expect(result.error).toBeInstanceOf(Error) + expect((result.error as Error).message).toBe("Session cancelled") + } + + await Session.remove(session.id) + }, + }) + }) +}) From 0598136895336d92588a72dc8e7e457fa876d810 Mon Sep 17 00:00:00 2001 From: neriousy Date: Sat, 21 Feb 2026 16:29:37 +0100 Subject: [PATCH 2/3] fix: avoid unhandled rejections on dispose --- packages/opencode/src/session/prompt.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b207289f2eb3..54e828afaeb5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -78,12 +78,9 @@ export namespace SessionPrompt { }, async (current) => { for (const item of Object.values(current)) { - const error = new Error("Instance disposed") - const queued = item.callbacks + // Avoid unhandled rejections during process/Instance teardown. + // Active loops/shells will observe abort and run their own deferred cleanup. item.callbacks = [] - for (const q of queued) { - q.reject(error) - } item.abort.abort() } }, From 79d5eda70ac7839e3df5466475c0c5d6e03ab649 Mon Sep 17 00:00:00 2001 From: neriousy Date: Sat, 21 Feb 2026 16:34:31 +0100 Subject: [PATCH 3/3] chore: remove teardown comment --- packages/opencode/src/session/prompt.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 54e828afaeb5..7bace09ea37b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -78,8 +78,6 @@ export namespace SessionPrompt { }, async (current) => { for (const item of Object.values(current)) { - // Avoid unhandled rejections during process/Instance teardown. - // Active loops/shells will observe abort and run their own deferred cleanup. item.callbacks = [] item.abort.abort() }