Skip to content

Commit 26bc03c

Browse files
committed
fix(openai): abort print stream on signal
1 parent 7971da5 commit 26bc03c

4 files changed

Lines changed: 114 additions & 2 deletions

File tree

apps/cli/src/entrypoints/cli/print/runSingleTurn.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ import { MaxBudgetUsdExceededError } from '#core/errors/maxBudgetUsd'
55
import { MaxTurnsExceededError } from '#core/errors/maxTurns'
66
import { randomUUID } from 'crypto'
77

8+
const PRINT_MODE_ABORT_SIGNALS: NodeJS.Signals[] = [
9+
'SIGINT',
10+
'SIGTERM',
11+
'SIGBREAK',
12+
]
13+
14+
function installPrintModeSignalAbort(
15+
abortController: AbortController,
16+
): () => void {
17+
const abort = () => {
18+
abortController.abort()
19+
}
20+
21+
for (const signal of PRINT_MODE_ABORT_SIGNALS) {
22+
process.prependListener(signal, abort)
23+
}
24+
25+
return () => {
26+
for (const signal of PRINT_MODE_ABORT_SIGNALS) {
27+
process.removeListener(signal, abort)
28+
}
29+
}
30+
}
31+
832
type RunTurnFn = (args: {
933
messages: Message[]
1034
canUseTool: CanUseToolFn
@@ -54,6 +78,9 @@ export async function runSingleTurnPrint(args: {
5478
}): Promise<void> {
5579
let lastAssistant: Message | null = null
5680
let queryError: unknown = null
81+
const cleanupSignalAbort = installPrintModeSignalAbort(
82+
args.toolUseContext.abortController,
83+
)
5784

5885
try {
5986
for await (const m of args.runTurn({
@@ -75,6 +102,8 @@ export async function runSingleTurnPrint(args: {
75102
args.toolUseContext.abortController.abort()
76103
} catch {}
77104
queryError = e
105+
} finally {
106+
cleanupSignalAbort()
78107
}
79108

80109
const totalCostUsd = args.getTotalCostUsd()
@@ -219,3 +248,5 @@ export async function runSingleTurnPrint(args: {
219248
}
220249
process.exit((resultMsg as any)?.is_error ? 1 : 0)
221250
}
251+
252+
export const __installPrintModeSignalAbortForTests = installPrintModeSignalAbort

packages/core/src/ai/llm/openai/stream.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ function messageReducer(
5252
return reduce(previous, choice.delta) as OpenAI.ChatCompletionMessage
5353
}
5454

55+
function throwIfAborted(signal?: AbortSignal): void {
56+
if (signal?.aborted) {
57+
throw new Error('Request was cancelled')
58+
}
59+
}
60+
5561
export async function handleMessageStream(
5662
stream: ChatCompletionStream,
5763
signal?: AbortSignal,
@@ -71,13 +77,16 @@ export async function handleMessageStream(
7177

7278
let id, model, created, object, usage
7379
try {
80+
throwIfAborted(signal)
7481
for await (const chunk of stream) {
75-
if (signal?.aborted) {
82+
try {
83+
throwIfAborted(signal)
84+
} catch (error) {
7685
debugLogger.flow('OPENAI_STREAM_ABORTED', {
7786
chunkCount,
7887
timestamp: Date.now(),
7988
})
80-
throw new Error('Request was cancelled')
89+
throw error
8190
}
8291

8392
chunkCount++
@@ -148,6 +157,8 @@ export async function handleMessageStream(
148157
}
149158
}
150159

160+
throwIfAborted(signal)
161+
151162
debugLogger.api('OPENAI_STREAM_COMPLETE', {
152163
totalChunks: String(chunkCount),
153164
errorCount: String(errorCount),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { handleMessageStream } from '#core/ai/llm/openai/stream'
3+
4+
function chunk(delta: Record<string, unknown>) {
5+
return {
6+
id: 'chatcmpl_test',
7+
model: 'gpt-4',
8+
created: 1,
9+
object: 'chat.completion.chunk',
10+
choices: [{ index: 0, delta, finish_reason: null }],
11+
}
12+
}
13+
14+
describe('OpenAI stream cancellation', () => {
15+
test('rejects when signal is aborted before reading stream chunks', async () => {
16+
const controller = new AbortController()
17+
controller.abort()
18+
19+
async function* stream() {
20+
yield chunk({ content: 'late' })
21+
}
22+
23+
await expect(
24+
handleMessageStream(stream() as any, controller.signal),
25+
).rejects.toThrow('Request was cancelled')
26+
})
27+
28+
test('does not return a partial response when signal aborts after a chunk', async () => {
29+
const controller = new AbortController()
30+
31+
async function* stream() {
32+
yield chunk({ content: 'partial' })
33+
controller.abort()
34+
}
35+
36+
await expect(
37+
handleMessageStream(stream() as any, controller.signal),
38+
).rejects.toThrow('Request was cancelled')
39+
})
40+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { __installPrintModeSignalAbortForTests } from '#host-cli/entrypoints/cli/print/runSingleTurn'
3+
4+
describe('print mode signal cancellation', () => {
5+
test('SIGINT aborts the active print turn controller', () => {
6+
const controller = new AbortController()
7+
const listenerCountBefore = process.listenerCount('SIGINT')
8+
const cleanup = __installPrintModeSignalAbortForTests(controller)
9+
10+
try {
11+
expect(process.listenerCount('SIGINT')).toBe(listenerCountBefore + 1)
12+
process.emit('SIGINT')
13+
expect(controller.signal.aborted).toBe(true)
14+
} finally {
15+
cleanup()
16+
}
17+
18+
expect(process.listenerCount('SIGINT')).toBe(listenerCountBefore)
19+
})
20+
21+
test('cleanup removes print turn signal handlers', () => {
22+
const controller = new AbortController()
23+
const cleanup = __installPrintModeSignalAbortForTests(controller)
24+
cleanup()
25+
26+
process.emit('SIGINT')
27+
28+
expect(controller.signal.aborted).toBe(false)
29+
})
30+
})

0 commit comments

Comments
 (0)