From c24c4a85b49fc369d96c8acc67a961d613371e5c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 14 Feb 2026 14:01:20 +0900 Subject: [PATCH 1/2] 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 678a26ea..b7d53294 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 2c467c14..d7494507 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 } } - From b7c32e8f50771cd82c7ba2bc2f90a79c0ebab82a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 14 Feb 2026 14:13:52 +0900 Subject: [PATCH 2/2] fix(test): use string containment check for ANSI-wrapped console.log output The waitForEventProcessorShutdown test was comparing exact string match against console.log spy, but picocolors wraps the message in ANSI dim codes. On CI (bun 1.3.9) this caused the assertion to fail. Use string containment check instead of exact argument match. --- src/cli/run/runner.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/run/runner.test.ts b/src/cli/run/runner.test.ts index b7d53294..03f1b6e1 100644 --- a/src/cli/run/runner.test.ts +++ b/src/cli/run/runner.test.ts @@ -117,7 +117,8 @@ describe("waitForEventProcessorShutdown", () => { //#then const elapsed = performance.now() - start expect(elapsed).toBeGreaterThanOrEqual(timeoutMs) - expect(spy).toHaveBeenCalledWith( + const callArgs = spy.mock.calls.flat().join("") + expect(callArgs).toContain( `[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`, ) } finally {