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
+207
View File
@@ -0,0 +1,207 @@
package gobridge
import (
"context"
"reflect"
"testing"
)
// testUser 对应 example/worker.py 里的 User dataclass。
type testUser struct {
ID int `json:"id"`
Name string `json:"name"`
Score float64 `json:"score"`
Level string `json:"level,omitempty"`
}
// TestInvokeModes 覆盖 example/main.go 的 demoPool 演示过的四种调用模式
// (普通调用、流式输出、流式输入、双向流)在 int / struct / slice / []byte
// 各种类型组合下的正确性,对应 README「四种调用模式」小节。
func TestInvokeModes(t *testing.T) {
pool := newTestPool(t, WithWorkers(2), WithMaxConns(4))
ctx := context.Background()
t.Run("普通调用_int", func(t *testing.T) {
sum, err := Invoke[int](ctx, pool, "add", 3, 4)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if sum != 7 {
t.Fatalf("want 7, got %d", sum)
}
})
t.Run("流式输出_int", func(t *testing.T) {
ch, err := Invoke[chan int](ctx, pool, "range_gen", 1, 6)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
var got []int
for v := range ch {
got = append(got, v)
}
want := []int{1, 2, 3, 4, 5}
if !reflect.DeepEqual(got, want) {
t.Fatalf("want %v, got %v", want, got)
}
})
t.Run("流式输入_int", func(t *testing.T) {
inputCh := make(chan int, 10)
go func() {
for i := 1; i <= 5; i++ {
inputCh <- i
}
close(inputCh)
}()
total, err := Invoke[int](ctx, pool, "sum_stream", inputCh)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if total != 15 {
t.Fatalf("want 15, got %d", total)
}
})
t.Run("双向流_int", func(t *testing.T) {
inputCh := make(chan int, 10)
go func() {
for i := 1; i <= 5; i++ {
inputCh <- i
}
close(inputCh)
}()
outCh, err := Invoke[chan int](ctx, pool, "double_stream", inputCh)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
var got []int
for v := range outCh {
got = append(got, v)
}
want := []int{1, 4, 9, 16, 25}
if !reflect.DeepEqual(got, want) {
t.Fatalf("want %v, got %v", want, got)
}
})
t.Run("普通调用_struct", func(t *testing.T) {
user, err := Invoke[testUser](ctx, pool, "get_user", 42)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
want := testUser{ID: 42, Name: "user_42", Score: 63}
if user != want {
t.Fatalf("want %+v, got %+v", want, user)
}
})
users := []testUser{
{ID: 1, Name: "alice", Score: 5.0},
{ID: 2, Name: "bob", Score: 8.0},
{ID: 3, Name: "carol", Score: 12.0},
}
t.Run("slice输入_返回标量", func(t *testing.T) {
scoreSum, err := Invoke[float64](ctx, pool, "total_score", users)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if scoreSum != 25 {
t.Fatalf("want 25, got %v", scoreSum)
}
})
t.Run("slice输入输出", func(t *testing.T) {
enriched, err := Invoke[[]testUser](ctx, pool, "enrich_users", users)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
want := []testUser{
{ID: 1, Name: "alice", Score: 5.0, Level: "silver"},
{ID: 2, Name: "bob", Score: 8.0, Level: "silver"},
{ID: 3, Name: "carol", Score: 12.0, Level: "gold"},
}
if !reflect.DeepEqual(enriched, want) {
t.Fatalf("want %+v, got %+v", want, enriched)
}
})
t.Run("流式输出_struct", func(t *testing.T) {
userCh, err := Invoke[chan testUser](ctx, pool, "gen_users", 3)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
var got []testUser
for u := range userCh {
got = append(got, u)
}
want := []testUser{
{ID: 1, Name: "user_1", Score: 3},
{ID: 2, Name: "user_2", Score: 6},
{ID: 3, Name: "user_3", Score: 9},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("want %+v, got %+v", want, got)
}
})
t.Run("双向流_struct", func(t *testing.T) {
inCh := make(chan testUser, len(users))
go func() {
for _, u := range users {
inCh <- u
}
close(inCh)
}()
procCh, err := Invoke[chan testUser](ctx, pool, "process_users", inCh)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
var got []testUser
for u := range procCh {
got = append(got, u)
}
want := []testUser{
{ID: 1, Name: "ALICE", Score: 10},
{ID: 2, Name: "BOB", Score: 16},
{ID: 3, Name: "CAROL", Score: 24},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("want %+v, got %+v", want, got)
}
})
t.Run("bytes_输入输出", func(t *testing.T) {
rev, err := Invoke[[]byte](ctx, pool, "bytes_reverse", []byte("hello"))
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if string(rev) != "olleh" {
t.Fatalf("want olleh, got %s", rev)
}
cat, err := Invoke[[]byte](ctx, pool, "bytes_concat", []byte("foo"), []byte("bar"))
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if string(cat) != "foobar" {
t.Fatalf("want foobar, got %s", cat)
}
})
t.Run("bytes_流式输出", func(t *testing.T) {
bCh, err := Invoke[chan []byte](ctx, pool, "bytes_chunks", []byte("abcdefgh"), 3)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
var got []string
for chunk := range bCh {
got = append(got, string(chunk))
}
want := []string{"abc", "def", "gh"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("want %v, got %v", want, got)
}
})
}