-
Notifications
You must be signed in to change notification settings - Fork 1
Add model stream idle-timeout handling #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { MessageV2 } from "./message-v2" | ||
|
|
||
| export namespace StreamIdle { | ||
| export function signal(input?: AbortSignal) { | ||
| const controller = new AbortController() | ||
| return { | ||
| controller, | ||
| signal: input ? AbortSignal.any([input, controller.signal]) : controller.signal, | ||
| } | ||
| } | ||
|
|
||
| export async function* timeout<T>(stream: AsyncIterable<T>, ms: number, abort: () => void) { | ||
| if (ms === 0) { | ||
| yield* stream | ||
| return | ||
| } | ||
|
|
||
| const iterator = stream[Symbol.asyncIterator]() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 Idle wrapper never releases inner iterator on exit. 🤖 Fix with your agentWhy this matters
if (ms === 0) {
yield* stream
return
}
const iterator = stream[Symbol.asyncIterator]()
while (true) {
const timer = Promise.withResolvers<never>()
const id = setTimeout(() => {
timer.reject(
new MessageV2.StreamIdleTimeoutError({
message: `Model stream produced no events for ${ms}ms`,
timeout: ms,
}),
)
abort()
}, ms)
const next = await Promise.race([iterator.next(), timer.promise]).finally(() => clearTimeout(id))
if (next.done) return
yield next.value
} |
||
| try { | ||
| while (true) { | ||
| const timer = Promise.withResolvers<never>() | ||
| const id = setTimeout(() => { | ||
| timer.reject( | ||
| new MessageV2.StreamIdleTimeoutError({ | ||
| message: `Model stream produced no events for ${ms}ms`, | ||
| timeout: ms, | ||
| }), | ||
| ) | ||
| abort() | ||
| }, ms) | ||
| const next = await Promise.race([iterator.next(), timer.promise]).finally(() => clearTimeout(id)) | ||
| if (next.done) return | ||
| yield next.value | ||
| } | ||
| } finally { | ||
| // Do not await cleanup: an async generator queues return() behind an | ||
| // in-flight next(), which may never settle for the stalled stream we are | ||
| // escaping. The abort above gives cooperative providers a chance to close. | ||
| try { | ||
| iterator.return?.().catch(() => {}) | ||
| } catch {} | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -13,8 +13,10 @@ import type { Provider } from "@/provider/provider" | |||||
| import { LLM } from "./llm" | ||||||
| import { Config } from "@/config/config" | ||||||
| import { SessionCompaction } from "./compaction" | ||||||
| import { StreamIdle } from "./idle" | ||||||
| import { PermissionNext } from "@/permission/next" | ||||||
| import { Question } from "@/question" | ||||||
| import { Flag } from "@/flag/flag" | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 New imports break the file's import grouping.
Suggested change
🤖 Fix with your agentWhy this mattersThe existing import block in import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
import { Question } from "@/question"
import { NamedError } from "@aictrl/util/error"
import { StreamIdle } from "./idle"
import { Flag } from "@/flag/flag" |
||||||
| import { NamedError } from "@aictrl/util/error" | ||||||
|
|
||||||
| export namespace SessionProcessor { | ||||||
|
|
@@ -51,9 +53,17 @@ export namespace SessionProcessor { | |||||
| try { | ||||||
| let currentText: MessageV2.TextPart | undefined | ||||||
| let reasoningMap: Record<string, MessageV2.ReasoningPart> = {} | ||||||
| const stream = await LLM.stream(streamInput) | ||||||
| const idle = StreamIdle.signal(streamInput.abort) | ||||||
| const stream = await LLM.stream({ | ||||||
| ...streamInput, | ||||||
| abort: idle.signal, | ||||||
| }) | ||||||
|
|
||||||
| for await (const value of stream.fullStream) { | ||||||
| for await (const value of StreamIdle.timeout( | ||||||
| stream.fullStream, | ||||||
| Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS, | ||||||
| () => idle.controller.abort(), | ||||||
| )) { | ||||||
| input.abort.throwIfAborted() | ||||||
| switch (value.type) { | ||||||
| case "start": | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,147 @@ | ||||||
| import { describe, expect, test } from "bun:test" | ||||||
| import { StreamIdle } from "../../src/session/idle" | ||||||
| import { MessageV2 } from "../../src/session/message-v2" | ||||||
| import { Flag } from "../../src/flag/flag" | ||||||
|
|
||||||
| describe("model stream idle timeout", () => { | ||||||
| test("uses its own abort signal when the caller signal is undefined", () => { | ||||||
| const idle = StreamIdle.signal() | ||||||
|
|
||||||
| expect(idle.signal.aborted).toBe(false) | ||||||
| idle.controller.abort() | ||||||
| expect(idle.signal.aborted).toBe(true) | ||||||
| }) | ||||||
|
|
||||||
| test("combines a supplied caller signal with its timeout controller", () => { | ||||||
| const caller = new AbortController() | ||||||
| const idle = StreamIdle.signal(caller.signal) | ||||||
|
|
||||||
| caller.abort() | ||||||
| expect(idle.signal.aborted).toBe(true) | ||||||
| }) | ||||||
|
|
||||||
| test("fails and aborts a stream whose next event stalls", async () => { | ||||||
| const pending = Promise.withResolvers<IteratorResult<string>>() | ||||||
| let aborted = false | ||||||
| const stream = { | ||||||
| [Symbol.asyncIterator]() { | ||||||
| return { | ||||||
| next: () => pending.promise, | ||||||
| } | ||||||
| }, | ||||||
| } | ||||||
|
|
||||||
| const result = StreamIdle.timeout(stream, 10, () => { | ||||||
| aborted = true | ||||||
| }) | ||||||
| const error = await result.next().catch((error) => error) | ||||||
|
|
||||||
| expect(aborted).toBe(true) | ||||||
| expect(MessageV2.StreamIdleTimeoutError.isInstance(error)).toBe(true) | ||||||
| expect(error.data).toEqual({ | ||||||
| message: "Model stream produced no events for 10ms", | ||||||
| timeout: 10, | ||||||
| }) | ||||||
| }) | ||||||
|
|
||||||
| test("calls return on the inner iterator after an idle timeout", async () => { | ||||||
| const pending = Promise.withResolvers<IteratorResult<string>>() | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 "releases iterator" test gives false confidence.
Suggested change
🤖 Fix with your agentWhy this mattersThe test "releases the inner iterator after an idle timeout" uses a hand-rolled iterator whose test("releases the inner iterator after an idle timeout", async () => {
const pending = Promise.withResolvers<IteratorResult<string>>()
let released = false
const stream = {
[Symbol.asyncIterator]() {
return {
next: () => pending.promise,
async return() {
released = true
return { done: true as const, value: undefined }
},
}
},
}
await StreamIdle.timeout(stream, 10, () => {}).next().catch(() => {})
expect(released).toBe(true)
}) |
||||||
| let called = false | ||||||
| const stream = { | ||||||
| [Symbol.asyncIterator]() { | ||||||
| return { | ||||||
| next: () => pending.promise, | ||||||
| async return() { | ||||||
| called = true | ||||||
| return { done: true as const, value: undefined } | ||||||
| }, | ||||||
| } | ||||||
| }, | ||||||
| } | ||||||
|
|
||||||
| await StreamIdle.timeout(stream, 10, () => {}).next().catch(() => {}) | ||||||
| expect(called).toBe(true) | ||||||
| }) | ||||||
|
|
||||||
| test("releases the inner iterator when the consumer stops early", async () => { | ||||||
| let released = false | ||||||
| const stream = { | ||||||
| [Symbol.asyncIterator]() { | ||||||
| return { | ||||||
| value: 0, | ||||||
| async next() { | ||||||
| return { done: false as const, value: ++this.value } | ||||||
| }, | ||||||
| async return() { | ||||||
| released = true | ||||||
| return { done: true as const, value: undefined } | ||||||
| }, | ||||||
| } | ||||||
| }, | ||||||
| } | ||||||
|
|
||||||
| for await (const _ of StreamIdle.timeout(stream, 100, () => {})) break | ||||||
| expect(released).toBe(true) | ||||||
| }) | ||||||
|
|
||||||
| test("resets after each event instead of limiting total stream duration", async () => { | ||||||
| async function* stream() { | ||||||
| yield 1 | ||||||
| await Bun.sleep(8) | ||||||
| yield 2 | ||||||
| await Bun.sleep(8) | ||||||
| yield 3 | ||||||
| } | ||||||
|
|
||||||
| const values: number[] = [] | ||||||
| for await (const value of StreamIdle.timeout(stream(), 20, () => { | ||||||
| throw new Error("active stream should not abort") | ||||||
| })) { | ||||||
| values.push(value) | ||||||
| } | ||||||
|
|
||||||
| expect(values).toEqual([1, 2, 3]) | ||||||
| }) | ||||||
|
|
||||||
| test("zero disables the timeout", async () => { | ||||||
| async function* stream() { | ||||||
| await Bun.sleep(15) | ||||||
| yield "done" | ||||||
| } | ||||||
|
|
||||||
| const values = [] | ||||||
| for await (const value of StreamIdle.timeout(stream(), 0, () => { | ||||||
| throw new Error("disabled timeout should not abort") | ||||||
| })) { | ||||||
| values.push(value) | ||||||
| } | ||||||
|
|
||||||
| expect(values).toEqual(["done"]) | ||||||
| }) | ||||||
| }) | ||||||
|
|
||||||
| describe("AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", () => { | ||||||
| test("supports default, override, disable, and invalid fallback", () => { | ||||||
| const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS | ||||||
|
|
||||||
| try { | ||||||
| delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS | ||||||
| expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) | ||||||
| process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1234" | ||||||
| expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(1234) | ||||||
| process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0" | ||||||
| expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) | ||||||
| process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "invalid" | ||||||
| expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) | ||||||
| process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1e3" | ||||||
| expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) | ||||||
| process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0x10" | ||||||
| expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) | ||||||
| process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "9007199254740992" | ||||||
| expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) | ||||||
| } finally { | ||||||
| if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS | ||||||
| else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original | ||||||
| } | ||||||
| }) | ||||||
| }) | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⚪ Lazy flag getter missing rationale comment its sibling has.
🤖 Fix with your agent
Why this matters
The pre-existing
AICTRL_DISABLE_PROJECT_CONFIGgetter is preceded by a 3-line comment explaining it is defined viaObject.definePropertyso the env var is read at access time rather than module load. The newAICTRL_MODEL_STREAM_IDLE_TIMEOUT_MSgetter uses the same pattern (and relies on the same lazy-read semantics — the test mutatesprocess.envat runtime and re-readsFlag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS), but omits the rationale comment, breaking the local convention for documenting this pattern.