mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
346 lines
18 KiB
Python
346 lines
18 KiB
Python
"""真实终端输入、EOF、中断与平台信号合同;测试只拥有自身创建的进程和描述符。"""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import os
|
||
import shlex
|
||
import subprocess
|
||
import sys
|
||
from collections.abc import AsyncIterator
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, Mock
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
|
||
from app.agent.shell import AgentShell
|
||
from app.agent.terminal import manager as terminal
|
||
from app.agent.terminal.manager import _TerminalSessionManager
|
||
from app.agent.terminal.output import TerminalOutputError
|
||
from app.agent.terminal.ownership import current_terminal_scope
|
||
from app.agent.terminal.session import _TerminalSession
|
||
|
||
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
||
|
||
|
||
def _command(code: str) -> str:
|
||
"""使用当前虚拟环境,POSIX exec 让被测程序直接拥有会话进程与信号处理器。"""
|
||
args = [sys.executable, "-u", "-c", code]
|
||
return subprocess.list2cmdline(args) if os.name == "nt" else "exec " + shlex.join(args)
|
||
|
||
|
||
def _cursor(payload: dict[str, Any]) -> dict[str, int]:
|
||
"""只续传已经交付的位置,不把输入/信号回执的高水位当成消费证据。"""
|
||
return {"since_seq": payload["output_until_seq"], "since_offset": payload["output_until_offset"]}
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def manager() -> AsyncIterator[_TerminalSessionManager]:
|
||
"""每个测试拥有独立管理器,失败与取消时也终止全部测试子进程。"""
|
||
current = _TerminalSessionManager()
|
||
try:
|
||
yield current
|
||
finally:
|
||
await asyncio.wait_for(current.close(), 10)
|
||
|
||
|
||
async def _ready(manager: _TerminalSessionManager, code: str, *, use_pty: bool = False) -> dict[str, Any]:
|
||
"""等待程序显式发布 READY,不启动用户登录 shell,也不靠固定休眠猜就绪。"""
|
||
options = {"shell": "/bin/sh", "login": False} if os.name == "posix" else {}
|
||
payload = await asyncio.wait_for(manager.start(
|
||
command=_command(code), use_pty=use_pty, yield_time_ms=10000, since_offset=0, **options,
|
||
), 5)
|
||
assert "READY" in payload["output"]
|
||
return payload
|
||
|
||
|
||
async def _read_to(manager: _TerminalSessionManager, current: dict[str, Any], marker: str = "") -> tuple[str, dict[str, Any]]:
|
||
"""读到指定程序握手或输出最终收尾,超时仅用于测试失败保护。"""
|
||
output = current["output"]
|
||
async with asyncio.timeout(5):
|
||
while (marker and marker not in output) or (not marker and not current["output_complete"]):
|
||
current = await manager.wait(session_id=current["session_id"], timeout_ms=1000, **_cursor(current))
|
||
output += current["output"]
|
||
return output, current
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipe_last_input_and_half_close_deliver_all_bytes(manager: _TerminalSessionManager) -> None:
|
||
"""依赖 EOF 的真实程序必须收到全部末段输入,并能继续读完最终输出。"""
|
||
payload = await _ready(manager, "import sys,hashlib\nprint('READY',flush=True)\ndata=sys.stdin.buffer.read()\nprint('RESULT:'+str(len(data))+':'+hashlib.sha256(data).hexdigest(),flush=True)")
|
||
text = "\x03\x04" + "完整末段😀\n" * 6000
|
||
data = text.encode()
|
||
empty = await manager.write(session_id=payload["session_id"], input_text="", **_cursor(payload))
|
||
assert empty["written_bytes"] == 0 and empty["stdin_closed"] is False
|
||
result = await manager.write(session_id=payload["session_id"], input_text=text, close_stdin=True, **_cursor(empty))
|
||
assert result["written_bytes"] == len(data) and result["stdin_closed"] is True
|
||
output, final = await _read_to(manager, result)
|
||
assert f"RESULT:{len(data)}:{hashlib.sha256(data).hexdigest()}" in output
|
||
assert final["exit_code"] == 0 and final["output_complete"] is True
|
||
repeated = await manager.write(session_id=payload["session_id"], input_text="", close_stdin=True, **_cursor(final))
|
||
assert repeated["written_bytes"] == 0 and repeated["stdin_closed"] is True
|
||
with pytest.raises(RuntimeError, match="stdin 已关闭"):
|
||
await manager.write(session_id=payload["session_id"], input_text="late", **_cursor(repeated))
|
||
with pytest.raises(RuntimeError, match="stdin 已关闭"):
|
||
await manager.write(session_id=payload["session_id"], input_text="", **_cursor(repeated))
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.skipif(os.name != "posix", reason="本例验证 POSIX PTY 的双向描述符")
|
||
async def test_pty_half_close_rejects_before_input_and_keeps_output_readable(manager: _TerminalSessionManager) -> None:
|
||
"""不支持的组合不能先写入 BAD,也不能通过关闭 master 破坏输出。"""
|
||
payload = await _ready(manager, "import sys,tty\ntty.setraw(0)\nprint('READY',flush=True)\ndata=sys.stdin.buffer.read(1)\nprint('GOT:'+str(data[0]),flush=True)", use_pty=True)
|
||
session = manager._sessions[payload["session_id"]]
|
||
descriptor = session.master_fd
|
||
with pytest.raises(ValueError, match="PTY 不支持"):
|
||
await manager.write(session_id=session.session_id, input_text="BAD", close_stdin=True, **_cursor(payload))
|
||
assert session.master_fd == descriptor and session.status == "running" and not session.stdin_closed
|
||
result = await manager.write(session_id=session.session_id, input_text="Z", **_cursor(payload))
|
||
output, final = await _read_to(manager, result)
|
||
assert "GOT:90" in output and final["exit_code"] == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.skipif(os.name != "posix", reason="本例验证 POSIX 非阻塞 PTY 短写和回压")
|
||
async def test_large_raw_pty_input_retries_short_writes_without_losing_tail(
|
||
manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""独立控制管道暂停读者,确定性填满 PTY 后再释放,验证真实短写不会丢尾。"""
|
||
control_read, control_write = os.pipe()
|
||
os.set_inheritable(control_read, True)
|
||
text = "0123456789abcdef" * 4096
|
||
data = text.encode()
|
||
loop = asyncio.get_running_loop()
|
||
blocked = asyncio.Event()
|
||
writes: list[tuple[int, int]] = []
|
||
original_write = os.write
|
||
task = None
|
||
try:
|
||
code = ("import os,sys,tty,hashlib\ntty.setraw(0)\nprint('READY',flush=True)\n"
|
||
f"os.read({control_read},1)\nos.close({control_read})\ndata=sys.stdin.buffer.read({len(data)})\n"
|
||
"print('RESULT:'+str(len(data))+':'+hashlib.sha256(data).hexdigest(),flush=True)")
|
||
payload = await _ready(manager, code, use_pty=True)
|
||
descriptor = manager._sessions[payload["session_id"]].master_fd
|
||
|
||
def observe_write(fd: int, content: Any) -> int:
|
||
"""只观察该测试 PTY 的真实写入计数与回压,不替换内核返回值。"""
|
||
try:
|
||
count = original_write(fd, content)
|
||
except BlockingIOError:
|
||
if fd == descriptor:
|
||
loop.call_soon_threadsafe(blocked.set)
|
||
raise
|
||
if fd == descriptor:
|
||
writes.append((len(content), count))
|
||
return count
|
||
|
||
monkeypatch.setattr(terminal.os, "write", observe_write)
|
||
task = asyncio.create_task(manager.write(session_id=payload["session_id"], input_text=text, **_cursor(payload)))
|
||
await asyncio.wait_for(blocked.wait(), 5)
|
||
original_write(control_write, b"g")
|
||
result = await asyncio.wait_for(task, 5)
|
||
assert any(count < requested for requested, count in writes)
|
||
assert result["written_bytes"] == len(data)
|
||
output, final = await _read_to(manager, result)
|
||
assert f"RESULT:{len(data)}:{hashlib.sha256(data).hexdigest()}" in output
|
||
assert final["exit_code"] == 0
|
||
finally:
|
||
if task is not None and not task.done():
|
||
task.cancel()
|
||
await asyncio.gather(task, return_exceptions=True)
|
||
os.close(control_read)
|
||
os.close(control_write)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.skipif(os.name != "posix", reason="本例验证 POSIX SIGINT 处理器")
|
||
async def test_interrupt_calls_handler_once_and_program_remains_interactive(manager: _TerminalSessionManager) -> None:
|
||
"""SIGINT 不设置 kill_requested,也不在等待后升级;处理器返回后仍能输入。"""
|
||
code = ("import signal,sys\nsignal.signal(signal.SIGINT,lambda *_:print('INTERRUPTED',flush=True))\n"
|
||
"print('READY',flush=True)\nfor line in sys.stdin:\n print('ECHO:'+line.strip(),flush=True)\n if line.strip()=='quit':break\n")
|
||
payload = await _ready(manager, code)
|
||
result = await manager.interrupt(session_id=payload["session_id"], **_cursor(payload))
|
||
assert result["signal"] == "SIGINT" and result["signal_sent"] is True
|
||
output, after = await _read_to(manager, result, "INTERRUPTED")
|
||
assert output.count("INTERRUPTED") == 1
|
||
session = manager._sessions[payload["session_id"]]
|
||
assert session.status == "running" and session.kill_requested is False
|
||
reply = await manager.write(session_id=session.session_id, input_text="quit\n", **_cursor(after))
|
||
output, final = await _read_to(manager, reply)
|
||
assert "ECHO:quit" in output and final["exit_code"] == 0 and final["status"] == "exited"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("value", ["NOT_A_SIGNAL", "", 0, "0", True, False, -1, 99999, "SIG_IGN", "SIG_UNBLOCK"])
|
||
async def test_invalid_signal_has_no_os_or_kill_intent_effect(value: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""未知信号、处理器常量和无效编号必须在任何状态变化之前拒绝。"""
|
||
current = _TerminalSessionManager()
|
||
session = _TerminalSession(owner=current_terminal_scope(), session_id="signal-validation", command="memory-only", cwd=".", pid=0, use_pty=False)
|
||
current._sessions[session.session_id] = session
|
||
send = Mock()
|
||
monkeypatch.setattr(current, "_send_signal", send)
|
||
with pytest.raises(ValueError):
|
||
await current.kill(session_id=session.session_id, sig=value)
|
||
send.assert_not_called()
|
||
assert session.kill_requested is False and session.status == "running"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_concurrent_pipe_write_then_close_serializes_final_segment(
|
||
manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""真实管道首段尚未 drain 时,close 请求必须等待输入锁并完整交付末段。"""
|
||
payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nprint('DATA:'+sys.stdin.read(),flush=True)")
|
||
session = manager._sessions[payload["session_id"]]
|
||
writer = session.process.stdin
|
||
original_drain = writer.drain
|
||
entered, release = asyncio.Event(), asyncio.Event()
|
||
count = 0
|
||
|
||
async def drain() -> None:
|
||
"""通过事件放大真实 drain 的持锁窗口,不伪造实际字节交付。"""
|
||
nonlocal count
|
||
count += 1
|
||
if count == 1:
|
||
entered.set()
|
||
await release.wait()
|
||
await original_drain()
|
||
|
||
monkeypatch.setattr(writer, "drain", drain)
|
||
first = asyncio.create_task(manager.write(session_id=session.session_id, input_text="first-", **_cursor(payload)))
|
||
close = None
|
||
try:
|
||
await asyncio.wait_for(entered.wait(), 5)
|
||
close = asyncio.create_task(manager.write(session_id=session.session_id, input_text="last", close_stdin=True, **_cursor(payload)))
|
||
assert session.input_lock.locked() and not session.stdin_closed
|
||
release.set()
|
||
await asyncio.wait_for(first, 5)
|
||
result = await asyncio.wait_for(close, 5)
|
||
output, final = await _read_to(manager, result)
|
||
assert "DATA:first-last" in output and final["exit_code"] == 0
|
||
finally:
|
||
release.set()
|
||
for task in (first, close):
|
||
if task is not None and not task.done():
|
||
task.cancel()
|
||
await asyncio.gather(*(task for task in (first, close) if task is not None), return_exceptions=True)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_concurrent_input_after_close_is_rejected_before_writer_receives_it() -> None:
|
||
"""关闭写端等待收尾期间,后续输入不能插入 close 与完成确认之间。"""
|
||
current = _TerminalSessionManager()
|
||
entered, release = asyncio.Event(), asyncio.Event()
|
||
values: list[bytes] = []
|
||
|
||
async def wait_closed() -> None:
|
||
"""确定性保持关闭请求持有输入锁,测试不启动虚构 PID。"""
|
||
entered.set()
|
||
await release.wait()
|
||
|
||
writer = SimpleNamespace(write=values.append, drain=AsyncMock(), close=Mock(), wait_closed=wait_closed)
|
||
session = _TerminalSession(owner=current_terminal_scope(), session_id="close-race", command="memory-only", cwd=".", pid=0, use_pty=False,
|
||
process=SimpleNamespace(stdin=writer))
|
||
current._sessions[session.session_id] = session
|
||
close = asyncio.create_task(current.write(session_id=session.session_id, input_text="tail", close_stdin=True))
|
||
late = None
|
||
try:
|
||
await entered.wait()
|
||
late = asyncio.create_task(current.write(session_id=session.session_id, input_text="late"))
|
||
release.set()
|
||
result = await close
|
||
with pytest.raises(RuntimeError, match="stdin 已关闭"):
|
||
await late
|
||
assert values == [b"tail"] and result["stdin_closed"] is True
|
||
finally:
|
||
release.set()
|
||
await asyncio.gather(*(task for task in (close, late) if task is not None), return_exceptions=True)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_input_control_preserves_cursor_validation_and_action_output_error(manager: _TerminalSessionManager) -> None:
|
||
"""非法游标先于 stdin 动作,已发生 half-close 后的小页错误保留会话和输入事实。"""
|
||
payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nprint('DATA:'+sys.stdin.read(),flush=True)")
|
||
session = manager._sessions[payload["session_id"]]
|
||
with pytest.raises(TerminalOutputError):
|
||
await manager.write(session_id=session.session_id, input_text="wrong", close_stdin=True, since_seq=999)
|
||
assert session.stdin_closed is False
|
||
result = await manager.write(session_id=session.session_id, input_text="ok", close_stdin=True, since_seq=0, max_bytes=1)
|
||
assert result["stdin_closed"] is True and result["written_bytes"] == 2
|
||
assert result["output_error"]["code"] == "read_limit_too_small"
|
||
assert result["session_id"] == session.session_id
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_windows_interrupt_uses_actual_break_event_without_terminate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Windows 控制事件与终止 API 分开,仅报告实际调用的 CTRL_BREAK_EVENT。"""
|
||
current = _TerminalSessionManager()
|
||
process = SimpleNamespace(send_signal=Mock(), terminate=Mock(), kill=Mock())
|
||
session = _TerminalSession(owner=current_terminal_scope(), session_id="windows-interrupt", command="memory-only", cwd=".", pid=0, use_pty=False, process=process)
|
||
current._sessions[session.session_id] = session
|
||
with monkeypatch.context() as scoped:
|
||
scoped.setattr(terminal.os, "name", "nt")
|
||
scoped.setattr(terminal.signal, "CTRL_BREAK_EVENT", 1, raising=False)
|
||
result = await current.interrupt(session_id=session.session_id)
|
||
process.send_signal.assert_called_once_with(1)
|
||
process.terminate.assert_not_called()
|
||
process.kill.assert_not_called()
|
||
assert result["signal"] == "CTRL_BREAK_EVENT" and session.kill_requested is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_windows_missing_control_event_and_unmapped_kill_never_terminate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""缺少控制事件或请求未映射的信号时明确失败,不能静默执行 terminate。"""
|
||
current = _TerminalSessionManager()
|
||
process = SimpleNamespace(send_signal=Mock(), terminate=Mock(), kill=Mock())
|
||
session = _TerminalSession(owner=current_terminal_scope(), session_id="windows-unavailable", command="memory-only", cwd=".", pid=0, use_pty=False, process=process)
|
||
current._sessions[session.session_id] = session
|
||
with monkeypatch.context() as scoped:
|
||
scoped.setattr(terminal.os, "name", "nt")
|
||
scoped.delattr(terminal.signal, "CTRL_BREAK_EVENT", raising=False)
|
||
with pytest.raises(NotImplementedError):
|
||
await current.interrupt(session_id=session.session_id)
|
||
with pytest.raises(ValueError):
|
||
await current.kill(session_id=session.session_id, sig="INT")
|
||
current._send_signal(session, current._resolve_signal("TERM"))
|
||
current._send_signal(session, current._resolve_signal("KILL"))
|
||
process.send_signal.assert_not_called()
|
||
process.terminate.assert_called_once()
|
||
process.kill.assert_called_once()
|
||
assert session.kill_requested is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.skipif(os.name != "posix", reason="验证 POSIX fork 后 exec 失败分支")
|
||
async def test_pty_exec_failure_exits_child_without_parent_logic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""以模拟 fork 子分支验证失败必须 _exit,不能登记会话或回到父事件循环。"""
|
||
current = _TerminalSessionManager()
|
||
policy = AgentShell(kind="sh", executable="/bin/sh", arguments=("-c",), login=False)
|
||
|
||
class ChildExited(BaseException):
|
||
"""只用于替代不应真的退出 pytest 的 os._exit。"""
|
||
|
||
exit_child = Mock(side_effect=ChildExited())
|
||
monkeypatch.setattr(terminal._pty, "fork", lambda: (0, 12345))
|
||
monkeypatch.setattr(terminal.os, "chdir", Mock())
|
||
monkeypatch.setattr(terminal.os, "execvpe", Mock(side_effect=OSError("exec failed")))
|
||
monkeypatch.setattr(terminal.os, "_exit", exit_child)
|
||
nonblocking = Mock()
|
||
monkeypatch.setattr(current, "_set_nonblocking", nonblocking)
|
||
with pytest.raises(ChildExited):
|
||
await current._start_pty_session("echo test", str(tmp_path), {}, shell_policy=policy)
|
||
exit_child.assert_called_once_with(127)
|
||
nonblocking.assert_not_called()
|
||
assert current._sessions == {}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipe_shell_policy_metadata_matches_explicit_non_login_execution(manager: _TerminalSessionManager) -> None:
|
||
"""真实 pipe 命令回显由同一 AgentShell 合同选择的解释器和登录模式。"""
|
||
payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nsys.stdin.read()")
|
||
if os.name == "posix":
|
||
assert payload["shell"] == "/bin/sh" and payload["login"] is False
|
||
assert manager._sessions[payload["session_id"]].shell_policy is not None
|