Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions .scratch/batch-b/issues/02-u2-transport-timeout-abort.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@
**Evidence:** `.scratch/batch-b/evidence.md#u-2--timeout-传播到底层-http-取消`
**Branch:** `test/transport-abort`
**Blocked by:** 01(同一 OpenSpec 串行落地)
**Status:** blocked
**Status:** closed

- [ ] fixture 提供“请求已接收”与“response 已取消”的有界 fence,禁止用固定 sleep 猜时序
- [ ] timeout 后断言现有 `LLMError` Transport/Timeout 形状
- [ ] 服务端确定性观察到 response stream cancellation;request abort 只作补充信号
- [ ] 不用内存 HttpClient 或显式 Fiber interrupt 重复现有覆盖
- [ ] 在 `packages/llm` 连续运行目标测试至少 3 次并运行 `bun typecheck`
- [x] fixture 提供“请求已接收”与“response 已取消”的有界 fence,禁止用固定 sleep 猜时序
- [x] timeout 后断言现有 `LLMError` Transport/Timeout 形状
- [x] 服务端确定性观察到 response stream cancellation;request abort 只作补充信号
- [x] 不用内存 HttpClient 或显式 Fiber interrupt 重复现有覆盖
- [x] 在 `packages/llm` 连续运行目标测试至少 3 次并运行 `bun typecheck`

## 验证证据

- 基线:`dev@55dd345491de4542dbf6fa7a4ba126a2c23104c4`;分支:`test/transport-abort`。
- 实现提交:`8ec1ef192`;PR:[LeXwDeX/OpenCode-GraphAgent#193](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/193) → `dev`。
- 真实 transport:公开 `LLMClient.stream(...)` 经 `FetchHttpClient.layer` 请求 loopback `Bun.serve`;2 秒有界 fence 分别证明请求已接收与服务端 response `cancel()` 已触发。
- mutation 红灯:临时移除 response stream 的 `Stream.timeoutOrElse` 后,新增场景在 1 秒测试边界超时,0 pass / 1 fail;mutation 已恢复,生产文件无 diff。
- `cd packages/llm && bun test test/transport-timeout.test.ts --timeout 30000`:连续 3 次均为 7 pass、0 fail、14 expect;`bun typecheck`:`tsgo --noEmit`,exit 0。
- 现有生产实现满足 OpenSpec Requirement 2,无生产代码修改;Requirement 3 留给 03 票。
4 changes: 2 additions & 2 deletions .scratch/batch-b/issues/03-transport-midstream-stall.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
**Spec:** `.scratch/batch-b/abort-path-contracts.md` 的 Requirement 3
**Evidence:** `.scratch/batch-b/evidence.md#transport-mid-stream-stall`
**Branch:** `test/midstream-timeout`
**Blocked by:** 02(共同修改 `packages/llm/test/transport-timeout.test.ts`
**Status:** blocked
**Blocked by:** None(02 已完成
**Status:** ready-for-agent

- [ ] 用 fence 证明 timeout 前合法首帧已经交付给消费者
- [ ] 用 TestClock 越过下一帧间隔,随后得到现有 Transport/Timeout
Expand Down
66 changes: 63 additions & 3 deletions packages/llm/test/transport-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import { describe, expect, test } from "bun:test"
import { Cause, Duration, Effect, Exit, Fiber, Option, Stream } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as TestClock from "effect/testing/TestClock"
import { LLM, LLMError, LLMEvent } from "../src"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { HttpOptions, Model, mergeHttpOptions } from "../src/schema"
import { LLMClient } from "../src/route"
import { testEffect } from "./lib/effect"
import { dynamicResponse, fixedResponse } from "./lib/http"
import { dynamicResponse, fixedResponse, runtimeLayer } from "./lib/http"
import { deltaChunk } from "./lib/openai-chunks"
import { sseEvents } from "./lib/sse"

const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })

const request = (timeout?: number) =>
const request = (timeout?: number, baseURL?: string) =>
LLM.request({
model,
model:
baseURL === undefined
? model
: Model.make({
id: "fake-model",
provider: "fake",
route: OpenAIChat.route.with({ endpoint: { baseURL } }),
}),
prompt: "Say hello.",
http: timeout === undefined ? undefined : { timeout: Duration.millis(timeout) },
})
Expand All @@ -37,7 +45,59 @@ const expectTimeoutExit = (exit: Exit.Exit<readonly LLMEvent[], LLMError>) => {
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "Timeout" })
}

const waitForFence = <A>(name: string, promise: Promise<A>) =>
Effect.promise(() => promise).pipe(
Effect.timeout(Duration.seconds(2)),
Effect.mapError(() => new Error(`${name} was not observed within 2000ms`)),
)

const timeoutProvider = () => {
const requestReceived = Promise.withResolvers<void>()
const responseCanceled = Promise.withResolvers<void>()
const encoder = new TextEncoder()
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
requestReceived.resolve()
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(": connected\n\n"))
},
cancel() {
responseCanceled.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
return { server, requestReceived: requestReceived.promise, responseCanceled: responseCanceled.promise }
}

const networkRuntime = runtimeLayer(FetchHttpClient.layer)

describe("http transport timeout", () => {
testEffect(networkRuntime).live(
"cancels the provider response stream when a real HTTP request times out",
() =>
Effect.gen(function* () {
const provider = yield* Effect.acquireRelease(
Effect.sync(timeoutProvider),
(fixture) => Effect.promise(() => fixture.server.stop(true)),
)
const fiber = yield* LLMClient.stream(request(200, provider.server.url.origin)).pipe(
Stream.runCollect,
Effect.forkScoped,
)

yield* waitForFence("provider request", provider.requestReceived)
expectTimeoutExit(yield* Fiber.join(fiber).pipe(Effect.exit))
yield* waitForFence("provider response cancellation", provider.responseCanceled)
}),
)

testEffect(hangingHeaders).effect(
"ends the stream with a Timeout error when the provider never sends response headers",
() =>
Expand Down
Loading