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
}
}
-