From a2f030e699d3f446ec32e1c18413f8a612dce4a2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 20:09:34 +0900 Subject: [PATCH] fix(todo-continuation-enforcer): expose prune interval for cleanup Prune interval created inside hook was not exposed for disposal, preventing cleanup on plugin unload. - Add dispose() method that clears the prune interval - Export dispose in hook return type Tests: 2 pass, 6 expects --- .../dispose.test.ts | 101 ++++++++++++++++++ src/hooks/todo-continuation-enforcer/index.ts | 1 + src/hooks/todo-continuation-enforcer/types.ts | 1 + 3 files changed, 103 insertions(+) create mode 100644 src/hooks/todo-continuation-enforcer/dispose.test.ts diff --git a/src/hooks/todo-continuation-enforcer/dispose.test.ts b/src/hooks/todo-continuation-enforcer/dispose.test.ts new file mode 100644 index 000000000..5423c8068 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/dispose.test.ts @@ -0,0 +1,101 @@ +declare module "bun:test" { + export interface Matchers { + toBeDefined(): void + toBeUndefined(): void + toHaveLength(expected: number): void + } +} + +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test" + +import * as actualSessionStateModule from "./session-state" +import type { SessionStateStore } from "./session-state" + +let createdSessionStateStore: SessionStateStore | undefined +const createActualSessionStateStore = actualSessionStateModule.createSessionStateStore + +const mockModule = mock as typeof mock & { + module: (specifier: string, factory: () => unknown) => void +} + +mockModule.module("./session-state", () => ({ + ...actualSessionStateModule, + createSessionStateStore: () => { + const sessionStateStore = createActualSessionStateStore() + createdSessionStateStore = sessionStateStore + return sessionStateStore + }, +})) + +const { createTodoContinuationEnforcer } = await import(".") + +type PluginInput = Parameters[0] + +function createMockPluginInput(): PluginInput { + return { + directory: "/tmp/test", + } as PluginInput +} + +function getCreatedSessionStateStore(): SessionStateStore { + if (!createdSessionStateStore) { + throw new Error("expected session state store to be created") + } + + return createdSessionStateStore +} + +describe("todo-continuation-enforcer dispose", () => { + afterEach(() => { + createdSessionStateStore?.shutdown() + createdSessionStateStore = undefined + }) + + afterAll(() => { + mockModule.module("./session-state", () => actualSessionStateModule) + }) + + it("#given todo-continuation-enforcer created #when dispose exists on return value #then it is a function", () => { + // given + const enforcer = createTodoContinuationEnforcer(createMockPluginInput()) + + // when + const { dispose } = enforcer + + // then + expect(typeof dispose).toBe("function") + + enforcer.dispose() + }) + + it("#given enforcer with active session states #when dispose is called #then internal session state store is shut down", () => { + // given + const originalClearInterval = globalThis.clearInterval + const clearIntervalCalls: Array[0]> = [] + globalThis.clearInterval = ((timer?: Parameters[0]) => { + clearIntervalCalls.push(timer) + return originalClearInterval(timer) + }) as typeof clearInterval + + try { + const enforcer = createTodoContinuationEnforcer(createMockPluginInput()) + const sessionStateStore = getCreatedSessionStateStore() + + enforcer.markRecovering("session-1") + enforcer.markRecovering("session-2") + + expect(sessionStateStore.getExistingState("session-1")).toBeDefined() + expect(sessionStateStore.getExistingState("session-2")).toBeDefined() + + // when + enforcer.dispose() + + // then + expect(clearIntervalCalls).toHaveLength(1) + expect(sessionStateStore.getExistingState("session-1")).toBeUndefined() + expect(sessionStateStore.getExistingState("session-2")).toBeUndefined() + } finally { + globalThis.clearInterval = originalClearInterval + } + }) +}) diff --git a/src/hooks/todo-continuation-enforcer/index.ts b/src/hooks/todo-continuation-enforcer/index.ts index b7e376563..edeba85b5 100644 --- a/src/hooks/todo-continuation-enforcer/index.ts +++ b/src/hooks/todo-continuation-enforcer/index.ts @@ -56,5 +56,6 @@ export function createTodoContinuationEnforcer( markRecovering, markRecoveryComplete, cancelAllCountdowns, + dispose: () => sessionStateStore.shutdown(), } } diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 2221a14c0..c801ab128 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -13,6 +13,7 @@ export interface TodoContinuationEnforcer { markRecovering: (sessionID: string) => void markRecoveryComplete: (sessionID: string) => void cancelAllCountdowns: () => void + dispose: () => void } export interface Todo {