Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

204 lines
6.9 KiB
Python

"""Async PTY session manager for computer-server.
Wraps :class:`cua_auto.terminal.Terminal` and adds asyncio-compatible
output broadcasting via per-consumer :class:`asyncio.Queue` objects.
Queue items are dicts:
- ``{"type": "output", "data": <bytes>}`` — terminal output chunk
- ``{"type": "exit", "code": <int>}`` — session terminated (sentinel)
"""
from __future__ import annotations
import asyncio
import logging
import threading
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
class PtyManager:
"""Manage PTY sessions with async broadcast queues.
Usage::
mgr = PtyManager()
# Create a session
info = await mgr.create(command="bash", cols=80, rows=24)
pid = info["pid"]
# Subscribe to output
q = mgr.subscribe(pid)
async for msg in _drain(q):
... # msg is {"type": "output", "data": b"..."} or {"type": "exit", "code": 0}
# Write to stdin
await mgr.send_stdin(pid, b"echo hello\\n")
# Resize
await mgr.resize(pid, 120, 40)
# Kill
await mgr.kill(pid)
"""
def __init__(self) -> None:
# pid → list of subscriber queues
self._queues: Dict[int, List[asyncio.Queue]] = {}
# pid → {"cols": int, "rows": int}
self._sessions: Dict[int, dict] = {}
self._terminal = None
# ------------------------------------------------------------------
# Lazy terminal access
# ------------------------------------------------------------------
def _get_terminal(self):
if self._terminal is None:
from cua_auto.terminal import Terminal
self._terminal = Terminal()
return self._terminal
# ------------------------------------------------------------------
# Public async API
# ------------------------------------------------------------------
async def create(
self,
command: Optional[str] = None,
cols: int = 80,
rows: int = 24,
cwd: Optional[str] = None,
envs: Optional[dict] = None,
) -> dict:
"""Spawn a new PTY session.
Returns:
``{"pid": int, "cols": int, "rows": int}``
"""
loop = asyncio.get_running_loop()
terminal = self._get_terminal()
# We need pid to route output, but we only know it after create().
# Use a mutable cell so the callback can look up the pid after creation.
# early_buffer holds chunks that arrive before pid_cell[0] is set (the
# reader thread can fire before asyncio.to_thread returns the session).
pid_cell: List[Optional[int]] = [None]
early_buffer: List[bytes] = []
def _on_data(data: bytes) -> None:
pid = pid_cell[0]
if pid is None:
early_buffer.append(data)
return
msg = {"type": "output", "data": data}
for q in list(self._queues.get(pid, [])):
try:
loop.call_soon_threadsafe(q.put_nowait, msg)
except Exception:
pass
session = await asyncio.to_thread(
terminal.create,
command=command,
cols=cols,
rows=rows,
on_data=_on_data,
cwd=cwd,
envs=envs,
)
pid_cell[0] = session.pid
self._queues[session.pid] = []
self._sessions[session.pid] = {"cols": session.cols, "rows": session.rows}
# Flush any output that arrived before pid_cell[0] was set.
for chunk in early_buffer:
msg = {"type": "output", "data": chunk}
for q in list(self._queues[session.pid]):
try:
loop.call_soon_threadsafe(q.put_nowait, msg)
except Exception:
pass
# Watch for process exit in a daemon thread, then broadcast sentinel.
def _watch_exit() -> None:
exit_code = terminal.wait(session.pid) or 0
sentinel = {"type": "exit", "code": exit_code}
for q in list(self._queues.get(session.pid, [])):
try:
loop.call_soon_threadsafe(q.put_nowait, sentinel)
except Exception:
pass
threading.Thread(target=_watch_exit, daemon=True, name=f"pty-exit-{session.pid}").start()
logger.info(
"PTY session created: pid=%d cmd=%r cols=%d rows=%d", session.pid, command, cols, rows
)
return {"pid": session.pid, "cols": session.cols, "rows": session.rows}
async def send_stdin(self, pid: int, data: bytes) -> None:
"""Write *data* to the stdin of session *pid*."""
terminal = self._get_terminal()
await asyncio.to_thread(terminal.send_stdin, pid, data)
async def resize(self, pid: int, cols: int, rows: int) -> None:
"""Resize the terminal for session *pid*."""
terminal = self._get_terminal()
await asyncio.to_thread(terminal.resize, pid, cols, rows)
if pid in self._sessions:
self._sessions[pid]["cols"] = cols
self._sessions[pid]["rows"] = rows
def get_info(self, pid: int) -> Optional[dict]:
"""Return ``{"pid": int, "cols": int, "rows": int}`` for *pid*, or ``None`` if unknown."""
info = self._sessions.get(pid)
if info is None:
return None
return {"pid": pid, "cols": info["cols"], "rows": info["rows"]}
async def kill(self, pid: int) -> bool:
"""Kill session *pid*.
Also broadcasts the exit sentinel to all subscribers.
Returns:
``True`` if the signal was delivered.
"""
terminal = self._get_terminal()
result = await asyncio.to_thread(terminal.kill, pid)
# Broadcast sentinel immediately (the exit watcher will also fire,
# but duplicate sentinels are harmless — consumers stop after the first).
sentinel = {"type": "exit", "code": -1}
for q in list(self._queues.get(pid, [])):
try:
q.put_nowait(sentinel)
except Exception:
pass
return result
# ------------------------------------------------------------------
# Queue-based pub/sub
# ------------------------------------------------------------------
def subscribe(self, pid: int) -> asyncio.Queue:
"""Return a new :class:`asyncio.Queue` that will receive output for *pid*.
The queue receives dicts with keys ``type`` (``"output"`` or ``"exit"``)
and either ``data`` (bytes) or ``code`` (int).
"""
q: asyncio.Queue = asyncio.Queue()
self._queues.setdefault(pid, []).append(q)
return q
def unsubscribe(self, pid: int, queue: asyncio.Queue) -> None:
"""Remove *queue* from the subscriber list for *pid*."""
qs = self._queues.get(pid, [])
try:
qs.remove(queue)
except ValueError:
pass