fix: resolve process-cleanup signal delay and hashline_edit tool name mismatch
This commit is contained in:
@@ -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 => {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user