Files
blackbird/aibox/tests/test_process_manager.py
T
qing 5cb35945a4 feat(aibox): 后台进程池 v3.8——智能体运行/管理长驻进程与交互式 I/O
新增 ProcessManager 子系统 + 5 个 worker 执行面工具
(bg_run/bg_write/bg_list/bg_output/bg_kill),弥补 run_command
同步阻塞、超时即杀的不足;run_command 原路径零改动。

- 生命周期三决策:用户中断不影响后台进程;aibox 退出经 atexit
  收尾(非 detach 全杀,detach=true 豁免);注册表跨会话/跨子代理
  全局共享
- 交互式 I/O:stdin 管道 + watermark 写读对齐(bg_output(since=
  watermark) 必然且仅为本条输入的响应);wait_lines expect 式同步
  等待;写入 watchdog 线程兜底防管道写满阻塞
- 输出缓冲:每进程 256KB 环形缓冲(单行截断 16KB);log_file=true
  同步 tee 无界全档,可被原生工具 tail/cat 读取
- 三级优雅终止:关 stdin → SIGTERM 宽限 → SIGKILL 兜底
- 安全边界:运行上限 16/条目上限 32/单次写入 64KB;background_
  processes 配置节双闸;proc_name 规整规避 execute_tool 首参撞名
- 测试:test_process_manager.py 39 项(生命周期/游标续读/环形淘汰/
  watermark 对齐/EOF 优雅终止/log tee/工具分发),全量 532 passed
  / 4 failed(平台环境项,与基线一致)零回归
2026-08-21 11:17:11 +08:00

449 lines
17 KiB
Python
Raw 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.
"""后台进程管理器(subsystems.process_manager)与 bg_* 工具回归测试
覆盖三决策语义与安全边界:
- bg 生命周期:启动立即返回 / 惰性收割 / kill 幂等 / TTL 过期
- 输出缓冲:tail/since 游标续读 / 环形淘汰提示 / 单行截断 / 拉取字节上限
- 上限:运行中进程数上限 / 总条目 LRU 淘汰
- 收尾:shutdown 杀非 detach、保留 detach;atexit 单例共享
- 工具层:参数规整(字符串句柄)/ 双闸禁用 / 注册表分发
跨平台:长驻进程用 sys.executable -c "time.sleep(...)"(echo/ping 不可移植)。
Windows 注意:shell=True 下 terminate 只杀 shell 包装进程(与执行器 L9
回退语义一致),被杀判定以 popen.poll() 为准。
"""
import queue
import sys
import time
import types
import pytest
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
from subsystems import ProcessManager, get_process_manager, reset_process_manager # noqa: E402
from subsystems.process_manager import MAX_LINE_BYTES # noqa: E402
from tools import ( # noqa: E402
ToolRegistry,
BgRunTool, BgWriteTool, BgListTool, BgOutputTool, BgKillTool,
)
PY = sys.executable
@pytest.fixture()
def echo_srv(tmp_path):
"""行协议回环服务器:stdin 读一行 → stdout 回 ECHO:行,EOF 退出。
交互式 I/O(bg_write/watermark/优雅终止)测试的标准对端。"""
script = (
"import sys\n"
"while True:\n"
" line = sys.stdin.readline()\n"
" if not line:\n"
" break\n"
" sys.stdout.write('ECHO:' + line.strip() + '\\n')\n"
" sys.stdout.flush()\n"
)
f = tmp_path / "echo_srv.py"
f.write_text(script, encoding="utf-8")
return f'"{PY}" "{f}"'
def _py(code: str) -> str:
"""跨平台 python 命令(code 内只用单引号)"""
return f'"{PY}" -c "{code}"'
ECHO_10 = _py("for i in range(10): print('line-%d' % i)")
SLEEP_10 = _py("import time; time.sleep(10)")
HELLO = _py("print('hello-bg')")
@pytest.fixture()
def mgr():
"""每测试独立管理器(不走单例),收尾兜底清进程"""
m = ProcessManager()
try:
yield m
finally:
m.shutdown()
def _wait_status(m: ProcessManager, handle: int, status: str, timeout: float = 10.0):
"""轮询等待句柄达到指定状态"""
deadline = time.time() + timeout
while time.time() < deadline:
for p in m.list_procs():
if p["handle"] == handle and p["status"] == status:
return p
time.sleep(0.1)
return None
# ==================== 启动与生命周期 ====================
class TestStart:
def test_start_returns_handle_immediately(self, mgr):
t0 = time.time()
r = mgr.start(SLEEP_10, name="sleeper")
assert r["ok"] is True
assert time.time() - t0 < 2 # 立即返回,不等进程结束
assert isinstance(r["handle"], int) and r["pid"] > 0
assert r["name"] == "sleeper"
def test_start_default_name_from_command(self, mgr):
r = mgr.start(HELLO)
assert r["ok"] is True
# 默认名称 = 命令前 40 字符
assert r["name"] == HELLO.strip()[:40]
def test_start_rejects_empty_command(self, mgr):
assert mgr.start(" ")["ok"] is False
assert mgr.start(None)["ok"] is False
def test_start_rejects_bad_name(self, mgr):
# 名称含控制字符(M5 惯例:防终端/前端注入)
assert mgr.start(HELLO, name="bad\x1b[31m")["ok"] is False
assert mgr.start(HELLO, name="x" * 65)["ok"] is False
def test_start_missing_binary_returns_error(self, mgr):
r = mgr.start("definitely-not-a-command-xyz --version")
# shell 本身能启动(rc=127/9009),故启动成功、退出码非零
assert r["ok"] is True
p = _wait_status(mgr, r["handle"], "exited")
assert p is not None
assert p["exit_code"] != 0
class TestLifecycle:
def test_exited_status_and_exit_code(self, mgr):
r = mgr.start(HELLO)
p = _wait_status(mgr, r["handle"], "exited")
assert p is not None
assert p["exit_code"] == 0
def test_kill_running_process(self, mgr):
r = mgr.start(SLEEP_10)
time.sleep(0.5) # 等进程真正起来
k = mgr.kill(r["handle"])
assert k["ok"] is True and k["status"] == "killed"
assert mgr._procs[r["handle"]].popen.poll() is not None
def test_kill_is_idempotent_on_exited(self, mgr):
r = mgr.start(HELLO)
assert _wait_status(mgr, r["handle"], "exited") is not None
k = mgr.kill(r["handle"])
assert k["ok"] is True and k["already_finished"] is True
def test_kill_missing_handle(self, mgr):
assert mgr.kill(999)["ok"] is False
def test_ttl_expiry(self):
m = ProcessManager(max_lifetime=0.5)
try:
r = m.start(SLEEP_10)
time.sleep(0.8)
# list 触发惰性 sweep
assert _wait_status(m, r["handle"], "expired", timeout=2) is not None
finally:
m.shutdown()
def test_max_processes_limit(self):
m = ProcessManager(max_processes=2)
try:
assert m.start(SLEEP_10)["ok"] is True
assert m.start(SLEEP_10)["ok"] is True
r3 = m.start(SLEEP_10)
assert r3["ok"] is False and "上限" in r3["error"]
finally:
m.shutdown()
def test_max_entries_evicts_oldest_exited(self):
m = ProcessManager(max_entries=2)
try:
h1 = m.start(HELLO)["handle"]
h2 = m.start(HELLO)["handle"]
assert _wait_status(m, h1, "exited") is not None
assert _wait_status(m, h2, "exited") is not None
# 第三条触发最老已退出条目(#1)LRU 淘汰
h3 = m.start(HELLO)["handle"]
assert h3 > 0
handles = {p["handle"] for p in m.list_procs()}
assert h1 not in handles and h2 in handles and h3 in handles
finally:
m.shutdown()
# ==================== 输出缓冲与游标 ====================
class TestOutput:
def test_tail_and_since_cursor(self, mgr):
r = mgr.start(ECHO_10)
assert _wait_status(mgr, r["handle"], "exited") is not None
time.sleep(0.3) # reader 排空末尾行
out = mgr.get_output(r["handle"], tail=3)
assert out["ok"] is True
assert out["lines"] == 3
assert "line-9" in out["output"] and "line-6" not in out["output"]
full = mgr.get_output(r["handle"], since=0)
assert full["lines"] == 10
assert full["next_cursor"] == 10
inc = mgr.get_output(r["handle"], since=full["next_cursor"])
assert inc["lines"] == 0 and inc["next_cursor"] == 10
def test_since_before_eviction_reports_gap(self):
m = ProcessManager(buffer_per_process=2048)
try:
r = m.start(_py("for i in range(50): print('pad-%03d-%s' % (i, 'x'*80))"))
assert _wait_status(m, r["handle"], "exited") is not None
time.sleep(0.3)
out = m.get_output(r["handle"], since=0)
assert out["ok"] is True
assert out["evicted"] > 0 # 早期行被环形淘汰
assert out["lines"] < 50
finally:
m.shutdown()
def test_single_line_truncated(self, mgr):
r = mgr.start(_py("print('A' * (1024*64))"))
assert _wait_status(mgr, r["handle"], "exited") is not None
time.sleep(0.3)
out = mgr.get_output(r["handle"], tail=5)
assert "单行截断" in out["output"]
# 缓冲内存预算不被超长单行击穿
entry = mgr._procs[r["handle"]]
assert entry.buffer_bytes <= MAX_LINE_BYTES + 1024
def test_fetch_byte_limit_truncates(self, mgr):
r = mgr.start(_py("for i in range(100): print('y' * 200)"))
assert _wait_status(mgr, r["handle"], "exited") is not None
time.sleep(0.3)
out = mgr.get_output(r["handle"], since=0, max_bytes=4096)
assert out["truncated"] is True
def test_missing_handle(self, mgr):
assert mgr.get_output(999)["ok"] is False
def test_invalid_since(self, mgr):
r = mgr.start(HELLO)
assert mgr.get_output(r["handle"], since="abc")["ok"] is False
# ==================== 交互式 I/O(stdin 管道 + watermark 对齐) ====================
class TestInteractive:
def test_write_roundtrip_watermark_alignment(self, mgr, echo_srv):
"""核心对齐语义:bg_output(since=watermark) = 本条输入的响应且不含回显"""
h = mgr.start(echo_srv)["handle"]
r = mgr.write(h, "hello", wait_lines=1, wait_timeout=10)
assert r["ok"] is True
assert "ECHO:hello" in r["wait"]["output"]
assert r["wait"]["timed_out"] is False
out = mgr.get_output(h, since=r["watermark"])
assert "ECHO:hello" in out["output"]
assert "[stdin]" not in out["output"] # 回显行序号 == watermark,不包含
def test_echo_line_visible_in_tail(self, mgr, echo_srv):
"""[stdin] 回显进环形缓冲,tail 视图可见完整交互时序"""
h = mgr.start(echo_srv)["handle"]
assert mgr.write(h, "abc", wait_lines=1)["ok"] is True
out = mgr.get_output(h, tail=10)
assert "[stdin] abc" in out["output"]
assert "ECHO:abc" in out["output"]
def test_write_wait_sync_roundtrip(self, mgr, echo_srv):
"""wait_lines 同步等待:一步问答(expect 式)"""
h = mgr.start(echo_srv)["handle"]
r = mgr.write(h, "1+1", wait_lines=1)
assert r["wait"]["lines"] == 1
assert r["wait"]["output"].strip() == "ECHO:1+1"
assert r["wait"]["next_cursor"] == r["watermark"] + 1
def test_write_wait_timeout_on_silent_process(self, mgr):
"""不读不响应的进程:写入成功(管道缓冲),等待如实超时"""
h = mgr.start(SLEEP_10)["handle"]
r = mgr.write(h, "ping", wait_lines=1, wait_timeout=0.5)
assert r["ok"] is True
assert r["wait"]["timed_out"] is True
assert r["wait"]["lines"] == 0
def test_write_to_exited_process(self, mgr):
h = mgr.start(HELLO)["handle"]
assert _wait_status(mgr, h, "exited") is not None
assert mgr.write(h, "x")["ok"] is False
def test_write_rejects_empty_and_oversize(self, mgr, echo_srv):
h = mgr.start(echo_srv)["handle"]
assert mgr.write(h, "")["ok"] is False
assert mgr.write(h, None)["ok"] is False
assert mgr.write(h, "A" * (64 * 1024 + 1))["ok"] is False
def test_stdin_eof_graceful_kill(self, mgr, echo_srv):
"""三级优雅终止第一级:关 stdin → 回环服务器见 EOF 自行退出"""
h = mgr.start(echo_srv)["handle"]
time.sleep(0.5)
k = mgr.kill(h) # 不 force:先关 stdin
assert k["ok"] is True
assert mgr._procs[h].popen.poll() is not None
assert _wait_status(mgr, h, "killed") is not None
def test_write_after_kill_rejected(self, mgr, echo_srv):
h = mgr.start(echo_srv)["handle"]
assert mgr.kill(h)["ok"] is True
assert mgr.write(h, "late")["ok"] is False
def test_log_file_tee(self, mgr, monkeypatch, tmp_path):
"""log_file 全档镜像:输出与 [stdin] 回显均落盘,可被原生工具读取"""
from common import constants as cm
monkeypatch.setattr(type(cm), "WORKSPACE_DIR",
property(lambda self: str(tmp_path)))
script = tmp_path / "srv.py"
script.write_text(
"import sys\n"
"for line in sys.stdin:\n"
" sys.stdout.write('ECHO:' + line.strip() + '\\n')\n"
" sys.stdout.flush()\n",
encoding="utf-8")
cmd = f'"{PY}" "{script}"'
r = mgr.start(cmd, log_file=True)
assert r["ok"] is True and r["log_file"] is not None
assert mgr.write(r["handle"], "tee-me", wait_lines=1)["ok"] is True
mgr.kill(r["handle"])
log = (tmp_path / f"bg_{r['handle']}.log").read_text(encoding="utf-8")
assert "[stdin] tee-me" in log
assert "ECHO:tee-me" in log
def test_bg_write_tool_flow(self, mgr, echo_srv):
"""工具层冒烟:BgWriteTool 参数规整 + 对齐读取提示"""
assert BgRunTool(manager=mgr).execute(command=echo_srv).startswith("✅")
out = BgWriteTool(manager=mgr).execute(handle="1", text="hi",
wait_lines="1", wait_timeout=10)
assert "ECHO:hi" in out
assert "watermark" in out
# 描述里的行缓冲指引(给模型看的适用前提)
assert "PYTHONUNBUFFERED" in BgWriteTool(manager=mgr).get_description()
def test_registry_dispatch_bg_write(self, mgr, echo_srv):
reg = ToolRegistry()
reg.register_many([BgRunTool(manager=mgr), BgWriteTool(manager=mgr)],
is_builtin=True)
assert reg.execute_tool("bg_run", command=echo_srv).startswith("✅")
r = reg.execute_tool("bg_write", handle=1, text="via-reg",
wait_lines=1, wait_timeout=10)
assert "ECHO:via-reg" in r
# ==================== 退出收尾 ====================
class TestShutdown:
def test_shutdown_kills_non_detach_keeps_detach(self):
m = ProcessManager()
try:
h1 = m.start(SLEEP_10)["handle"] # 常规:退出时终止
h2 = m.start(SLEEP_10, detach=True)["handle"] # detach:保留
time.sleep(0.5)
e1, e2 = m._procs[h1], m._procs[h2]
r = m.shutdown()
assert r["killed"] == 1 and r["detached"] == 1
assert e1.popen.poll() is not None # 常规进程已终止
assert e2.popen.poll() is None # detach 进程仍存活
assert m.list_procs() == [] # 注册表已清空(detach 仅注销)
# 幂等
assert m.shutdown() == {"killed": 0, "detached": 0}
# 收尾后拒绝新进程
assert m.start(HELLO)["ok"] is False
# 清理 detach 测试进程
e2.popen.kill()
finally:
m.shutdown()
def test_singleton_shared_across_callers(self):
m1 = get_process_manager()
m2 = get_process_manager()
assert m1 is m2 # 跨会话/跨子代理共享同一注册表
reset_process_manager(None)
m3 = get_process_manager()
assert m3 is not m1
m3.shutdown()
reset_process_manager(None)
# ==================== 工具层 ====================
class TestTools:
def test_bg_run_and_output_flow(self, mgr):
run = BgRunTool(manager=mgr)
r = run.execute(command=HELLO, proc_name="hello")
assert r.startswith("✅") and "#" in r
handle = 1
assert _wait_status(mgr, handle, "exited") is not None
time.sleep(0.3)
out = BgOutputTool(manager=mgr).execute(handle=handle, since=0)
assert "hello-bg" in out
assert "next_cursor" in out
def test_bg_run_string_handle_coercion(self, mgr):
"""LLM 可能传字符串数字句柄(W 批输入规整惯例)"""
run = BgRunTool(manager=mgr)
assert run.execute(command=HELLO).startswith("✅")
assert _wait_status(mgr, 1, "exited") is not None
out = BgOutputTool(manager=mgr).execute(handle="1", tail=5)
assert "hello-bg" in out
def test_bg_run_missing_command(self, mgr):
r = BgRunTool(manager=mgr).execute()
assert "缺少" in r
def test_bg_list_empty_and_filled(self, mgr):
lst = BgListTool(manager=mgr)
assert "没有后台进程" in lst.execute()
mgr.start(SLEEP_10, name="sleeper")
r = lst.execute()
assert "sleeper" in r and "运行中 1" in r
def test_bg_kill_flow(self, mgr):
mgr.start(SLEEP_10, name="sleeper")
time.sleep(0.5)
r = BgKillTool(manager=mgr).execute(handle=1)
assert r.startswith("✅") and "已终止" in r
def test_disabled_gate_blocks_all(self, mgr, monkeypatch):
from common import constants
monkeypatch.setattr(type(constants), "BG_PROCESSES_ENABLED",
property(lambda self: False))
assert "禁用" in BgRunTool(manager=mgr).execute(command=HELLO)
assert "禁用" in BgListTool(manager=mgr).execute()
assert "禁用" in BgOutputTool(manager=mgr).execute(handle=1)
assert "禁用" in BgKillTool(manager=mgr).execute(handle=1)
def test_registry_dispatch(self, mgr):
"""4 个工具经 ToolRegistry 正常分发(worker 注册链路冒烟)"""
reg = ToolRegistry()
reg.register_many([
BgRunTool(manager=mgr), BgListTool(manager=mgr),
BgOutputTool(manager=mgr), BgKillTool(manager=mgr),
], is_builtin=True)
r = reg.execute_tool("bg_run", command=HELLO, proc_name="smoke")
assert r.startswith("✅")
assert _wait_status(mgr, 1, "exited") is not None
time.sleep(0.3)
r = reg.execute_tool("bg_output", handle=1, since=0)
assert "hello-bg" in r
r = reg.execute_tool("bg_list")
assert "smoke" in r
def test_worker_only_tools_includes_bg():
"""编排者误调拦截表包含 bg 工具(worker 执行面边界)"""
from aibox import WORKER_ONLY_TOOLS
assert {"bg_run", "bg_write", "bg_list", "bg_output", "bg_kill"} <= WORKER_ONLY_TOOLS