From c24c4a85b49fc369d96c8acc67a961d613371e5c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 14 Feb 2026 14:01:20 +0900 Subject: [PATCH] fix(cli-run): bounded shutdown wait for event stream processor Prevents Run CLI from hanging indefinitely when the event stream fails to close after abort. Fixes #1825 Co-authored-by: cloudwaddie-agent --- src/cli/run/runner.test.ts | 61 ++++++++++++++++++++++++++++++++++++-- src/cli/run/runner.ts | 32 +++++++++++++++----- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/cli/run/runner.test.ts b/src/cli/run/runner.test.ts index 678a26ea5..b7d532949 100644 --- a/src/cli/run/runner.test.ts +++ b/src/cli/run/runner.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect } from "bun:test" +/// + +import { describe, it, expect, spyOn, afterEach } from "bun:test" import type { OhMyOpenCodeConfig } from "../../config" -import { resolveRunAgent } from "./runner" +import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner" const createConfig = (overrides: Partial = {}): OhMyOpenCodeConfig => ({ ...overrides, @@ -68,3 +70,58 @@ describe("resolveRunAgent", () => { expect(agent).toBe("hephaestus") }) }) + +describe("waitForEventProcessorShutdown", () => { + let consoleLogSpy: ReturnType> | null = null + + afterEach(() => { + if (consoleLogSpy) { + consoleLogSpy.mockRestore() + consoleLogSpy = null + } + }) + + it("returns quickly when event processor completes", async () => { + //#given + const eventProcessor = new Promise((resolve) => { + setTimeout(() => { + resolve() + }, 25) + }) + consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}) + const start = performance.now() + + //#when + await waitForEventProcessorShutdown(eventProcessor, 200) + + //#then + const elapsed = performance.now() - start + expect(elapsed).toBeLessThan(200) + expect(console.log).not.toHaveBeenCalledWith( + "[run] Event stream did not close within 200ms after abort; continuing shutdown.", + ) + }) + + it("times out and continues when event processor does not complete", async () => { + //#given + const eventProcessor = new Promise(() => {}) + const spy = spyOn(console, "log").mockImplementation(() => {}) + consoleLogSpy = spy + const timeoutMs = 50 + const start = performance.now() + + try { + //#when + await waitForEventProcessorShutdown(eventProcessor, timeoutMs) + + //#then + const elapsed = performance.now() - start + expect(elapsed).toBeGreaterThanOrEqual(timeoutMs) + expect(spy).toHaveBeenCalledWith( + `[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`, + ) + } finally { + spy.mockRestore() + } + }) +}) diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index 2c467c14c..d74945075 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -12,6 +12,25 @@ import { pollForCompletion } from "./poll-for-completion" export { resolveRunAgent } const DEFAULT_TIMEOUT_MS = 600_000 +const EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS = 2_000 + +export async function waitForEventProcessorShutdown( + eventProcessor: Promise, + timeoutMs = EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS, +): Promise { + const completed = await Promise.race([ + eventProcessor.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]) + + if (!completed) { + console.log( + pc.dim( + `[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`, + ), + ) + } +} export async function run(options: RunOptions): Promise { process.env.OPENCODE_CLI_RUN_MODE = "true" @@ -81,14 +100,14 @@ export async function run(options: RunOptions): Promise { query: { directory }, }) - console.log(pc.dim("Waiting for completion...\n")) - const exitCode = await pollForCompletion(ctx, eventState, abortController) + console.log(pc.dim("Waiting for completion...\n")) + const exitCode = await pollForCompletion(ctx, eventState, abortController) - // Abort the event stream to stop the processor - abortController.abort() + // Abort the event stream to stop the processor + abortController.abort() - await eventProcessor - cleanup() + await waitForEventProcessorShutdown(eventProcessor) + cleanup() const durationMs = Date.now() - startTime @@ -127,4 +146,3 @@ export async function run(options: RunOptions): Promise { return 1 } } -