Files
MoviePilot/app/agent/terminal/manager.py
Aqr-K 7584d4b9fc fix: isolate agent sessions and retain runtime resource ownership (#7149)
* fix: arbitrate subscription identity and restore workflow state

* fix: isolate agent sessions and retain runtime resource ownership

* test: preserve legacy empty-owner subscription transactions

* test: handle reaped terminal descendants during shutdown polling

* fix: align shared atomic configuration method with lifecycle prerequisite

* test: cover subagent cancellation and completion lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 08:20:59 +08:00

918 lines
37 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.
"""
Agent 终端会话管理器。
"""
from __future__ import annotations
import asyncio
import errno
import os
import signal
import subprocess
import time
import uuid
from types import ModuleType
from typing import Any, Optional
from app.agent.shell import (
AgentShell,
bind_agent_project_environment,
build_agent_subprocess_env,
resolve_agent_cwd,
resolve_agent_shell,
)
from app.agent.terminal.output import (
TERMINAL_DEFAULT_READ_BYTES,
TerminalOutputError,
read_payload,
resolve_cursor,
validate_output_budget,
)
from app.agent.terminal.ownership import TerminalAccessError, TerminalScope, require_terminal_scope
from app.agent.terminal.session import TERMINAL_RETENTION_SECONDS, _TerminalSession
from app.agent.tools.impl._command_safety import validate_command_safety
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
if os.name == "posix":
import fcntl as _posix_fcntl
import pty as _posix_pty
_fcntl: Optional[ModuleType] = _posix_fcntl
_pty: Optional[ModuleType] = _posix_pty
else:
_fcntl = None
_pty = None
TERMINAL_CONCURRENCY_LIMIT = 4
TERMINAL_READ_CHUNK_SIZE = 4096
TERMINAL_PTY_POLL_INTERVAL = 0.05
TERMINAL_WAIT_DEFAULT_MS = 1000
TERMINAL_WAIT_MAX_MS = 60 * 1000
TERMINAL_YIELD_DEFAULT_MS = 250
TERMINAL_YIELD_MAX_MS = 10 * 1000
TERMINAL_KILL_GRACE_SECONDS = 3
_KILL_SIGNAL = getattr(signal, "SIGKILL", 9)
_SHARE_ACTIONS = frozenset({"read", "wait", "write", "interrupt", "kill"})
class _TerminalSessionManager:
"""
管理 Agent 后台终端会话的生命周期。
"""
def __init__(self) -> None:
"""
初始化会话表和并发保护锁。
"""
self._sessions: dict[str, _TerminalSession] = {}
self._lock = asyncio.Lock()
self._close_lock = asyncio.Lock()
self._closed = False
self._starting = 0
self._starts_idle = asyncio.Event()
self._starts_idle.set()
self._owner_starts: dict[TerminalScope, int] = {}
self._owner_idle: dict[TerminalScope, asyncio.Event] = {}
self._grants: dict[TerminalScope, dict[str, frozenset[str]]] = {}
@staticmethod
def _normalize_bool(value: Any, default: bool = True) -> bool:
"""
兼容 LLM 或 HTTP 传入的 bool/string/int 布尔值。
"""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() not in {"false", "0", "no", "off"}
return bool(value)
@staticmethod
def _validate_command(command: str, *, confirmed: bool = False) -> None:
"""
拒绝明显危险或空白命令。
"""
validate_command_safety(command, confirmed=confirmed)
@staticmethod
def _set_nonblocking(fd: int) -> None:
"""
将 PTY master fd 设置为非阻塞,避免后台读取任务卡住事件循环。
"""
if _fcntl is None:
raise RuntimeError("当前平台不支持 PTY 非阻塞设置")
flags = _fcntl.fcntl(fd, _fcntl.F_GETFL)
_fcntl.fcntl(fd, _fcntl.F_SETFL, flags | os.O_NONBLOCK)
@staticmethod
def _pipe_subprocess_kwargs() -> dict[str, Any]:
"""
生成普通管道模式的子进程参数。
"""
kwargs: dict[str, Any] = {
"stdin": asyncio.subprocess.PIPE,
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE,
}
if os.name == "posix":
kwargs["start_new_session"] = True
elif os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP")
return kwargs
async def start(
self,
*,
command: str,
cwd: Optional[str] = None,
env: Optional[dict[str, Any]] = None,
use_pty: Any = True,
confirm_dangerous: bool = False,
yield_time_ms: Optional[int] = TERMINAL_YIELD_DEFAULT_MS,
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
since_offset: Optional[int] = None,
max_output_chars: Optional[int] = None,
shell: Optional[str] = None,
login: Optional[bool] = None,
) -> dict[str, Any]:
"""
启动后台命令,在首个输出、完成或首次等待预算到期时交付会话。
"""
owner = require_terminal_scope()
self._validate_command(command, confirmed=confirm_dangerous)
validate_output_budget(max_output_chars)
if since_offset is not None and (type(since_offset) is not int or since_offset != 0):
raise TerminalOutputError("新会话的 since_offset 只能为 0 或 null")
initial_wait = self._normalize_yield_timeout(yield_time_ms)
project_root = get_runtime_setting("ROOT_PATH")
normalized_cwd = resolve_agent_cwd(cwd, root_path=project_root)
normalized_env = bind_agent_project_environment(
build_agent_subprocess_env(env),
project_root=project_root,
)
shell_policy = resolve_agent_shell(executable=shell, login=login, environment=normalized_env, cwd=normalized_cwd)
should_use_pty = self._normalize_bool(use_pty, default=True) and os.name == "posix"
async with self._lock:
if owner.closed:
raise TerminalAccessError()
if self._closed:
raise RuntimeError("终端会话管理器已关闭")
self._cleanup_finished_sessions_locked()
if (
self._active_session_count_locked() + self._starting
>= TERMINAL_CONCURRENCY_LIMIT
):
raise RuntimeError(
f"后台终端会话数已达到上限 {TERMINAL_CONCURRENCY_LIMIT}"
)
self._starting += 1
self._starts_idle.clear()
self._owner_starts[owner] = self._owner_starts.get(owner, 0) + 1
self._owner_idle.setdefault(owner, asyncio.Event()).clear()
session: Optional[_TerminalSession] = None
slot_released = False
launch = asyncio.create_task(
self._start_pty_session(command, normalized_cwd, normalized_env, shell_policy=shell_policy)
if should_use_pty else self._start_pipe_session(
command, normalized_cwd, normalized_env, shell_policy=shell_policy,
)
)
try:
session = await asyncio.shield(launch)
session.owner = owner
async with self._lock:
if self._closed:
raise RuntimeError("终端会话管理器已关闭")
if owner.closed:
raise TerminalAccessError()
self._sessions[session.session_id] = session
self._release_start_locked(owner)
slot_released = True
self._check_access(session, owner, "start")
logger.info(
f"启动后台终端会话: session_id={session.session_id}, pid={session.pid}, "
f"use_pty={session.use_pty}, command={command}"
)
payload = await self._wait_for_output(
session, timeout_ms=initial_wait, since_seq=0, since_offset=since_offset,
max_bytes=max_bytes, preserve_output_error=True, max_output_chars=max_output_chars,
extra_fields={"yield_time_ms": initial_wait}, scope=owner, action="start",
)
self._check_access(session, owner, "start")
return payload
except BaseException:
cleanup_task = asyncio.create_task(self._recover_started_session(launch, session, owner))
while True:
try:
await asyncio.shield(cleanup_task)
break
except asyncio.CancelledError:
# 再次取消调用者也不能把启动预留与未交付的真实进程分离。
if cleanup_task.done():
break
continue
raise
finally:
if not slot_released:
async with self._lock:
self._release_start_locked(owner)
async def _recover_started_session(
self, launch: asyncio.Task[_TerminalSession], session: Optional[_TerminalSession], owner: TerminalScope,
) -> None:
"""
启动预留持有迟到进程;先回收再取得登记锁,避免取消被登记阻塞。
"""
if session is None:
try:
session = await launch
except Exception:
return
session.owner = owner
await self._discard_started_session(session)
def _release_start_locked(self, owner: TerminalScope) -> None:
"""
同一登记锁中释放全局与任务启动预留,关闭者才可确认快照完整。
"""
self._starting -= 1
if self._starting == 0:
self._starts_idle.set()
remaining = self._owner_starts[owner] - 1
if remaining:
self._owner_starts[owner] = remaining
else:
self._owner_starts.pop(owner)
self._owner_idle.pop(owner).set()
async def _discard_started_session(self, session: _TerminalSession) -> None:
"""
首次返回前取消时回收无从寻址的进程,并撤销其会话登记。
"""
converged = await self._terminate_session(session)
async with self._lock:
if converged and self._sessions.get(session.session_id) is session:
self._sessions.pop(session.session_id)
elif not converged:
self._sessions[session.session_id] = session
async def _start_pty_session(
self, command: str, cwd: str, env: dict[str, str], *, shell_policy: Optional[AgentShell] = None,
) -> _TerminalSession:
"""
通过 PTY fork 启动交互式命令会话。
"""
if _pty is None:
raise RuntimeError("当前平台不支持 PTY 会话")
shell_policy = shell_policy or resolve_agent_shell(environment=env, cwd=cwd)
argv = shell_policy.build_argv(command)
pid, master_fd = _pty.fork()
if pid == 0:
try:
os.chdir(cwd)
os.execvpe(argv[0], argv, env)
finally:
# exec 失败的子进程不能回到继承的事件循环或执行父进程清理逻辑。
os._exit(127)
self._set_nonblocking(master_fd)
session = _TerminalSession(
session_id=f"term_{uuid.uuid4().hex[:12]}",
command=command,
cwd=cwd,
pid=pid,
use_pty=True,
master_fd=master_fd,
shell_policy=shell_policy,
)
session.reader_tasks.append(asyncio.create_task(self._read_pty(session)))
session.wait_task = asyncio.create_task(self._wait_pty_process(session))
return session
async def _start_pipe_session(
self, command: str, cwd: str, env: dict[str, str], *, shell_policy: Optional[AgentShell] = None,
) -> _TerminalSession:
"""
通过普通 stdin/stdout/stderr 管道启动命令会话。
"""
shell_policy = shell_policy or resolve_agent_shell(environment=env, cwd=cwd)
process = await asyncio.create_subprocess_exec(
*shell_policy.build_argv(command), cwd=cwd, env=env, **self._pipe_subprocess_kwargs(),
)
session = _TerminalSession(
session_id=f"term_{uuid.uuid4().hex[:12]}",
command=command,
cwd=cwd,
pid=process.pid or 0,
use_pty=False,
process=process,
shell_policy=shell_policy,
)
if process.stdout:
session.reader_tasks.append(
asyncio.create_task(self._read_pipe(session, process.stdout, "stdout"))
)
if process.stderr:
session.reader_tasks.append(
asyncio.create_task(self._read_pipe(session, process.stderr, "stderr"))
)
session.wait_task = asyncio.create_task(self._wait_pipe_process(session))
return session
@staticmethod
async def _read_pty(session: _TerminalSession) -> None:
"""
持续从 PTY 读取增量输出。
"""
complete = False
try:
while session.master_fd is not None:
try:
data = os.read(session.master_fd, TERMINAL_READ_CHUNK_SIZE)
except BlockingIOError:
await asyncio.sleep(TERMINAL_PTY_POLL_INTERVAL)
continue
except OSError as err:
complete = err.errno == errno.EIO
if err.errno not in {errno.EIO, errno.EBADF}:
logger.debug(f"PTY 输出读取异常: session_id={session.session_id}, error={err}")
break
if not data:
complete = True
break
session.append_output("pty", data)
finally:
session.finish_stream("pty", complete=complete)
@staticmethod
async def _read_pipe(
session: _TerminalSession,
stream: asyncio.StreamReader,
stream_name: str,
) -> None:
"""
持续从普通管道读取增量输出。
"""
complete = False
try:
while True:
data = await stream.read(TERMINAL_READ_CHUNK_SIZE)
if not data:
complete = True
break
session.append_output(stream_name, data)
finally:
session.finish_stream(stream_name, complete=complete)
async def _wait_pty_process(self, session: _TerminalSession) -> None:
"""
等待 PTY 子进程结束并完成输出读取任务收尾。
"""
try:
_, status = await asyncio.to_thread(os.waitpid, session.pid, 0)
exit_code = os.waitstatus_to_exitcode(status)
session.mark_finished(exit_code)
except ChildProcessError:
session.mark_finished(session.exit_code)
except Exception as err:
session.mark_error(str(err))
logger.warning(
f"等待 PTY 进程失败: session_id={session.session_id}, error={err}"
)
finally:
await self._finish_reader_tasks(session)
session.close_pty()
async def _wait_pipe_process(self, session: _TerminalSession) -> None:
"""
等待普通管道子进程结束并完成输出读取任务收尾。
"""
try:
if not session.process:
session.mark_error("进程对象不存在")
return
exit_code = await session.process.wait()
session.mark_finished(exit_code)
except Exception as err:
session.mark_error(str(err))
logger.warning(
f"等待管道进程失败: session_id={session.session_id}, error={err}"
)
finally:
await self._finish_reader_tasks(session)
@staticmethod
async def _finish_reader_tasks(session: _TerminalSession) -> None:
"""
等待输出读取任务退出,超时后取消残留任务。
"""
if session.reader_tasks:
done, pending = await asyncio.wait(session.reader_tasks, timeout=1)
for task in pending:
task.cancel()
results = await asyncio.gather(*done, *pending, return_exceptions=True)
if pending or any(isinstance(result, BaseException) for result in results):
session.output_lost = True
session.finish_output()
async def read(
self,
*,
session_id: str,
since_seq: Optional[int] = None,
since_offset: Optional[int] = None,
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
max_output_chars: Optional[int] = None,
) -> dict[str, Any]:
"""
读取会话当前保留的增量输出。
"""
session, _ = self._get_accessible_session(session_id, "read")
return read_payload(
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, max_output_chars=max_output_chars,
)
async def wait(
self,
*,
session_id: str,
timeout_ms: Optional[int] = TERMINAL_WAIT_DEFAULT_MS,
since_seq: Optional[int] = None,
since_offset: Optional[int] = None,
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
max_output_chars: Optional[int] = None,
) -> dict[str, Any]:
"""
等待未读输出或输出最终收尾;零预算只取快照,不终止后台命令。
"""
session, scope = self._get_accessible_session(session_id, "wait")
normalized_timeout = self._normalize_wait_timeout(timeout_ms)
payload = await self._wait_for_output(
session, timeout_ms=normalized_timeout, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes,
max_output_chars=max_output_chars, extra_fields={"wait_timeout_ms": normalized_timeout},
scope=scope, action="wait",
)
return payload
async def _wait_for_output(
self, session: _TerminalSession, *, timeout_ms: int, since_seq: Optional[int] = None,
since_offset: Optional[int] = None, max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
preserve_output_error: bool = False, max_output_chars: Optional[int] = None,
extra_fields: Optional[dict[str, Any]] = None,
scope: TerminalScope, action: str,
) -> dict[str, Any]:
"""
先捕获通知再查输出,无数据到 await 之间的变化也能唤醒所有等待者。
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_ms / 1000
while True:
self._check_access(session, scope, action)
changed = session.changed_event
payload = read_payload(
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes,
preserve_output_error=preserve_output_error, max_output_chars=max_output_chars,
extra_fields={**(extra_fields or {}), "wait_reason": "completed"},
)
# 捕获丢失是持久事实;只有本次请求落在已淘汰位置才是新的可读缺口。
new_gap = since_seq is not None and since_seq < session.retained_from_seq - 1
if payload["output"] or new_gap or payload.get("output_error"):
payload["wait_reason"] = "output"
return payload
if session.output_complete:
payload["wait_reason"] = "completed"
return payload
remaining = deadline - loop.time()
if remaining <= 0:
payload["wait_reason"] = "timeout"
return payload
await self._wait_for_change(session, scope, changed, remaining)
@staticmethod
async def _wait_for_change(
session: _TerminalSession, scope: TerminalScope, changed: asyncio.Event, timeout: float,
) -> None:
"""
输出和双方作用域封口任一变化都结束等待,取消时收齐短命通知任务。
"""
events = {changed, scope.changed}
if session.owner is not None:
events.add(session.owner.changed)
waiters = [asyncio.create_task(event.wait()) for event in events]
try:
await asyncio.wait(waiters, timeout=timeout, return_when=asyncio.FIRST_COMPLETED)
finally:
for task in waiters:
task.cancel()
await asyncio.gather(*waiters, return_exceptions=True)
async def write(
self, *, session_id: str, input_text: str, since_seq: Optional[int] = None,
since_offset: Optional[int] = None, max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
max_output_chars: Optional[int] = None, close_stdin: bool = False,
) -> dict[str, Any]:
"""
串行写入输入和可选管道 EOF;关闭 stdin 不影响输出读取器。
"""
session, scope = self._get_accessible_session(session_id, "write")
validate_output_budget(max_output_chars)
resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
if type(close_stdin) is not bool:
raise ValueError("close_stdin 必须为布尔值")
if close_stdin and session.use_pty:
raise ValueError("PTY 不支持独立关闭 stdin;输出端保持打开,请使用程序自己的结束命令")
data = (input_text or "").encode("utf-8")
written = 0
async with session.input_lock:
self._check_access(session, scope, "write")
if session.stdin_closed:
if data or not close_stdin:
raise RuntimeError("会话 stdin 已关闭,不能再写入输入")
elif session.status != "running":
raise RuntimeError(f"会话已结束,当前状态: {session.status}")
elif session.use_pty:
while written < len(data):
self._check_access(session, scope, "write")
if session.master_fd is None:
raise RuntimeError("PTY 已关闭")
try:
# master 已设为非阻塞;同一事件循环完成写入,避免线程持有关闭前的 fd。
count = os.write(session.master_fd, memoryview(data)[written:])
except BlockingIOError:
await asyncio.sleep(TERMINAL_PTY_POLL_INTERVAL)
continue
if count <= 0:
raise BrokenPipeError("PTY 未接受后续输入")
written += count
else:
await self._write_pipe_input(session, scope, data, close_stdin=close_stdin)
written = len(data)
self._check_access(session, scope, "write")
session.updated_at = time.time()
payload = read_payload(
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True,
max_output_chars=max_output_chars, extra_fields={"written_bytes": written},
)
return payload
async def _write_pipe_input(
self, session: _TerminalSession, scope: TerminalScope, data: bytes, *, close_stdin: bool,
) -> None:
"""
在会话输入锁内先排空末段,再半关闭并等待写端收尾。
"""
writer = session.process.stdin if session.process else None
if writer is None:
raise RuntimeError("进程 stdin 不可写")
if data:
writer.write(data)
await writer.drain()
self._check_access(session, scope, "write")
if close_stdin:
self._check_access(session, scope, "write")
writer.close()
session.stdin_closed = True
await writer.wait_closed()
async def interrupt(
self, *, session_id: str, since_seq: Optional[int] = None, since_offset: Optional[int] = None,
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES, max_output_chars: Optional[int] = None,
) -> dict[str, Any]:
"""
只发送一次真实中断信号,不改变终止意图,也不等待或升级成强杀。
"""
session, _ = self._get_accessible_session(session_id, "interrupt")
validate_output_budget(max_output_chars)
resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
event = getattr(signal, "CTRL_BREAK_EVENT", None)
sender = getattr(session.process, "send_signal", None)
if os.name != "posix" and (event is None or not callable(sender)):
raise NotImplementedError("当前平台不支持可独立发送的控制台中断,未终止进程")
sent = False
if session.status == "running":
try:
if os.name == "posix":
os.killpg(session.pid, signal.SIGINT)
elif callable(sender):
sender(event)
sent = True
except ProcessLookupError:
pass
return read_payload(
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True,
max_output_chars=max_output_chars,
extra_fields={"signal": "SIGINT" if os.name == "posix" else "CTRL_BREAK_EVENT", "signal_sent": sent},
)
async def kill(
self,
*,
session_id: str,
sig: Optional[str | int] = "TERM",
since_seq: Optional[int] = None,
since_offset: Optional[int] = None,
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
max_output_chars: Optional[int] = None,
) -> dict[str, Any]:
"""
向会话进程组发送信号并等待短暂清理。
"""
session, scope = self._get_accessible_session(session_id, "kill")
validate_output_budget(max_output_chars)
resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
signal_number = self._resolve_signal(sig)
if session.status == "running":
session.kill_requested = True
self._send_signal(session, signal_number)
if not await self._wait_for_exit(session):
self._check_access(session, scope, "kill")
self._send_signal(session, _KILL_SIGNAL)
self._check_access(session, scope, "kill")
return read_payload(
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True,
max_output_chars=max_output_chars,
)
async def close(self) -> None:
"""
停止全部会话,未确认进程和读取器收尾的记录保留给下次关闭。
"""
async with self._close_lock:
async with self._lock:
self._closed = True
for owner in {*self._owner_starts, *(session.owner for session in self._sessions.values())}:
if owner is not None:
owner.seal()
for child in self._grants:
child.seal()
self._grants.clear()
await self._starts_idle.wait()
async with self._lock:
sessions = list(self._sessions.values())
results = await asyncio.gather(
*(self._terminate_session(session) for session in sessions),
return_exceptions=True,
)
async with self._lock:
for session, converged in zip(sessions, results):
if converged is True:
self._sessions.pop(session.session_id, None)
async def close_owner(self, owner: TerminalScope) -> bool:
"""
先封口并撤销能力,再只收敛本任务的启动和进程;未收敛时保留事实供重试。
"""
owner.seal()
async with self._lock:
revoked = set(self._grants.pop(owner, {}))
owned = {key for key, session in self._sessions.items() if session.owner is owner}
for grants in self._grants.values():
for key in owned:
grants.pop(key, None)
for key in revoked | owned:
if key in self._sessions:
self._sessions[key].notify_changed()
starting = self._owner_idle.get(owner)
if starting is not None:
try:
await asyncio.wait_for(starting.wait(), timeout=TERMINAL_KILL_GRACE_SECONDS)
except asyncio.TimeoutError:
return False
async with self._lock:
sessions = [session for session in self._sessions.values() if session.owner is owner]
results = await asyncio.gather(
*(self._terminate_session(session) for session in sessions), return_exceptions=True,
)
async with self._lock:
for session, converged in zip(sessions, results):
if converged is True:
self._sessions.pop(session.session_id, None)
return not self._owner_starts.get(owner) and not any(
session.owner is owner for session in self._sessions.values()
)
async def _terminate_session(self, session: _TerminalSession) -> bool:
"""
同一终端的关闭串行执行,真实收尾前不丢弃进程或输出读取器。
"""
async with session.termination_lock:
if session.status == "running" or os.name == "posix":
session.kill_requested = True
self._send_signal(session, signal.SIGTERM)
if not await self._wait_for_exit(session):
if session.status == "running" or os.name == "posix":
self._send_signal(session, _KILL_SIGNAL)
if not await self._wait_for_exit(session):
logger.error(f"终端会话关闭超时: session_id={session.session_id}, pid={session.pid}")
return False
# 组长先结束也不代表组内后代已退出;关闭该owner时完成整组最终收口。
if os.name == "posix" and not await self._kill_remaining_process_group(session):
return False
if any(not task.done() for task in session.reader_tasks):
return False
session.finish_output()
session.close_pty()
return True
async def _kill_remaining_process_group(self, session: _TerminalSession) -> bool:
"""
有界重试组长退出后的进程组收口,兼容 macOS 后代退出期间的临时权限错误。
"""
deadline = asyncio.get_running_loop().time() + TERMINAL_KILL_GRACE_SECONDS
while True:
try:
self._send_signal(session, _KILL_SIGNAL)
return True
except PermissionError:
if asyncio.get_running_loop().time() >= deadline:
logger.error(f"终端进程组关闭被拒绝: session_id={session.session_id}, pid={session.pid}")
return False
await asyncio.sleep(TERMINAL_PTY_POLL_INTERVAL)
@staticmethod
async def _wait_for_exit(session: _TerminalSession) -> bool:
"""
复用终止与关闭的有界等待,超时不取消会话原本的进程收尾任务。
"""
if session.wait_task is not None:
try:
await asyncio.wait_for(asyncio.shield(session.wait_task), timeout=TERMINAL_KILL_GRACE_SECONDS)
except asyncio.TimeoutError:
return False
except asyncio.CancelledError:
if not session.wait_task.cancelled():
raise
return False
except Exception:
return False
return session.status in {"exited", "killed"} and (
session.process is None or session.process.returncode is not None
)
def _get_accessible_session(self, session_id: str, action: str) -> tuple[_TerminalSession, TerminalScope]:
"""
所有用户动作共用归属查表,未知句柄与无权访问返回同一最小错误。
"""
scope = require_terminal_scope()
session = self._sessions.get(session_id)
if session is None:
raise TerminalAccessError()
self._check_access(session, scope, action)
return session, scope
def _check_access(self, session: _TerminalSession, scope: TerminalScope, action: str) -> None:
"""
每个可暂停边界重新核对原调用者,封口或撤销后不再泄露输出或继续写入。
"""
if (
self._closed or scope.closed or not scope.user_id or not scope.task_id
or session.owner is None or session.owner.closed
or self._sessions.get(session.session_id) is not session
or (session.owner is not scope and action not in self._grants.get(scope, {}).get(
session.session_id, frozenset(),
))
):
raise TerminalAccessError()
def share(self, parent: TerminalScope, child: TerminalScope, grants: dict[str, frozenset[str]]) -> None:
"""
宿主原子授予同用户指定任务的有限动作;受授者不能继续转授。
"""
if (
require_terminal_scope() is not parent or parent is child or child.closed
or not child.task_id or not child.user_id or parent.user_id != child.user_id
):
raise TerminalAccessError()
for key, actions in grants.items():
session, _ = self._get_accessible_session(key, "read")
if session.owner is not parent or not isinstance(actions, frozenset) or not actions <= _SHARE_ACTIONS:
raise TerminalAccessError()
self._grants.setdefault(child, {}).update(grants)
@staticmethod
def _normalize_wait_timeout(timeout_ms: Optional[int]) -> int:
"""
限制 wait 单次等待时间,避免工具调用长时间占用模型回合。
"""
try:
normalized = int(TERMINAL_WAIT_DEFAULT_MS if timeout_ms is None else timeout_ms)
except (TypeError, ValueError):
normalized = TERMINAL_WAIT_DEFAULT_MS
if normalized < 0:
return 0
return min(normalized, TERMINAL_WAIT_MAX_MS)
@staticmethod
def _normalize_yield_timeout(yield_time_ms: Optional[int]) -> int:
"""
首次等待接受显式零值,直调入口也拒绝布尔和非法负数。
"""
if yield_time_ms is None:
return TERMINAL_YIELD_DEFAULT_MS
if type(yield_time_ms) is not int or yield_time_ms < 0:
raise ValueError("yield_time_ms 必须为非负整数")
return min(yield_time_ms, TERMINAL_YIELD_MAX_MS)
@staticmethod
def _resolve_signal(sig: Optional[str | int]) -> int:
"""
只接受已知有效且当前终止路径可真实发送的信号,绝不把拼写错误当 TERM。
"""
if isinstance(sig, bool) or (sig is not None and not isinstance(sig, (str, int))):
raise ValueError("signal 必须为已知名称或有效正整数")
number: Optional[int] = int(sig) if isinstance(sig, int) else None
if not isinstance(sig, int):
name = "TERM" if sig is None else sig.strip().upper()
label = name if name.startswith("SIG") else f"SIG{name}"
number = int(name) if name.isdigit() else signal.Signals.__members__.get(label)
if label == "SIGKILL" and os.name == "nt":
number = _KILL_SIGNAL
valid = {int(value) for value in signal.valid_signals()}
if os.name == "nt":
valid = {int(signal.SIGTERM), int(_KILL_SIGNAL)}
if not isinstance(number, int) or number <= 0 or number not in valid:
raise ValueError("未知、无效或当前平台不支持的终止信号;非终止中断请使用 interrupt")
return int(number)
@staticmethod
def _send_signal(session: _TerminalSession, sig: int) -> None:
"""
按平台向所属进程组或进程发信号,已消失的目标视为收尾完成。
"""
try:
if os.name == "posix":
os.killpg(session.pid, sig)
elif session.process:
if sig == _KILL_SIGNAL:
session.process.kill()
elif sig == signal.SIGTERM:
session.process.terminate()
else:
raise ValueError("当前平台没有该信号的真实发送映射")
except ProcessLookupError:
pass
def _active_session_count_locked(self) -> int:
"""
统计仍在运行的会话数量。
"""
return sum(1 for session in self._sessions.values() if session.status == "running")
@staticmethod
def _process_group_exists(session: _TerminalSession) -> bool:
"""
保留组长退出但仍有后代的终端归属,避免过期清理先丢弃唯一收口句柄。
"""
if os.name != "posix":
return session.status == "running"
try:
os.killpg(session.pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _cleanup_finished_sessions_locked(self) -> None:
"""
清理已经结束且超过保留时间的会话。
"""
now = time.time()
expired_ids = [
session_id
for session_id, session in self._sessions.items()
if session.status != "running"
and now - session.updated_at > TERMINAL_RETENTION_SECONDS
and not self._process_group_exists(session)
]
for session_id in expired_ids:
session = self._sessions.pop(session_id)
session.close_pty()
terminal_session_manager = _TerminalSessionManager()
def get_terminal_session_manager() -> _TerminalSessionManager:
"""
返回当前进程的终端会话管理器,避免复用已完成关停的实例。
"""
global terminal_session_manager
if (
terminal_session_manager._closed and not terminal_session_manager._sessions
and terminal_session_manager._starting == 0
):
terminal_session_manager = _TerminalSessionManager()
return terminal_session_manager