diff --git a/example/worker.py b/example/worker.py index 84e048c..a10af1a 100644 --- a/example/worker.py +++ b/example/worker.py @@ -37,6 +37,12 @@ def get_env(name: str) -> str: return os.environ.get(name, "") +@expose +def count_threads() -> int: + """返回当前 Python 进程存活线程数,用于检测阻塞在 chunk_q.get() 上的线程是否泄漏""" + return threading.active_count() + + @expose def range_gen(start: int, stop: int) -> Iterator[int]: """流式输出:对应 Go 侧 Invoke[chan int]""" diff --git a/pool_test.go b/pool_test.go index fc1bfe2..3a97192 100644 --- a/pool_test.go +++ b/pool_test.go @@ -177,6 +177,48 @@ func TestStreamErrorMidwayCorruptsNextCall(t *testing.T) { } } +// TestStreamInputCancelDoesNotLeakThread 验证流式输入模式下,handler 线程阻塞在 +// _ChunkIter.__next__ → chunk_q.get() 等待下一个输入块时,如果 ctx 取消导致连接被 +// Go 关闭,Python 侧能否真正把这个线程唤醒退出,而不是永久卡住。 +// +// _ConnMux._reader 在连接关闭时只处理了 call_q(推 None)和 _active_tids(注入 +// InterruptedError),完全没有触碰 chunk_q;而 handler 线程当前正阻塞在 +// chunk_q.get() 里——问题是 PyThreadState_SetAsyncExc 能不能真正打断一个阻塞在 +// queue.Queue.get()(无 timeout)上的线程。如果不能,这个线程会永久泄漏:既不会 +// 收到任何新数据,也不会因为异常注入而退出,因为 chunk_q 以后也不会再有任何写入。 +func TestStreamInputCancelDoesNotLeakThread(t *testing.T) { + pool := newTestPool(t, WithWorkers(1), WithMaxConns(2)) + ctx := context.Background() + + baseline, err := Invoke[int](ctx, pool, "count_threads") + if err != nil { + t.Fatalf("Invoke count_threads (baseline): %v", err) + } + + // inputCh 故意不发送任何数据也不关闭,让 sum_stream 的 handler 线程卡在 + // _ChunkIter.__next__ → chunk_q.get() 上等待第一个 chunk。 + inputCh := make(chan int) + shortCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond) + defer cancel() + _, err = Invoke[int](shortCtx, pool, "sum_stream", inputCh) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("want context.DeadlineExceeded, got %v", err) + } + + // 给 Python 侧一点时间处理连接关闭 / InterruptedError 注入 + time.Sleep(500 * time.Millisecond) + + after, err := Invoke[int](ctx, pool, "count_threads") + if err != nil { + t.Fatalf("Invoke count_threads (after): %v", err) + } + + t.Logf("thread count: baseline=%d after=%d", baseline, after) + if after > baseline { + t.Fatalf("线程泄漏:baseline=%d after=%d,卡在 chunk_q.get() 上的 handler 线程没有被 InterruptedError 唤醒退出", baseline, after) + } +} + // TestStreamTimeoutClosesChannelSilently 验证流式输出模式下,ctx 超时不会通过 error // 返回,而是静默关闭已返回的 channel,需要调用方自行检查 ctx.Err()。 func TestStreamTimeoutClosesChannelSilently(t *testing.T) { diff --git a/python/gobridge/__init__.py b/python/gobridge/__init__.py index 36a6447..69e3521 100644 --- a/python/gobridge/__init__.py +++ b/python/gobridge/__init__.py @@ -247,6 +247,11 @@ class _ConnMux: if msg is None: # 连接关闭:唤醒主循环,中断所有正在执行的函数,并唤醒所有 call_go 等待 self.call_q.put(None) + # 唤醒卡在 chunk_q.get() 上的流式输入线程(_ChunkIter)—— + # PyThreadState_SetAsyncExc 打断不了阻塞在 queue.Queue.get() 上的 + # C 层等待,必须显式推一个哨兵值,_ChunkIter.__next__ 已经把 + # None 当作流结束处理,会正常抛 StopIteration 退出。 + self.chunk_q.put(None) with self._lock: for tid in self._active_tids.values(): _raise_in_thread(tid, InterruptedError) diff --git a/python/pyproject.toml b/python/pyproject.toml index 04da48f..171f009 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "gobridge" -version = "0.1.4" +version = "0.1.5" description = "Python 端库,配合 Go 侧 gobridge 使用" requires-python = ">=3.10"