diff --git a/.changeset/session-prompt-queue-memory-leak.md b/.changeset/session-prompt-queue-memory-leak.md new file mode 100644 index 00000000000..7a035f2f788 --- /dev/null +++ b/.changeset/session-prompt-queue-memory-leak.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent memory leak in KiloSessionPromptQueue.cancel for sessions without active tails diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index 405906eb9c8..fb454011b14 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -27,6 +27,11 @@ export namespace KiloSessionPromptQueue { const activeSince = new Map() let seq = 0 + /** @internal - test-only helper */ + export function _hasInternalState(sessionID: SessionID): boolean { + return versions.has(sessionID) || targets.has(sessionID) || latest.has(sessionID) || activeSince.has(sessionID) + } + const version = (sessionID: SessionID) => versions.get(sessionID) ?? 0 const settle = (promise: Promise) => promise.then( @@ -36,6 +41,13 @@ export namespace KiloSessionPromptQueue { export function cancel(sessionID: SessionID) { return Effect.sync(() => { + if (!tails.has(sessionID)) { + versions.delete(sessionID) + targets.delete(sessionID) + latest.delete(sessionID) + activeSince.delete(sessionID) + return + } versions.set(sessionID, version(sessionID) + 1) }) } diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 4d5cb905af1..0f4b317f5ac 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -491,6 +491,25 @@ describe("session prompt queue", () => { } }) + test("cancel on a session with no active tail is a no-op and does not leak state", async () => { + const sessionID = SessionID.make("session_cancel_noop") + + await Effect.runPromise(KiloSessionPromptQueue.cancel(sessionID)) + + expect(KiloSessionPromptQueue._hasInternalState(sessionID)).toBe(false) + + const result = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_probe"), + Effect.succeed("work executed"), + Effect.succeed("cancelled returned"), + ), + ) + + expect(result).toBe("work executed") + }) + test("cancel drops queued prompts and resets internal state", async () => { const ready = Promise.withResolvers() const calls: number[] = []