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:
2026-07-23 16:24:05 +08:00
parent 6ccec66a1e
commit ee5e5b96af
9 changed files with 717 additions and 5 deletions
+149
View File
@@ -0,0 +1,149 @@
package gobridge
import (
"context"
"fmt"
"reflect"
"testing"
)
// sessionResult 对应 example/worker.py 里 session_result 返回的
// {"value": int, "steps": [int, ...]}。
type sessionResult struct {
Value int `json:"value"`
Steps []int `json:"steps"`
}
// parseWorkerID 从 "[worker N] ..." 格式的字符串里解析出 worker 编号,
// example/worker.py 里 session_init/global_increment/global_get 都是这个格式。
func parseWorkerID(t *testing.T, msg string) int {
t.Helper()
var id int
if _, err := fmt.Sscanf(msg, "[worker %d]", &id); err != nil {
t.Fatalf("parse worker id from %q: %v", msg, err)
}
return id
}
// TestNewSessionIsolation 覆盖 example/main.go 的 demoSession 演示过的
// NewSession 功能:不同 session 之间状态互相隔离;同一个 worker 进程内的
// session 共享进程级全局变量,不同 worker 之间完全独立。
func TestNewSessionIsolation(t *testing.T) {
pool := newTestPool(t, WithWorkers(2))
ctx := context.Background()
// NewSession 内部按 pool 共享的原子计数器轮询:第一次 idx=1%2=1,
// 第二次 idx=2%2=0,第三次 idx=3%2=1——sessA 和 sessC 落在同一个 worker
// sessB 落在另一个(用全新 pool,不掺杂其它调用,保证这个顺序确定)。
sessA := NewSession(pool)
sessB := NewSession(pool)
sessC := NewSession(pool)
if _, err := Invoke[string](ctx, sessA, "session_init", "A", 100); err != nil {
t.Fatalf("sessA init: %v", err)
}
if _, err := Invoke[string](ctx, sessB, "session_init", "B", 200); err != nil {
t.Fatalf("sessB init: %v", err)
}
if _, err := Invoke[string](ctx, sessC, "session_init", "C", 300); err != nil {
t.Fatalf("sessC init: %v", err)
}
// 各自 step,验证会话状态互不干扰
if v, err := Invoke[int](ctx, sessA, "session_step", "A", 10); err != nil || v != 110 {
t.Fatalf("sessA step(+10): v=%d err=%v, want 110", v, err)
}
if v, err := Invoke[int](ctx, sessA, "session_step", "A", 5); err != nil || v != 115 {
t.Fatalf("sessA step(+5): v=%d err=%v, want 115", v, err)
}
if v, err := Invoke[int](ctx, sessB, "session_step", "B", 50); err != nil || v != 250 {
t.Fatalf("sessB step(+50): v=%d err=%v, want 250", v, err)
}
if v, err := Invoke[int](ctx, sessC, "session_step", "C", 99); err != nil || v != 399 {
t.Fatalf("sessC step(+99): v=%d err=%v, want 399(与 sessA 同 worker 但状态独立)", v, err)
}
rA, err := Invoke[sessionResult](ctx, sessA, "session_result", "A")
if err != nil {
t.Fatalf("sessA result: %v", err)
}
if want := (sessionResult{Value: 115, Steps: []int{10, 5}}); !reflect.DeepEqual(rA, want) {
t.Fatalf("sessA result: want %+v, got %+v", want, rA)
}
rB, err := Invoke[sessionResult](ctx, sessB, "session_result", "B")
if err != nil {
t.Fatalf("sessB result: %v", err)
}
if want := (sessionResult{Value: 250, Steps: []int{50}}); !reflect.DeepEqual(rB, want) {
t.Fatalf("sessB result: want %+v, got %+v", want, rB)
}
rC, err := Invoke[sessionResult](ctx, sessC, "session_result", "C")
if err != nil {
t.Fatalf("sessC result: %v", err)
}
if want := (sessionResult{Value: 399, Steps: []int{99}}); !reflect.DeepEqual(rC, want) {
t.Fatalf("sessC result: want %+v, got %+v", want, rC)
}
// 进程级全局变量:sessA 和 sessC 同一个 worker 进程,应该共享 _global_counter
// sessB 是独立进程,从 0 开始,不受影响。
rawA, err := Invoke[string](ctx, sessA, "global_increment", 10)
if err != nil {
t.Fatalf("sessA global_increment: %v", err)
}
workerA := parseWorkerID(t, rawA)
rawC, err := Invoke[string](ctx, sessC, "global_increment", 5)
if err != nil {
t.Fatalf("sessC global_increment: %v", err)
}
workerC := parseWorkerID(t, rawC)
rawB, err := Invoke[string](ctx, sessB, "global_increment", 99)
if err != nil {
t.Fatalf("sessB global_increment: %v", err)
}
workerB := parseWorkerID(t, rawB)
if workerA != workerC {
t.Fatalf("want sessA/sessC 落在同一个 workergot worker %d / %d", workerA, workerC)
}
if workerB == workerA {
t.Fatalf("want sessB 落在独立的 workergot 跟 sessA/sessC 一样都是 worker %d", workerB)
}
wantA := fmt.Sprintf("[worker %d] counter = 15", workerA) // sessA(+10) + sessC(+5) 共享
if rawA2, err := Invoke[string](ctx, sessA, "global_get"); err != nil || rawA2 != wantA {
t.Fatalf("sessA global_get: want %q, got %q (err=%v)", wantA, rawA2, err)
}
wantB := fmt.Sprintf("[worker %d] counter = 99", workerB) // 独立进程,只有自己的 +99
if rawB != wantB {
t.Fatalf("sessB global_increment: want %q, got %q", wantB, rawB)
}
}
// TestStickyCtx 覆盖 example/main.go 的 demoSession 演示过的 StickyCtx 功能:
// 相同的亲和 key 无论调用多少次,都应该稳定路由到同一个 worker 进程。
func TestStickyCtx(t *testing.T) {
pool := newTestPool(t, WithWorkers(2))
ctx := StickyCtx(context.Background(), "sticky-key")
var firstWorker int
for i := 0; i < 4; i++ {
msg, err := Invoke[string](ctx, pool, "session_init", fmt.Sprintf("aff-%d", i), i)
if err != nil {
t.Fatalf("Invoke #%d: %v", i, err)
}
worker := parseWorkerID(t, msg)
if i == 0 {
firstWorker = worker
continue
}
if worker != firstWorker {
t.Fatalf("StickyCtx 路由不稳定:第 1 次落在 worker %d,第 %d 次落在 worker %d", firstWorker, i+1, worker)
}
}
}