feat: 添加 WithStreamErrors 查询流式调用执行过程中的异常
流式输出/双向流的 handler(Python 生成器)如果执行过程中抛异常, Invoke[chan T] 本身的 err 只描述"调用有没有发起成功",跟这个异常 无关(永远是 nil),channel 只会静默提前关闭,调用方原本完全无法 感知。新增 WithStreamErrors(ctx) 返回一个包过的 ctx 和一个查询函数 streamErr,opt-in 之后可以查到具体错误。 错误记录挂在 WithStreamErrors 返回的 ctx 的对象图里(context.WithValue), 不是全局表——调用方不再引用 ctx/channel 时会被 GC 自然回收,不需要 任何显式清理逻辑,也不依赖 ctx.Done(),即使用 context.Background() 也能正常释放;ctx 之后被别的 context.With*(包括 StickyCtx)再包一层 也不影响查询。 同时补充完整的自动化测试覆盖 example/main.go 里演示过的所有功能: 四种调用模式 × int/struct/slice/[]byte 的组合(client_test.go)、 WithHandlers/call_go 全双工(handlers_test.go)、NewSession 隔离性 与 StickyCtx 路由(session_test.go),之前这些只能靠人肉跑 go run 看输出,现在都有真实断言。
This commit is contained in:
+97
-3
@@ -7,6 +7,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -40,6 +41,26 @@ func newTestPool(t *testing.T, opts ...Option) Pool {
|
||||
return pool
|
||||
}
|
||||
|
||||
// stableThreadCount 轮询 count_threads(),直到连续两次读数一致再返回,
|
||||
// 用于绕开 pool 刚创建时 Python 侧连接处理线程还没必然全部起稳的竞态。
|
||||
func stableThreadCount(t *testing.T, ctx context.Context, pool Pool) (int, error) {
|
||||
t.Helper()
|
||||
prev := -1
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := Invoke[int](ctx, pool, "count_threads")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n == prev {
|
||||
return n, nil
|
||||
}
|
||||
prev = n
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return prev, nil
|
||||
}
|
||||
|
||||
// TestPoolExhaustionBlocks 验证:workers=2、maxConns=2(总容量 4)的池子被占满后,
|
||||
// 新的 Invoke 调用会排队阻塞等待连接释放,而不是立刻失败或被跳过;
|
||||
// 一旦有连接释放,新调用能正常拿到连接并执行。
|
||||
@@ -162,8 +183,8 @@ func TestStreamErrorMidwayCorruptsNextCall(t *testing.T) {
|
||||
for v := range ch {
|
||||
got = append(got, v)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 items before the raise, got %v", got)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 items before the raise, got %v", got)
|
||||
}
|
||||
|
||||
// 连接理论上已经被当作"健康"放回池子;下一次调用应该拿到自己的正常结果,
|
||||
@@ -177,6 +198,76 @@ func TestStreamErrorMidwayCorruptsNextCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamError 验证 WithStreamErrors:opt-in 之后,流式调用执行过程中因为 Python
|
||||
// 侧真实抛出的异常提前结束时,能通过它返回的 streamErr 函数查到具体错误(不需要再传
|
||||
// ctx);正常跑完的流式调用查询结果是 nil。
|
||||
//
|
||||
// 这个方案没有全局登记表——错误记录挂在 WithStreamErrors 返回的那个 ctx 的对象图里,
|
||||
// 调用方不再引用这个 ctx(和对应的 ch)时,整条链会被 GC 自然回收,不需要任何显式
|
||||
// 清理逻辑,也不依赖 ctx.Done(),context.Background() 一样能正常释放。
|
||||
func TestStreamError(t *testing.T) {
|
||||
pool := newTestPool(t, WithWorkers(1), WithMaxConns(2))
|
||||
|
||||
t.Run("opt-in 后能查到 Python 侧真实抛出的异常", func(t *testing.T) {
|
||||
ctx, streamErr := WithStreamErrors(context.Background())
|
||||
// stream_then_raise 是 example/worker.py 里真实的 Python 函数:
|
||||
// 在还没 yield 完 n 个数之前,执行过程中就 raise ValueError("boom: ..."),
|
||||
// 不是模拟出来的错误,也不是等全部 yield 完才失败。
|
||||
ch, err := Invoke[chan int](ctx, pool, "stream_then_raise", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
var got []int
|
||||
for v := range ch {
|
||||
got = append(got, v)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 items before the raise, got %v", got)
|
||||
}
|
||||
err = streamErr(ch)
|
||||
if err == nil {
|
||||
t.Fatal("want non-nil error after Python 侧执行过程中抛异常")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("want error containing %q, got %v", "boom", err)
|
||||
}
|
||||
t.Logf("streamErr 查到的错误: %v", err)
|
||||
})
|
||||
|
||||
t.Run("正常跑完不会有错误", func(t *testing.T) {
|
||||
ctx, streamErr := WithStreamErrors(context.Background())
|
||||
ch, err := Invoke[chan int](ctx, pool, "range_gen", 1, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
var got []int
|
||||
for v := range ch {
|
||||
got = append(got, v)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 items, got %v", got)
|
||||
}
|
||||
if err := streamErr(ch); err != nil {
|
||||
t.Fatalf("want nil,got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("再包一层 ctx(比如加超时)不会丢失 WithStreamErrors 记录的信息", func(t *testing.T) {
|
||||
ctx, streamErr := WithStreamErrors(context.Background())
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // WithStreamErrors 之后又包了一层
|
||||
defer cancel()
|
||||
ch, err := Invoke[chan int](ctx, pool, "stream_then_raise", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
for range ch {
|
||||
}
|
||||
if err := streamErr(ch); err == nil {
|
||||
t.Fatal("再包一层 WithTimeout 之后应该还能查到 WithStreamErrors 记录的错误")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStreamOutIgnoresDefaultTimeout 验证 WithDefaultTimeout 不会套用到流式输出/
|
||||
// 双向流调用上:流式聊天这类响应可能持续很久,如果套用全局默认超时会在正常输出
|
||||
// 过程中把它腰斩。没有显式传入 deadline 时,流式调用应该完全不受池子默认超时影响,
|
||||
@@ -215,7 +306,10 @@ func TestStreamInputCancelDoesNotLeakThread(t *testing.T) {
|
||||
pool := newTestPool(t, WithWorkers(1), WithMaxConns(2))
|
||||
ctx := context.Background()
|
||||
|
||||
baseline, err := Invoke[int](ctx, pool, "count_threads")
|
||||
// 刚创建的 pool,Python 那边给每条预建连接起的 _handle_conn/_reader 线程
|
||||
// 还没必然全部起稳(server.accept() 是异步接受的),第一次量出来的线程数
|
||||
// 可能偏低导致误判。轮询到连续两次读数一致再当作稳定的 baseline。
|
||||
baseline, err := stableThreadCount(t, ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke count_threads (baseline): %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user