Files
countbot-ai__countbot/tests/test_tool_session_isolation.py

258 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""并发会话隔离回归测试。
背景bug渠道 handler 用 ``asyncio.create_task`` 并发处理多条入站消息,
且**同一个 handler 复用同一个 ToolRegistry 与同一批工具实例**。若工具把
session_id / cancel_token / message_context 存进**实例属性**,两条并发消息会
互相覆盖A 会话生成的文件被发到 B、取消 A 却打断 B、shell 子进程环境变量串号。
修复:这些上下文改用 per-instance ``contextvars.ContextVar`` 存储。由于
``create_task`` 会复制当前 Context每个任务内的 ``.set()`` 只作用于自己的
Context 副本,实例虽共享但读到的值按执行上下文隔离。
本测试直接复现「两个并发任务在同一个共享工具实例上先各自 set、再各自读回」的
时序。若回退成实例属性写法,`_Rendezvous` 保证两次 set 都先于读取发生,后写者
必然覆盖前者 → 至少一个任务读到对方的值 → 测试失败。
"""
import asyncio
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from backend.modules.tools.spawn import SpawnTool
from backend.modules.tools.workflow_tool import WorkflowTool
from backend.modules.tools.send_media import SendMediaTool
from backend.modules.tools.shell import ExecTool
from backend.modules.tools.registry import ToolRegistry
class _Rendezvous:
"""N 路会合点:所有参与者都到达后才一起放行。
用它强制「两个任务都完成 set 之后,才开始 read」的最坏交错
从而稳定复现共享实例属性被覆盖的竞态(否则时序偶发、测不出来)。
"""
def __init__(self, parties: int) -> None:
self._parties = parties
self._count = 0
self._event = asyncio.Event()
async def wait(self) -> None:
# 单线程事件循环内,自增与判断之间无 await天然原子
self._count += 1
if self._count >= self._parties:
self._event.set()
await self._event.wait()
async def _run_two_session_isolation(tool, setter_name, getter):
"""在同一个 tool 实例上并发跑两个「设置不同会话 → 会合 → 读回」的任务。
返回 {session_id: 读回的值},隔离正确时二者应各自读回自身。
"""
gate = _Rendezvous(2)
async def worker(session_id: str, payload):
# 每个 worker 作为独立 Task 运行(见下方 create_task拥有独立 Context 副本
getattr(tool, setter_name)(payload)
await gate.wait() # 等两个任务都 set 完,制造最坏交错
return getter(tool)
task_a = asyncio.create_task(worker("A", _payload_for("A")))
task_b = asyncio.create_task(worker("B", _payload_for("B")))
read_a, read_b = await asyncio.gather(task_a, task_b)
return read_a, read_b
def _payload_for(tag: str):
return f"session-{tag}"
# --------------------------------------------------------------------------
# 各工具的 session_id 隔离
# --------------------------------------------------------------------------
def test_spawn_tool_session_id_isolated_across_tasks():
async def scenario():
tool = SpawnTool(manager=object())
return await _run_two_session_isolation(
tool, "set_session_id", lambda t: t._session_id
)
read_a, read_b = asyncio.run(scenario())
assert read_a == "session-A", f"A 任务读到了 {read_a!r},会话上下文被串号"
assert read_b == "session-B", f"B 任务读到了 {read_b!r},会话上下文被串号"
def test_spawn_tool_set_context_also_isolated():
"""spawn 还有一个 set_context 入口loop.py 单独调用),同样必须隔离。"""
async def scenario():
tool = SpawnTool(manager=object())
return await _run_two_session_isolation(
tool, "set_context", lambda t: t._session_id
)
read_a, read_b = asyncio.run(scenario())
assert read_a == "session-A"
assert read_b == "session-B"
def test_workflow_tool_session_id_isolated_across_tasks():
async def scenario():
tool = WorkflowTool(subagent_manager=object())
return await _run_two_session_isolation(
tool, "set_session_id", lambda t: t._session_id
)
read_a, read_b = asyncio.run(scenario())
assert read_a == "session-A"
assert read_b == "session-B"
def test_send_media_tool_session_id_isolated_across_tasks():
async def scenario():
tool = SendMediaTool()
return await _run_two_session_isolation(
tool, "set_session_id", lambda t: t._current_session_id
)
read_a, read_b = asyncio.run(scenario())
assert read_a == "session-A"
assert read_b == "session-B"
# --------------------------------------------------------------------------
# 取消令牌隔离(取消 A 不能打断 B
# --------------------------------------------------------------------------
def test_spawn_tool_cancel_token_isolated_across_tasks():
async def scenario():
tool = SpawnTool(manager=object())
return await _run_two_session_isolation(
tool, "set_cancel_token", lambda t: t._cancel_token
)
token_a, token_b = asyncio.run(scenario())
assert token_a == "session-A", "A 读到的取消令牌被 B 覆盖 → 取消会打断错误的会话"
assert token_b == "session-B"
def test_workflow_tool_cancel_token_isolated_across_tasks():
async def scenario():
tool = WorkflowTool(subagent_manager=object())
return await _run_two_session_isolation(
tool, "set_cancel_token", lambda t: t._cancel_token
)
token_a, token_b = asyncio.run(scenario())
assert token_a == "session-A"
assert token_b == "session-B"
# --------------------------------------------------------------------------
# ExecTool 的 message_context 隔离(决定子进程渠道/发件人环境变量)
# --------------------------------------------------------------------------
def test_exec_tool_message_context_isolated_across_tasks(tmp_path):
async def scenario():
tool = ExecTool(workspace=tmp_path)
async def worker(tag, ctx):
tool.set_message_context(ctx)
await gate.wait()
return tool._message_context
gate = _Rendezvous(2)
ctx_a = {"metadata": {"channel": "A"}}
ctx_b = {"metadata": {"channel": "B"}}
ta = asyncio.create_task(worker("A", ctx_a))
tb = asyncio.create_task(worker("B", ctx_b))
return await asyncio.gather(ta, tb)
read_a, read_b = asyncio.run(scenario())
assert read_a == {"metadata": {"channel": "A"}}, "A 的消息上下文被 B 覆盖"
assert read_b == {"metadata": {"channel": "B"}}
# --------------------------------------------------------------------------
# 端到端:走真实的 ToolRegistry.set_session_id / set_cancel_token 分发路径
# --------------------------------------------------------------------------
def test_registry_dispatch_isolates_session_across_concurrent_tasks():
"""复现生产链路:并发任务各自 registry.set_session_id(X) 后读回对应工具。
这条路径正是 loop.process_message 每轮所走的registry 遍历工具注入上下文)。
"""
async def scenario():
registry = ToolRegistry()
spawn = SpawnTool(manager=object())
workflow = WorkflowTool(subagent_manager=object())
registry.register(spawn)
registry.register(workflow)
gate = _Rendezvous(2)
async def worker(session_id):
registry.set_session_id(session_id)
registry.set_cancel_token(f"token-{session_id}")
await gate.wait()
# 注册表自身的 contextvar以及被注入的两个工具都应读到本任务的值
return (
registry._session_id,
spawn._session_id,
workflow._session_id,
spawn._cancel_token,
workflow._cancel_token,
)
ta = asyncio.create_task(worker("A"))
tb = asyncio.create_task(worker("B"))
return await asyncio.gather(ta, tb)
(reg_a, spawn_a, wf_a, ctok_a, wtok_a), (reg_b, spawn_b, wf_b, ctok_b, wtok_b) = (
asyncio.run(scenario())
)
assert reg_a == "A" and reg_b == "B"
assert spawn_a == "A" and spawn_b == "B"
assert wf_a == "A" and wf_b == "B"
assert ctok_a == "token-A" and ctok_b == "token-B"
assert wtok_a == "token-A" and wtok_b == "token-B"
# --------------------------------------------------------------------------
# 元测试:证明本测试确实能抓住「实例属性」写法的 bug防止测试假阳性
# --------------------------------------------------------------------------
def test_rendezvous_would_catch_instance_attribute_regression():
"""用一个故意用实例属性的假工具,验证 _Rendezvous 时序能暴露串号。
若有人把某个工具改回实例属性存储,等价于这个 _BadTool本断言表明
在相同并发时序下它会失败——即上面的隔离测试不是摆设。
"""
class _BadTool:
def __init__(self):
self._sid = None # 共享实例属性(错误写法)
def set_session_id(self, sid):
self._sid = sid
async def scenario():
tool = _BadTool()
return await _run_two_session_isolation(
tool, "set_session_id", lambda t: t._sid
)
read_a, read_b = asyncio.run(scenario())
# 实例属性写法下,两个任务读到的是同一个(后写者)值,无法各自隔离
assert read_a == read_b, (
"预期实例属性写法会串号(两任务读到同值),若此处不相等说明测试时序失效"
)