feat: 添加 WithDefaultTimeout 默认超时配置

Invoke 之前完全依赖调用方传入的 ctx 控制超时,池子被占满或 Python 侧
handler 阻塞时,未设置 deadline 的调用会永久阻塞且不报错。新增
WithDefaultTimeout 选项,仅在 ctx 未设置 deadline 时兜底生效,调用方
显式设置的超时优先级更高。

同时补充 example 中的阻塞/超时演示(demoTimeout、demoBlocking)和
pool_test.go 集成测试,覆盖池占满排队、默认超时、显式 deadline 优先级、
流式输出超时后 channel 静默关闭等场景。
This commit is contained in:
2026-07-23 10:37:24 +08:00
parent db71a904e0
commit 0f4a4ded53
7 changed files with 367 additions and 7 deletions
+49
View File
@@ -186,6 +186,53 @@ def slow_compute(n: int) -> int:
> 限制:长时间不释放 GIL 的 C 扩展(如大规模 numpy 矩阵运算)无法被中断,需等其释放 GIL 后才触发。 > 限制:长时间不释放 GIL 的 C 扩展(如大规模 numpy 矩阵运算)无法被中断,需等其释放 GIL 后才触发。
### 默认超时(WithDefaultTimeout
`Invoke` 本身不带任何默认超时——如果传入的 `ctx` 没有 deadline(比如直接用 `context.Background()`),且池子被占满(所有 worker 的连接都在处理别的请求),调用会**永久阻塞**,不会自动放弃。
`WithDefaultTimeout` 用于兜底这种情况:仅当调用方传入的 `ctx` **未设置** deadline 时才生效,调用方显式设置的 `context.WithTimeout` 优先级更高,不会被覆盖。
> 池子被占满时新调用是**排队阻塞等待**,不是立刻失败或被跳过——`example/main.go` 中的 `demoBlocking` 用 `workers=2, maxConns=2`(总容量 4)故意占满连接池,
> 再发起一个不设超时的调用,实测会阻塞约 1.8s(等到某个占位任务释放连接)才返回,而不是瞬间失败。
```go
pool, _ := gobridge.NewPool("worker.py",
gobridge.WithDefaultTimeout(500 * time.Millisecond),
)
// 未设置 deadline,池的默认超时自动生效,500ms 后返回 context.DeadlineExceeded
_, err := gobridge.Invoke[string](context.Background(), pool, "sleep_seconds", 2.0)
// 显式传入的 deadline 优先,不受默认超时影响
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
result, err := gobridge.Invoke[string](ctx, pool, "sleep_seconds", 1.0) // 正常返回
```
**超时是否会通过 `error` 返回,取决于调用模式:**
| 调用模式 | 超时表现 |
|---|---|
| 普通调用 / 流式输入 | `Invoke` 返回非 nil 的 `error``context.DeadlineExceeded` / `context.Canceled` |
| 流式输出 / 双向流 | `Invoke` 建立阶段失败会返回 `error`;**建立成功后**若中途超时,只会静默关闭已返回的 channel,不会有第二次 `error` |
流式模式下,`for v := range ch` 结束后无法区分"正常读完"还是"被超时打断",需要调用方自行检查传入的 `ctx.Err()`
```go
ch, err := gobridge.Invoke[chan int](ctx, pool, "slow_range_gen", 1, 10, 100)
if err != nil {
// 建立阶段失败/超时
}
for v := range ch {
fmt.Println(v)
}
if ctx.Err() != nil {
// channel 是因为超时/取消提前关闭的,不是正常 yield 完
}
```
完整可运行示例见 [example/main.go](example/main.go) 中的 `demoTimeout`(默认超时 / 显式 deadline 优先级 / 流式超时静默关闭 channel)和 `demoBlocking`(池子占满后阻塞排队)。
## Session 亲和路由 ## Session 亲和路由
默认情况下,每次 `Invoke` 通过轮询分配 worker 进程。当多次调用需要共享同一 Python 进程的状态时,可以使用 Session 或 StickyCtx 将调用固定到同一进程。 默认情况下,每次 `Invoke` 通过轮询分配 worker 进程。当多次调用需要共享同一 Python 进程的状态时,可以使用 Session 或 StickyCtx 将调用固定到同一进程。
@@ -394,6 +441,7 @@ pool, err := gobridge.NewPool("worker.py",
gobridge.WithSocketDir("/var/run/myapp"), // socket 文件目录,默认 /tmp gobridge.WithSocketDir("/var/run/myapp"), // socket 文件目录,默认 /tmp
gobridge.WithStdout(os.Stdout), // 子进程 stdout,默认 os.Stdout gobridge.WithStdout(os.Stdout), // 子进程 stdout,默认 os.Stdout
gobridge.WithStderr(os.Stderr), // 子进程 stderr,默认 os.Stderr gobridge.WithStderr(os.Stderr), // 子进程 stderr,默认 os.Stderr
gobridge.WithDefaultTimeout(10*time.Second), // Invoke 默认超时,默认不启用
) )
// 静默模式:丢弃子进程输出 // 静默模式:丢弃子进程输出
@@ -414,6 +462,7 @@ pool, err := gobridge.NewPool("worker.py",
| `WithSocketDir(dir)` | UDS socket 文件目录 | `"/tmp"` | | `WithSocketDir(dir)` | UDS socket 文件目录 | `"/tmp"` |
| `WithStdout(w)` | 子进程标准输出 | `os.Stdout` | | `WithStdout(w)` | 子进程标准输出 | `os.Stdout` |
| `WithStderr(w)` | 子进程标准错误 | `os.Stderr` | | `WithStderr(w)` | 子进程标准错误 | `os.Stderr` |
| `WithDefaultTimeout(d)` | `Invoke` 默认超时,仅在传入的 `ctx` 未设置 deadline 时生效 | 不启用 |
## 使用 uv 管理 Python 环境 ## 使用 uv 管理 Python 环境
+38
View File
@@ -12,6 +12,19 @@ import (
"sync" "sync"
) )
// applyDefaultTimeout 在 ctx 未设置 deadline 时,套用 pool 配置的默认超时(WithDefaultTimeout)。
// 调用方显式设置的 deadline 优先级更高,不会被覆盖;未配置默认超时时返回原 ctx 和 no-op cancel。
func applyDefaultTimeout(ctx context.Context, pool Pool) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return ctx, func() {}
}
d := pool.defaultTimeout()
if d <= 0 {
return ctx, func() {}
}
return context.WithTimeout(ctx, d)
}
// Invoke 调用 Python 暴露的函数,支持四种模式: // Invoke 调用 Python 暴露的函数,支持四种模式:
// //
// 普通调用: Invoke[int](ctx, pool, "Add", 3, 4) // 普通调用: Invoke[int](ctx, pool, "Add", 3, 4)
@@ -119,6 +132,9 @@ func readResult(ctx context.Context, conn net.Conn, pool Pool, write func(Messag
func invokeRegular[R any](ctx context.Context, pool Pool, method string, args ...any) (R, error) { func invokeRegular[R any](ctx context.Context, pool Pool, method string, args ...any) (R, error) {
var zero R var zero R
ctx, cancel := applyDefaultTimeout(ctx, pool)
defer cancel()
argsJSON, err := json.Marshal(args) argsJSON, err := json.Marshal(args)
if err != nil { if err != nil {
return zero, fmt.Errorf("marshal args: %w", err) return zero, fmt.Errorf("marshal args: %w", err)
@@ -167,13 +183,17 @@ func invokeRegular[R any](ctx context.Context, pool Pool, method string, args ..
func invokeStreamOut[R any](ctx context.Context, pool Pool, method string, rt reflect.Type, args ...any) (R, error) { func invokeStreamOut[R any](ctx context.Context, pool Pool, method string, rt reflect.Type, args ...any) (R, error) {
var zero R var zero R
ctx, cancel := applyDefaultTimeout(ctx, pool)
argsJSON, err := json.Marshal(args) argsJSON, err := json.Marshal(args)
if err != nil { if err != nil {
cancel()
return zero, fmt.Errorf("marshal args: %w", err) return zero, fmt.Errorf("marshal args: %w", err)
} }
conn, w, err := pool.acquire(ctx) conn, w, err := pool.acquire(ctx)
if err != nil { if err != nil {
cancel()
return zero, err return zero, err
} }
@@ -185,6 +205,7 @@ func invokeStreamOut[R any](ctx context.Context, pool Pool, method string, rt re
Args: argsJSON, Args: argsJSON,
}); err != nil { }); err != nil {
w.release(conn, false) w.release(conn, false)
cancel()
return zero, contextErr(ctx, fmt.Errorf("write call: %w", err)) return zero, contextErr(ctx, fmt.Errorf("write call: %w", err))
} }
@@ -198,6 +219,7 @@ func invokeStreamOut[R any](ctx context.Context, pool Pool, method string, rt re
stop() stop()
ch.Close() ch.Close()
w.release(conn, ctx.Err() == nil) w.release(conn, ctx.Err() == nil)
cancel()
}() }()
for { for {
msg, err := readResult(ctx, conn, pool, write) msg, err := readResult(ctx, conn, pool, write)
@@ -220,6 +242,9 @@ func invokeStreamOut[R any](ctx context.Context, pool Pool, method string, rt re
func invokeStreamIn[R any](ctx context.Context, pool Pool, method string, streamArgIdx int, streamCh reflect.Value, args ...any) (R, error) { func invokeStreamIn[R any](ctx context.Context, pool Pool, method string, streamArgIdx int, streamCh reflect.Value, args ...any) (R, error) {
var zero R var zero R
ctx, cancel := applyDefaultTimeout(ctx, pool)
defer cancel()
jsonArgs := make([]any, len(args)) jsonArgs := make([]any, len(args))
copy(jsonArgs, args) copy(jsonArgs, args)
jsonArgs[streamArgIdx] = nil jsonArgs[streamArgIdx] = nil
@@ -306,17 +331,21 @@ func invokeStreamIn[R any](ctx context.Context, pool Pool, method string, stream
func invokeStreamBoth[R any](ctx context.Context, pool Pool, method string, streamArgIdx int, streamCh reflect.Value, rt reflect.Type, args ...any) (R, error) { func invokeStreamBoth[R any](ctx context.Context, pool Pool, method string, streamArgIdx int, streamCh reflect.Value, rt reflect.Type, args ...any) (R, error) {
var zero R var zero R
ctx, cancel := applyDefaultTimeout(ctx, pool)
jsonArgs := make([]any, len(args)) jsonArgs := make([]any, len(args))
copy(jsonArgs, args) copy(jsonArgs, args)
jsonArgs[streamArgIdx] = nil jsonArgs[streamArgIdx] = nil
argsJSON, err := json.Marshal(jsonArgs) argsJSON, err := json.Marshal(jsonArgs)
if err != nil { if err != nil {
cancel()
return zero, fmt.Errorf("marshal args: %w", err) return zero, fmt.Errorf("marshal args: %w", err)
} }
conn, w, err := pool.acquire(ctx) conn, w, err := pool.acquire(ctx)
if err != nil { if err != nil {
cancel()
return zero, err return zero, err
} }
@@ -330,6 +359,7 @@ func invokeStreamBoth[R any](ctx context.Context, pool Pool, method string, stre
StreamArgIdx: streamArgIdx, StreamArgIdx: streamArgIdx,
}); err != nil { }); err != nil {
w.release(conn, false) w.release(conn, false)
cancel()
return zero, contextErr(ctx, fmt.Errorf("write call: %w", err)) return zero, contextErr(ctx, fmt.Errorf("write call: %w", err))
} }
@@ -338,8 +368,15 @@ func invokeStreamBoth[R any](ctx context.Context, pool Pool, method string, stre
var mu sync.Mutex var mu sync.Mutex
write := func(msg Message) { mu.Lock(); writeMsg(conn, msg); mu.Unlock() } //nolint write := func(msg Message) { mu.Lock(); writeMsg(conn, msg); mu.Unlock() } //nolint
// 两个 goroutine 共用同一个 ctxdefaultTimeout 产生的 cancel 必须等两者都结束才能调用,
// 否则先完成的一方会提前取消掉另一方仍在进行的操作。
var wg sync.WaitGroup
wg.Add(2)
go func() { wg.Wait(); cancel() }()
// 写入 goroutine:输入 channel → Python chunks // 写入 goroutine:输入 channel → Python chunks
go func() { go func() {
defer wg.Done()
for { for {
val, ok, cancelled := chanRecv(ctx, streamCh) val, ok, cancelled := chanRecv(ctx, streamCh)
if cancelled || !ok { if cancelled || !ok {
@@ -361,6 +398,7 @@ func invokeStreamBoth[R any](ctx context.Context, pool Pool, method string, stre
// 读取 goroutinePython chunks → 输出 channel,内联处理 callback // 读取 goroutinePython chunks → 输出 channel,内联处理 callback
go func() { go func() {
defer wg.Done()
stop := watchCtx(ctx, conn, id, write) stop := watchCtx(ctx, conn, id, write)
defer func() { defer func() {
stop() stop()
+91
View File
@@ -6,6 +6,8 @@ import (
"log" "log"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sync"
"time"
"git.fsdpf.net/go/gobridge" "git.fsdpf.net/go/gobridge"
) )
@@ -35,6 +37,8 @@ func main() {
demoPool(ctx, pool) demoPool(ctx, pool)
demoServer(ctx, script) demoServer(ctx, script)
demoSession(ctx, script) demoSession(ctx, script)
demoTimeout(script)
demoBlocking(script)
} }
func demoPool(ctx context.Context, pool gobridge.Pool) { func demoPool(ctx context.Context, pool gobridge.Pool) {
@@ -277,6 +281,93 @@ func demoSession(ctx context.Context, script string) {
} }
} }
func demoTimeout(script string) {
fmt.Println("\n── 默认超时示例(WithDefaultTimeout)─────────────────────────────")
// workers=1, maxConns=1:整个池子只有一个连接槽位,便于演示"占满后阻塞/超时"
pool, err := gobridge.NewPool(script,
gobridge.WithWorkers(1),
gobridge.WithMaxConns(1),
gobridge.WithDefaultTimeout(500*time.Millisecond),
)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
// ── 示例1ctx 未设置 deadline,池的默认超时自动生效 ─────────────────────
// sleep_seconds(2) 耗时 2s,远超 500ms 默认超时,Invoke 会在 500ms 后返回
// context.DeadlineExceeded,而不是永久阻塞。
_, err = gobridge.Invoke[string](context.Background(), pool, "sleep_seconds", 2.0)
fmt.Println("sleep_seconds(2.0)(无 ctx deadline,池默认超时 500ms)→ err =", err)
// ── 示例2:调用方显式传入的 deadline 优先级更高,不会被默认超时覆盖 ───────
ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel2()
msg, err := gobridge.Invoke[string](ctx2, pool, "sleep_seconds", 1.0)
fmt.Println("sleep_seconds(1.0)(显式 3s ctx)→", msg, err)
// ── 示例3:流式输出模式下,超时只会静默关闭 channel,不返回 error
// 需要调用方自行在 range 结束后检查 ctx.Err() 才能区分"正常结束"还是"被打断"。
ctx3, cancel3 := context.WithTimeout(context.Background(), 350*time.Millisecond)
defer cancel3()
ch, err := gobridge.Invoke[chan int](ctx3, pool, "slow_range_gen", 1, 10, 100)
if err != nil {
log.Fatal(err)
}
fmt.Print("slow_range_gen(1, 10, 100ms)350ms ctx 超时)=")
for v := range ch {
fmt.Print(" ", v)
}
fmt.Println()
if ctx3.Err() != nil {
fmt.Println("channel 提前关闭,ctx.Err() =", ctx3.Err())
}
}
func demoBlocking(script string) {
fmt.Println("\n── 连接池占满后阻塞示例(不设超时)───────────────────────────")
// workers=2, maxConns=2:总容量只有 2*2=4 个连接槽位,便于演示"占满后新调用会怎样"
pool, err := gobridge.NewPool(script,
gobridge.WithWorkers(2),
gobridge.WithMaxConns(2),
)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
const capacity = 4 // 2 workers * 2 maxConns
const taskSeconds = 2.0 // 每个占位任务耗时 2s
// 并发发起 4 个耗时调用,占满全部连接槽位
var wg sync.WaitGroup
wg.Add(capacity)
for i := 0; i < capacity; i++ {
go func(n int) {
defer wg.Done()
if _, err := gobridge.Invoke[string](context.Background(), pool, "sleep_seconds", taskSeconds); err != nil {
log.Printf("occupy task %d failed: %v", n, err)
}
}(i)
}
// 留出时间让 4 个占位任务都真正进入 Python 侧 time.sleep(),占满全部槽位
time.Sleep(200 * time.Millisecond)
fmt.Printf("池子已被 %d 个任务占满(各耗时 %.0fs),此时发起第 5 个调用(ctx 不设超时)……\n", capacity, taskSeconds)
// 池子被占满时,新调用既不会立刻失败,也不会被跳过,而是排队阻塞等待连接释放。
// 这里故意不设超时,用等待耗时证明它确实在阻塞排队,而不是瞬间返回。
callStart := time.Now()
sum, err := gobridge.Invoke[int](context.Background(), pool, "add", 1, 2)
fmt.Printf("第 5 个调用等待 %v 后返回:add(1,2)=%d, err=%v\n", time.Since(callStart).Round(time.Millisecond), sum, err)
// 预期:等待时长接近"占满起 ~taskSeconds 秒后有槽位释放"的剩余时间,
// 说明调用被真正阻塞排队,直到某个占位任务结束、连接被释放后才继续执行。
wg.Wait()
}
func demoServer(ctx context.Context, script string) { func demoServer(ctx context.Context, script string) {
fmt.Println("\n── Server 全双工示例 ─────────────────────────────────────────────") fmt.Println("\n── Server 全双工示例 ─────────────────────────────────────────────")
+19
View File
@@ -5,6 +5,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "python"))
import dataclasses import dataclasses
import threading import threading
import time
from typing import Iterator from typing import Iterator
from gobridge import expose, call_go, run, worker_id, worker_count from gobridge import expose, call_go, run, worker_id, worker_count
@@ -120,6 +121,24 @@ def bytes_chunks(data: bytes, size: int):
yield data[i:i + size] yield data[i:i + size]
# ── 超时示例 ─────────────────────────────────────────────────────────────────
@expose
def sleep_seconds(n: float) -> str:
"""模拟一次耗时阻塞调用(不检查 ctx 取消),用于演示 WithDefaultTimeout"""
time.sleep(n)
return f"slept {n}s"
@expose
def slow_range_gen(start: int, stop: int, delay_ms: int) -> Iterator[int]:
"""流式输出,每个元素之间人为延迟,用于演示 ctx 超时会提前关闭 channel"""
for i in range(start, stop):
time.sleep(delay_ms / 1000)
yield i
# ── Server 全双工示例 ──────────────────────────────────────────────────────── # ── Server 全双工示例 ────────────────────────────────────────────────────────
+22 -7
View File
@@ -9,6 +9,7 @@ import (
"reflect" "reflect"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
) )
// poolConfig 是进程池内部配置,通过 Option 函数填充 // poolConfig 是进程池内部配置,通过 Option 函数填充
@@ -23,6 +24,7 @@ type poolConfig struct {
stdout io.Writer stdout io.Writer
stderr io.Writer stderr io.Writer
handler any handler any
defaultTimeout time.Duration
} }
// Option 是 NewPool 的函数选项 // Option 是 NewPool 的函数选项
@@ -74,6 +76,14 @@ func WithStderr(w io.Writer) Option {
return func(c *poolConfig) { c.stderr = w } return func(c *poolConfig) { c.stderr = w }
} }
// WithDefaultTimeout 设置 Invoke 调用的默认超时时间(默认不启用)。
// 仅当调用方传入的 ctx 未设置 deadline 时才会生效,用于防止调用方忘记设置超时导致
// Invoke 无限期阻塞(例如所有 worker 连接被占满,或 Python 侧 handler 阻塞不返回)。
// 调用方通过 context.WithTimeout 显式设置的超时优先级更高,不会被覆盖。
func WithDefaultTimeout(d time.Duration) Option {
return func(c *poolConfig) { c.defaultTimeout = d }
}
// WithHandlers 注册 Go handler struct,其所有公开方法自动暴露给 Python 通过 call_go() 调用。 // WithHandlers 注册 Go handler struct,其所有公开方法自动暴露给 Python 通过 call_go() 调用。
// //
// type MyService struct{} // type MyService struct{}
@@ -93,6 +103,7 @@ type Pool interface {
acquire(ctx context.Context) (net.Conn, *worker, error) acquire(ctx context.Context) (net.Conn, *worker, error)
nextReqID() uint64 nextReqID() uint64
callbackDispatch(ctx context.Context, msg Message) (any, string) callbackDispatch(ctx context.Context, msg Message) (any, string)
defaultTimeout() time.Duration
} }
// ── goHandler ──────────────────────────────────────────────────────────────── // ── goHandler ────────────────────────────────────────────────────────────────
@@ -105,15 +116,15 @@ type goHandler struct {
hasErr bool hasErr bool
} }
// ── pool(内部实现)───────────────────────────────────────────────────────── // ── pool(内部实现)─────────────────────────────────────────────────────────
type pool struct { type pool struct {
workers []*worker workers []*worker
idx atomic.Uint64 idx atomic.Uint64
reqID atomic.Uint64 reqID atomic.Uint64
mu sync.RWMutex mu sync.RWMutex
handlers map[string]goHandler handlers map[string]goHandler
defTimeout time.Duration
} }
// NewPool 创建并启动进程池。 // NewPool 创建并启动进程池。
@@ -151,7 +162,7 @@ func NewPool(script string, opts ...Option) (Pool, error) {
workers[i] = w workers[i] = w
} }
p := &pool{workers: workers, handlers: make(map[string]goHandler)} p := &pool{workers: workers, handlers: make(map[string]goHandler), defTimeout: cfg.defaultTimeout}
if cfg.handler != nil { if cfg.handler != nil {
p.bindHandlers(cfg.handler) p.bindHandlers(cfg.handler)
@@ -231,6 +242,10 @@ func (p *pool) nextReqID() uint64 {
return p.reqID.Add(1) return p.reqID.Add(1)
} }
func (p *pool) defaultTimeout() time.Duration {
return p.defTimeout
}
func (p *pool) callbackDispatch(ctx context.Context, msg Message) (any, string) { func (p *pool) callbackDispatch(ctx context.Context, msg Message) (any, string) {
p.mu.RLock() p.mu.RLock()
h, ok := p.handlers[msg.Method] h, ok := p.handlers[msg.Method]
+143
View File
@@ -0,0 +1,143 @@
package gobridge
import (
"context"
"errors"
"io"
"os/exec"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
)
// testWorkerScript 返回 example/worker.py 的绝对路径,供集成测试复用其中的
// sleep_seconds / add 等函数;没有 python3 时跳过测试。
func testWorkerScript(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 not found in PATH, skipping integration test")
}
_, file, _, _ := runtime.Caller(0)
return filepath.Join(filepath.Dir(file), "example", "worker.py")
}
func newTestPool(t *testing.T, opts ...Option) Pool {
t.Helper()
script := testWorkerScript(t)
// 注意:不要用 t.TempDir() 当 socketDir——macOS 的 AF_UNIX sun_path 只有 104
// 字节长度限制,t.TempDir() 产出的路径很容易超限导致 bind 失败,所以沿用库默认的 /tmp。
opts = append([]Option{
WithStdout(io.Discard),
WithStderr(io.Discard),
}, opts...)
pool, err := NewPool(script, opts...)
if err != nil {
t.Fatalf("NewPool: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
// TestPoolExhaustionBlocks 验证:workers=2、maxConns=2(总容量 4)的池子被占满后,
// 新的 Invoke 调用会排队阻塞等待连接释放,而不是立刻失败或被跳过;
// 一旦有连接释放,新调用能正常拿到连接并执行。
func TestPoolExhaustionBlocks(t *testing.T) {
pool := newTestPool(t, WithWorkers(2), WithMaxConns(2))
const capacity = 4
const taskSeconds = 5.0
var wg sync.WaitGroup
wg.Add(capacity)
for i := 0; i < capacity; i++ {
go func() {
defer wg.Done()
if _, err := Invoke[string](context.Background(), pool, "sleep_seconds", taskSeconds); err != nil {
t.Errorf("occupy task failed: %v", err)
}
}()
}
// 留出时间让 4 个占位任务都真正 acquire 到连接、进入 Python 侧 time.sleep(),占满全部 4 个槽位
time.Sleep(300 * time.Millisecond)
// 池子已被占满:用一个很短的超时验证新调用确实在排队等待连接(而非瞬间失败/跳过)
shortCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
start := time.Now()
_, err := Invoke[string](shortCtx, pool, "add", 1, 2)
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("want context.DeadlineExceeded while pool exhausted, got %v", err)
}
if elapsed < 250*time.Millisecond {
t.Fatalf("acquire returned too fast (%v),说明没有真正排队等待连接释放", elapsed)
}
// 等 4 个占位任务(各耗时 5s)全部结束、连接释放后,新调用应恢复正常
wg.Wait()
sum, err := Invoke[int](context.Background(), pool, "add", 3, 4)
if err != nil {
t.Fatalf("Invoke after release: %v", err)
}
if sum != 7 {
t.Fatalf("want 7, got %d", sum)
}
}
// TestDefaultTimeout 验证 WithDefaultTimeout:仅在 ctx 未设置 deadline 时生效,
// 调用方显式传入的 deadline 优先级更高,不会被覆盖。
func TestDefaultTimeout(t *testing.T) {
pool := newTestPool(t, WithWorkers(1), WithMaxConns(1), WithDefaultTimeout(300*time.Millisecond))
// ctx 未设置 deadline:池的默认超时应自动生效,而不是永久阻塞
start := time.Now()
_, err := Invoke[string](context.Background(), pool, "sleep_seconds", 5.0)
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("want context.DeadlineExceeded from default timeout, got %v", err)
}
if elapsed > 2*time.Second {
t.Fatalf("default timeout did not kick in promptly, took %v", elapsed)
}
// 显式传入的 deadline 优先级更高,不应被 300ms 的默认超时覆盖
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
msg, err := Invoke[string](ctx, pool, "sleep_seconds", 1.0)
if err != nil {
t.Fatalf("explicit deadline should override default timeout: %v", err)
}
if msg != "slept 1s" {
t.Fatalf("want %q, got %q", "slept 1s", msg)
}
}
// TestStreamTimeoutClosesChannelSilently 验证流式输出模式下,ctx 超时不会通过 error
// 返回,而是静默关闭已返回的 channel,需要调用方自行检查 ctx.Err()。
func TestStreamTimeoutClosesChannelSilently(t *testing.T) {
pool := newTestPool(t, WithWorkers(1), WithMaxConns(1))
ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
defer cancel()
ch, err := Invoke[chan int](ctx, pool, "slow_range_gen", 1, 10, 100)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
var got []int
for v := range ch {
got = append(got, v)
}
if len(got) == 0 || len(got) >= 9 {
t.Fatalf("want a partial stream (超时提前打断), got %v", got)
}
if ctx.Err() == nil {
t.Fatalf("want ctx.Err() != nil after timeout-truncated stream")
}
}
+5
View File
@@ -3,6 +3,7 @@ package gobridge
import ( import (
"context" "context"
"net" "net"
"time"
) )
// singleWorkerPool 是 Pool 的包装,所有请求固定路由到同一个 worker 进程。 // singleWorkerPool 是 Pool 的包装,所有请求固定路由到同一个 worker 进程。
@@ -49,3 +50,7 @@ func (s *singleWorkerPool) nextReqID() uint64 {
func (s *singleWorkerPool) callbackDispatch(ctx context.Context, msg Message) (any, string) { func (s *singleWorkerPool) callbackDispatch(ctx context.Context, msg Message) (any, string) {
return s.pool.callbackDispatch(ctx, msg) return s.pool.callbackDispatch(ctx, msg)
} }
func (s *singleWorkerPool) defaultTimeout() time.Duration {
return s.pool.defaultTimeout()
}