fix: 修复流式输入 ctx 取消时 handler 线程永久阻塞泄漏的问题
流式输入模式下,handler 线程阻塞在 _ChunkIter.__next__ → chunk_q.get() 等待下一个输入块时,如果 ctx 取消导致 Go 关闭连接,_ConnMux._reader 原来只往 call_q 推了 None、给 _active_tids 里的线程注入 InterruptedError, 没有处理 chunk_q。而 PyThreadState_SetAsyncExc 打断不了阻塞在 queue.Queue.get()(无 timeout)上的线程,因为它是纯 C 层等待、不会回到 字节码解释循环检查待处理异常,导致这个线程永久卡死、泄漏。 修复:连接关闭时也往 chunk_q 推一个 None 哨兵,_ChunkIter.__next__ 已经 把 None 当作流结束处理,会正常抛 StopIteration 让线程退出。 新增 pool_test.go 里的 TestStreamInputCancelDoesNotLeakThread 复现并验证 修复(通过对比 count_threads() 前后线程数)。python 包版本号同步升到 0.1.5。
This commit is contained in:
@@ -37,6 +37,12 @@ def get_env(name: str) -> str:
|
|||||||
return os.environ.get(name, "")
|
return os.environ.get(name, "")
|
||||||
|
|
||||||
|
|
||||||
|
@expose
|
||||||
|
def count_threads() -> int:
|
||||||
|
"""返回当前 Python 进程存活线程数,用于检测阻塞在 chunk_q.get() 上的线程是否泄漏"""
|
||||||
|
return threading.active_count()
|
||||||
|
|
||||||
|
|
||||||
@expose
|
@expose
|
||||||
def range_gen(start: int, stop: int) -> Iterator[int]:
|
def range_gen(start: int, stop: int) -> Iterator[int]:
|
||||||
"""流式输出:对应 Go 侧 Invoke[chan int]"""
|
"""流式输出:对应 Go 侧 Invoke[chan int]"""
|
||||||
|
|||||||
@@ -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
|
// TestStreamTimeoutClosesChannelSilently 验证流式输出模式下,ctx 超时不会通过 error
|
||||||
// 返回,而是静默关闭已返回的 channel,需要调用方自行检查 ctx.Err()。
|
// 返回,而是静默关闭已返回的 channel,需要调用方自行检查 ctx.Err()。
|
||||||
func TestStreamTimeoutClosesChannelSilently(t *testing.T) {
|
func TestStreamTimeoutClosesChannelSilently(t *testing.T) {
|
||||||
|
|||||||
@@ -247,6 +247,11 @@ class _ConnMux:
|
|||||||
if msg is None:
|
if msg is None:
|
||||||
# 连接关闭:唤醒主循环,中断所有正在执行的函数,并唤醒所有 call_go 等待
|
# 连接关闭:唤醒主循环,中断所有正在执行的函数,并唤醒所有 call_go 等待
|
||||||
self.call_q.put(None)
|
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:
|
with self._lock:
|
||||||
for tid in self._active_tids.values():
|
for tid in self._active_tids.values():
|
||||||
_raise_in_thread(tid, InterruptedError)
|
_raise_in_thread(tid, InterruptedError)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "gobridge"
|
name = "gobridge"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
description = "Python 端库,配合 Go 侧 gobridge 使用"
|
description = "Python 端库,配合 Go 侧 gobridge 使用"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user