fix: resolve process-cleanup signal delay and hashline_edit tool name mismatch

This commit is contained in:
YeonGyu-Kim
2026-03-31 16:59:04 -07:00
parent 9f2c4500e8
commit 7d6f47bf3d
4 changed files with 193 additions and 115 deletions
@@ -1,162 +1,206 @@
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { import {
_resetForTesting,
registerManagerForCleanup, registerManagerForCleanup,
unregisterManagerForCleanup, unregisterManagerForCleanup,
_resetForTesting,
} from "./process-cleanup" } from "./process-cleanup"
describe("process-cleanup", () => { type CleanupManager = {
const registeredManagers: Array<{ shutdown: () => void }> = [] shutdown: () => void | Promise<void>
const mockShutdown = mock(() => {}) }
const processOnCalls: Array<[string, Function]> = [] type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
const processOffCalls: Array<[string, Function]> = []
const originalProcessOn = process.on.bind(process) function getNewListener(
const originalProcessOff = process.off.bind(process) signal: ProcessCleanupEvent,
existingListeners: Function[],
): () => void {
const listener = process
.listeners(signal)
.find((registeredListener) => !existingListeners.includes(registeredListener))
expect(listener).toBeDefined()
if (typeof listener !== "function") {
throw new Error(`Expected a ${signal} listener to be registered`)
}
return listener
}
async function flushMicrotasks(): Promise<void> {
for (let iteration = 0; iteration < 10; iteration += 1) {
await Promise.resolve()
}
}
describe("#given process cleanup registration", () => {
const registeredManagers: CleanupManager[] = []
const originalExitCode = process.exitCode
beforeEach(() => { beforeEach(() => {
mockShutdown.mockClear() process.exitCode = originalExitCode
processOnCalls.length = 0
processOffCalls.length = 0
registeredManagers.length = 0 registeredManagers.length = 0
process.on = originalProcessOn as any
process.off = originalProcessOff as any
_resetForTesting() _resetForTesting()
process.on = ((event: string, listener: Function) => {
processOnCalls.push([event, listener])
return process
}) as any
process.off = ((event: string, listener: Function) => {
processOffCalls.push([event, listener])
return process
}) as any
}) })
afterEach(() => { afterEach(() => {
process.on = originalProcessOn as any
process.off = originalProcessOff as any
for (const manager of [...registeredManagers]) { for (const manager of [...registeredManagers]) {
unregisterManagerForCleanup(manager) unregisterManagerForCleanup(manager)
} }
process.exitCode = originalExitCode
_resetForTesting()
}) })
describe("registerManagerForCleanup", () => { describe("#given the first cleanup manager", () => {
test("registers signal handlers on first manager", () => { test("#when registerManagerForCleanup runs #then signal handlers are registered", () => {
const manager = { shutdown: mockShutdown } const sigintListenersBefore = process.listeners("SIGINT")
const sigtermListenersBefore = process.listeners("SIGTERM")
const beforeExitListenersBefore = process.listeners("beforeExit")
const exitListenersBefore = process.listeners("exit")
const manager = { shutdown: mock(() => {}) }
registeredManagers.push(manager) registeredManagers.push(manager)
registerManagerForCleanup(manager) registerManagerForCleanup(manager)
const signals = processOnCalls.map(([signal]) => signal) expect(process.listeners("SIGINT")).toHaveLength(sigintListenersBefore.length + 1)
expect(signals).toContain("SIGINT") expect(process.listeners("SIGTERM")).toHaveLength(sigtermListenersBefore.length + 1)
expect(signals).toContain("SIGTERM") expect(process.listeners("beforeExit")).toHaveLength(beforeExitListenersBefore.length + 1)
expect(signals).toContain("beforeExit") expect(process.listeners("exit")).toHaveLength(exitListenersBefore.length + 1)
expect(signals).toContain("exit")
if (process.platform === "win32") {
expect(process.listeners("SIGBREAK").length).toBeGreaterThan(0)
}
}) })
test("signal listener calls shutdown on registered manager", () => { test("#when the exit listener runs #then the registered manager shuts down", () => {
const manager = { shutdown: mockShutdown } const exitListenersBefore = process.listeners("exit")
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager) registeredManagers.push(manager)
registerManagerForCleanup(manager) registerManagerForCleanup(manager)
const exitEntry = processOnCalls.find(([signal]) => signal === "exit") const exitListener = getNewListener("exit", exitListenersBefore)
expect(exitEntry).toBeDefined() exitListener()
const [, listener] = exitEntry!
listener()
expect(mockShutdown).toHaveBeenCalled() expect(shutdown).toHaveBeenCalledTimes(1)
}) })
test("multiple managers all get shutdown when signal fires", () => { test("#when cleanup finishes after SIGINT #then the fallback exit timer is cleared", async () => {
const shutdown1 = mock(() => {}) const sigintListenersBefore = process.listeners("SIGINT")
const shutdown2 = mock(() => {}) const timeoutHandle = setTimeout(() => undefined, 0)
const shutdown3 = mock(() => {}) clearTimeout(timeoutHandle)
const manager1 = { shutdown: shutdown1 }
const manager2 = { shutdown: shutdown2 }
const manager3 = { shutdown: shutdown3 }
registeredManagers.push(manager1, manager2, manager3)
registerManagerForCleanup(manager1) const setTimeoutImplementation: typeof setTimeout = () => timeoutHandle
registerManagerForCleanup(manager2) const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(
registerManagerForCleanup(manager3) setTimeoutImplementation,
)
const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")
const exitEntry = processOnCalls.find(([signal]) => signal === "exit") try {
expect(exitEntry).toBeDefined() const manager = {
const [, listener] = exitEntry! shutdown: mock(async () => {
listener() await Promise.resolve()
}),
}
registeredManagers.push(manager)
expect(shutdown1).toHaveBeenCalledTimes(1) registerManagerForCleanup(manager)
expect(shutdown2).toHaveBeenCalledTimes(1)
expect(shutdown3).toHaveBeenCalledTimes(1)
})
test("does not re-register signal handlers for subsequent managers", () => { const sigintListener = getNewListener("SIGINT", sigintListenersBefore)
const manager1 = { shutdown: mockShutdown }
const manager2 = { shutdown: mockShutdown }
registeredManagers.push(manager1, manager2)
registerManagerForCleanup(manager1) sigintListener()
const callsAfterFirst = processOnCalls.length await flushMicrotasks()
registerManagerForCleanup(manager2) expect(setTimeoutSpy).toHaveBeenCalledTimes(1)
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle)
expect(processOnCalls.length).toBe(callsAfterFirst) } finally {
setTimeoutSpy.mockRestore()
clearTimeoutSpy.mockRestore()
clearTimeout(timeoutHandle)
}
}) })
}) })
describe("unregisterManagerForCleanup", () => { describe("#given multiple cleanup managers", () => {
test("removes signal handlers when last manager unregisters", () => { test("#when the exit listener runs #then every registered manager shuts down", () => {
const manager = { shutdown: mockShutdown } const exitListenersBefore = process.listeners("exit")
const shutdownOne = mock(() => {})
const shutdownTwo = mock(() => {})
const shutdownThree = mock(() => {})
const managers = [
{ shutdown: shutdownOne },
{ shutdown: shutdownTwo },
{ shutdown: shutdownThree },
]
registeredManagers.push(...managers)
for (const manager of managers) {
registerManagerForCleanup(manager)
}
const exitListener = getNewListener("exit", exitListenersBefore)
exitListener()
expect(shutdownOne).toHaveBeenCalledTimes(1)
expect(shutdownTwo).toHaveBeenCalledTimes(1)
expect(shutdownThree).toHaveBeenCalledTimes(1)
})
test("#when another manager registers #then signal handlers are not duplicated", () => {
const managerOne = { shutdown: mock(() => {}) }
const managerTwo = { shutdown: mock(() => {}) }
registeredManagers.push(managerOne, managerTwo)
registerManagerForCleanup(managerOne)
const sigintListenersAfterFirstRegistration = process.listeners("SIGINT").length
registerManagerForCleanup(managerTwo)
expect(process.listeners("SIGINT")).toHaveLength(sigintListenersAfterFirstRegistration)
})
})
describe("#given cleanup managers are unregistered", () => {
test("#when the last manager unregisters #then signal handlers are removed", () => {
const sigintListenersBefore = process.listeners("SIGINT")
const sigtermListenersBefore = process.listeners("SIGTERM")
const beforeExitListenersBefore = process.listeners("beforeExit")
const exitListenersBefore = process.listeners("exit")
const manager = { shutdown: mock(() => {}) }
registeredManagers.push(manager) registeredManagers.push(manager)
registerManagerForCleanup(manager) registerManagerForCleanup(manager)
unregisterManagerForCleanup(manager) unregisterManagerForCleanup(manager)
registeredManagers.length = 0 registeredManagers.length = 0
const offSignals = processOffCalls.map(([signal]) => signal) expect(process.listeners("SIGINT")).toHaveLength(sigintListenersBefore.length)
expect(offSignals).toContain("SIGINT") expect(process.listeners("SIGTERM")).toHaveLength(sigtermListenersBefore.length)
expect(offSignals).toContain("SIGTERM") expect(process.listeners("beforeExit")).toHaveLength(beforeExitListenersBefore.length)
expect(offSignals).toContain("beforeExit") expect(process.listeners("exit")).toHaveLength(exitListenersBefore.length)
expect(offSignals).toContain("exit")
}) })
test("keeps signal handlers when other managers remain", () => { test("#when one manager remains registered #then cleanup handlers stay active for it", () => {
const manager1 = { shutdown: mockShutdown } const exitListenersBefore = process.listeners("exit")
const manager2 = { shutdown: mockShutdown } const remainingManagerShutdown = mock(() => {})
registeredManagers.push(manager1, manager2) const removedManagerShutdown = mock(() => {})
const remainingManager = { shutdown: remainingManagerShutdown }
const removedManager = { shutdown: removedManagerShutdown }
registeredManagers.push(remainingManager, removedManager)
registerManagerForCleanup(manager1) registerManagerForCleanup(remainingManager)
registerManagerForCleanup(manager2) registerManagerForCleanup(removedManager)
unregisterManagerForCleanup(removedManager)
unregisterManagerForCleanup(manager2) const exitListener = getNewListener("exit", exitListenersBefore)
exitListener()
expect(processOffCalls.length).toBe(0) expect(remainingManagerShutdown).toHaveBeenCalledTimes(1)
}) expect(removedManagerShutdown).not.toHaveBeenCalled()
test("remaining managers still get shutdown after partial unregister", () => {
const shutdown1 = mock(() => {})
const shutdown2 = mock(() => {})
const manager1 = { shutdown: shutdown1 }
const manager2 = { shutdown: shutdown2 }
registeredManagers.push(manager1, manager2)
registerManagerForCleanup(manager1)
registerManagerForCleanup(manager2)
const exitEntry = processOnCalls.find(([signal]) => signal === "exit")
expect(exitEntry).toBeDefined()
const [, listener] = exitEntry!
unregisterManagerForCleanup(manager2)
listener()
expect(shutdown1).toHaveBeenCalledTimes(1)
expect(shutdown2).not.toHaveBeenCalled()
}) })
}) })
}) })
@@ -4,14 +4,17 @@ type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
function registerProcessSignal( function registerProcessSignal(
signal: ProcessCleanupEvent, signal: ProcessCleanupEvent,
handler: () => void, handler: () => void | Promise<void>,
exitAfter: boolean exitAfter: boolean
): () => void { ): () => void {
const listener = () => { const listener = () => {
handler() const cleanupResult = handler()
if (exitAfter) { if (exitAfter) {
process.exitCode = 0 process.exitCode = 0
setTimeout(() => process.exit(), 6000) const exitTimeout = setTimeout(() => process.exit(), 6000)
void Promise.resolve(cleanupResult).finally(() => {
clearTimeout(exitTimeout)
})
} }
} }
process.on(signal, listener) process.on(signal, listener)
@@ -34,8 +37,8 @@ export function registerManagerForCleanup(manager: CleanupTarget): void {
let cleanupPromise: Promise<void> | undefined let cleanupPromise: Promise<void> | undefined
const cleanupAll = () => { const cleanupAll = (): Promise<void> => {
if (cleanupPromise) return if (cleanupPromise) return cleanupPromise
const promises: Promise<void>[] = [] const promises: Promise<void>[] = []
for (const m of cleanupManagers) { for (const m of cleanupManagers) {
try { try {
@@ -52,6 +55,8 @@ export function registerManagerForCleanup(manager: CleanupTarget): void {
cleanupPromise.then(() => { cleanupPromise.then(() => {
log("[background-agent] All shutdown cleanup completed") log("[background-agent] All shutdown cleanup completed")
}) })
return cleanupPromise
} }
const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean): void => { const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean): void => {
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { tool } from "@opencode-ai/plugin"
import type { ToolsRecord } from "./types"
import { trimToolsToCap } from "./tool-registry"
const fakeTool = tool({
description: "test tool",
args: {},
async execute(): Promise<string> {
return "ok"
},
})
describe("#given tool trimming prioritization", () => {
test("#when max_tools trims a hashline edit registration named edit #then edit is removed before higher-priority tools", () => {
const filteredTools = {
bash: fakeTool,
edit: fakeTool,
read: fakeTool,
} satisfies ToolsRecord
trimToolsToCap(filteredTools, 2)
expect(filteredTools).not.toHaveProperty("edit")
expect(filteredTools).toHaveProperty("bash")
expect(filteredTools).toHaveProperty("read")
})
})
+2 -2
View File
@@ -54,7 +54,7 @@ const LOW_PRIORITY_TOOL_ORDER = [
"task_update", "task_update",
"background_output", "background_output",
"background_cancel", "background_cancel",
"hashline_edit", "edit",
"ast_grep_replace", "ast_grep_replace",
"ast_grep_search", "ast_grep_search",
"glob", "glob",
@@ -70,7 +70,7 @@ const LOW_PRIORITY_TOOL_ORDER = [
"lsp_diagnostics", "lsp_diagnostics",
] as const ] as const
function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): void { export function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): void {
const toolNames = Object.keys(filteredTools) const toolNames = Object.keys(filteredTools)
if (toolNames.length <= maxTools) return if (toolNames.length <= maxTools) return