mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/process: cross-platform PTY via backend and pywinpty
POSIX openpty/fork and Windows ConPTY; win32 marker dep plus typings stub.
This commit is contained in:
@@ -10,10 +10,11 @@ from .pty_session import PtyManager
|
||||
|
||||
@tool
|
||||
def open_pty(command: list[str], *, cwd: str = ".") -> str:
|
||||
"""Open a Unix PTY session running ``command`` (argv) under the workspace.
|
||||
"""Open a PTY session running ``command`` (argv) under the workspace.
|
||||
|
||||
Returns structured status including session_id. Not supported on Windows.
|
||||
Failed exec surfaces via later read_pty data (marker) and exit_code=127.
|
||||
POSIX uses openpty/fork; Windows uses ConPTY (pywinpty). Returns structured
|
||||
status including session_id. Failed exec may surface via later read_pty
|
||||
data (marker) and a non-zero exit_code.
|
||||
"""
|
||||
try:
|
||||
session = PtyManager.open(command, cwd=cwd)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Platform PTY backends (POSIX openpty/fork; Windows ConPTY via pywinpty)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PtyHandle(Protocol):
|
||||
"""One live PTY-backed child process."""
|
||||
|
||||
@property
|
||||
def master_fd(self) -> int | None:
|
||||
"""POSIX master FD when applicable; ``None`` on Windows ConPTY."""
|
||||
...
|
||||
|
||||
@property
|
||||
def pid(self) -> int | None:
|
||||
"""POSIX child pid when applicable; ``None`` on Windows ConPTY."""
|
||||
...
|
||||
|
||||
def poll_exit(self) -> tuple[bool, int | None]:
|
||||
"""Return ``(alive, exit_code)`` without blocking."""
|
||||
...
|
||||
|
||||
def read_bytes(self, max_bytes: int, timeout: float) -> bytes:
|
||||
"""Read up to *max_bytes* waiting up to *timeout* seconds."""
|
||||
...
|
||||
|
||||
def write_bytes(self, data: bytes) -> None:
|
||||
"""Write raw bytes to the PTY input."""
|
||||
...
|
||||
|
||||
def terminate(self) -> None:
|
||||
"""Request graceful shutdown (SIGTERM / TerminateProcess soft)."""
|
||||
...
|
||||
|
||||
def kill(self) -> None:
|
||||
"""Force-kill the child."""
|
||||
...
|
||||
|
||||
def wait_reap(self, *, timeout: float | None = None) -> None:
|
||||
"""Wait for the child to exit and reap it."""
|
||||
...
|
||||
|
||||
def close_resources(self) -> None:
|
||||
"""Release OS handles/FDs (after terminate/kill)."""
|
||||
...
|
||||
|
||||
|
||||
_EXEC_FAIL_MARKER = b"plyngent-pty-exec-failed: "
|
||||
_STDERR_FD = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class PosixPtyHandle:
|
||||
master_fd: int
|
||||
pid: int
|
||||
_alive: bool = True
|
||||
_exit_code: int | None = None
|
||||
_closed: bool = False
|
||||
|
||||
def poll_exit(self) -> tuple[bool, int | None]:
|
||||
if not self._alive or self._closed:
|
||||
return self._alive, self._exit_code
|
||||
try:
|
||||
waited_pid, status = os.waitpid(self.pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
self._alive = False
|
||||
return False, self._exit_code
|
||||
if waited_pid == 0:
|
||||
return True, None
|
||||
self._alive = False
|
||||
if os.WIFEXITED(status):
|
||||
self._exit_code = os.WEXITSTATUS(status)
|
||||
elif os.WIFSIGNALED(status):
|
||||
self._exit_code = -os.WTERMSIG(status)
|
||||
else:
|
||||
self._exit_code = status
|
||||
return False, self._exit_code
|
||||
|
||||
def read_bytes(self, max_bytes: int, timeout: float) -> bytes:
|
||||
if self._closed or max_bytes < 1:
|
||||
return b""
|
||||
data = b""
|
||||
try:
|
||||
ready, _, _ = select.select([self.master_fd], [], [], timeout)
|
||||
if ready:
|
||||
data = os.read(self.master_fd, max_bytes)
|
||||
except (OSError, ValueError):
|
||||
data = b""
|
||||
_ = self.poll_exit()
|
||||
return data
|
||||
|
||||
def write_bytes(self, data: bytes) -> None:
|
||||
_ = os.write(self.master_fd, data)
|
||||
|
||||
def terminate(self) -> None:
|
||||
if not self._alive:
|
||||
return
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(self.pid, signal.SIGTERM)
|
||||
|
||||
def kill(self) -> None:
|
||||
if not self._alive:
|
||||
return
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(self.pid, signal.SIGKILL)
|
||||
with contextlib.suppress(ChildProcessError):
|
||||
_ = os.waitpid(self.pid, 0)
|
||||
self._alive = False
|
||||
if self._exit_code is None:
|
||||
self._exit_code = -signal.SIGKILL
|
||||
|
||||
def wait_reap(self, *, timeout: float | None = None) -> None:
|
||||
deadline = None if timeout is None else time.monotonic() + max(0.0, timeout)
|
||||
while self._alive:
|
||||
_ = self.poll_exit()
|
||||
if not self._alive:
|
||||
with contextlib.suppress(ChildProcessError):
|
||||
_ = os.waitpid(self.pid, os.WNOHANG)
|
||||
return
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
|
||||
def close_resources(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(self.master_fd)
|
||||
self._closed = True
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
from winpty import PtyProcess
|
||||
|
||||
@dataclass
|
||||
class WinPtyHandle:
|
||||
"""ConPTY session via pywinpty ``PtyProcess``."""
|
||||
|
||||
process: PtyProcess
|
||||
_alive: bool = True
|
||||
_exit_code: int | None = None
|
||||
_closed: bool = False
|
||||
|
||||
@property
|
||||
def master_fd(self) -> int | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def pid(self) -> int | None:
|
||||
return None
|
||||
|
||||
def poll_exit(self) -> tuple[bool, int | None]:
|
||||
if not self._alive or self._closed:
|
||||
return self._alive, self._exit_code
|
||||
try:
|
||||
alive = bool(self.process.isalive())
|
||||
except Exception: # noqa: BLE001 — treat as dead on probe failure
|
||||
alive = False
|
||||
if alive:
|
||||
return True, None
|
||||
self._alive = False
|
||||
status = self.process.exitstatus
|
||||
if isinstance(status, int):
|
||||
self._exit_code = status
|
||||
return False, self._exit_code
|
||||
|
||||
def read_bytes(self, max_bytes: int, timeout: float) -> bytes:
|
||||
"""Read up to *max_bytes*.
|
||||
|
||||
pywinpty ``PtyProcess.read(size)`` is blocking and has no timeout kwarg;
|
||||
*timeout* is accepted for API parity with the POSIX backend.
|
||||
"""
|
||||
del timeout # ConPTY read has no timed wait in pywinpty 3.x
|
||||
if self._closed or max_bytes < 1:
|
||||
return b""
|
||||
try:
|
||||
raw = self.process.read(max_bytes)
|
||||
except Exception: # noqa: BLE001 — EOF / closed PTY
|
||||
_ = self.poll_exit()
|
||||
return b""
|
||||
data = raw.encode("utf-8", errors="replace")
|
||||
if len(data) > max_bytes:
|
||||
data = data[:max_bytes]
|
||||
_ = self.poll_exit()
|
||||
return data
|
||||
|
||||
def write_bytes(self, data: bytes) -> None:
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
_ = self.process.write(text)
|
||||
|
||||
def terminate(self) -> None:
|
||||
if not self._alive:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
_ = self.process.terminate(force=False)
|
||||
|
||||
def kill(self) -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
_ = self.process.terminate(force=True)
|
||||
with contextlib.suppress(Exception):
|
||||
self.process.kill()
|
||||
self._alive = False
|
||||
if self._exit_code is None:
|
||||
self._exit_code = 1
|
||||
|
||||
def wait_reap(self, *, timeout: float | None = None) -> None:
|
||||
deadline = None if timeout is None else time.monotonic() + max(0.0, timeout)
|
||||
while self._alive:
|
||||
_ = self.poll_exit()
|
||||
if not self._alive:
|
||||
return
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
|
||||
def close_resources(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
self.process.close(force=True)
|
||||
self._closed = True
|
||||
|
||||
def pty_available() -> bool:
|
||||
return True
|
||||
|
||||
def spawn_pty(command: list[str], *, cwd: Path) -> PtyHandle:
|
||||
if not command:
|
||||
msg = "command argv must not be empty"
|
||||
raise OSError(msg)
|
||||
try:
|
||||
process = PtyProcess.spawn(command, cwd=str(cwd))
|
||||
except Exception as exc:
|
||||
msg = f"failed to open ConPTY: {exc}"
|
||||
raise OSError(msg) from exc
|
||||
return WinPtyHandle(process=process)
|
||||
|
||||
else:
|
||||
|
||||
def pty_available() -> bool:
|
||||
return hasattr(os, "fork")
|
||||
|
||||
def spawn_pty(command: list[str], *, cwd: Path) -> PtyHandle:
|
||||
if not command:
|
||||
msg = "command argv must not be empty"
|
||||
raise OSError(msg)
|
||||
return _spawn_posix(command, cwd=cwd)
|
||||
|
||||
def _spawn_posix(command: list[str], *, cwd: Path) -> PosixPtyHandle:
|
||||
import pty
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
with contextlib.suppress(OSError, AttributeError):
|
||||
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
|
||||
pid = os.fork()
|
||||
if pid == 0: # child — fork-then-exec only
|
||||
try:
|
||||
os.close(master_fd)
|
||||
_ = os.setsid()
|
||||
_ = os.dup2(slave_fd, 0)
|
||||
_ = os.dup2(slave_fd, 1)
|
||||
_ = os.dup2(slave_fd, _STDERR_FD)
|
||||
if slave_fd > _STDERR_FD:
|
||||
os.close(slave_fd)
|
||||
_ = os.chdir(cwd)
|
||||
os.execvp(command[0], command)
|
||||
except OSError as exc:
|
||||
with contextlib.suppress(OSError):
|
||||
_ = os.write(1, _EXEC_FAIL_MARKER + str(exc).encode(errors="replace") + b"\n")
|
||||
os._exit(127)
|
||||
|
||||
os.close(slave_fd)
|
||||
with contextlib.suppress(OSError, AttributeError):
|
||||
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
|
||||
return PosixPtyHandle(master_fd=master_fd, pid=pid)
|
||||
@@ -1,17 +1,13 @@
|
||||
"""Unix PTY session registry (requires ``pty`` + ``os.fork``; not supported on Windows)."""
|
||||
"""Cross-platform PTY session registry (POSIX openpty/fork; Windows ConPTY)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
|
||||
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -27,15 +23,12 @@ DEFAULT_SESSION_OUTPUT_BUDGET = 256_000
|
||||
DEFAULT_CLOSE_GRACE_SECONDS = 0.5
|
||||
_SESSION_LIMIT_STEP = 4
|
||||
_BUDGET_STEP = 256_000
|
||||
_STDERR_FD = 2
|
||||
_EXEC_FAIL_MARKER = b"plyngent-pty-exec-failed: "
|
||||
|
||||
|
||||
@dataclass
|
||||
class PtySession:
|
||||
session_id: int
|
||||
master_fd: int
|
||||
pid: int
|
||||
handle: PtyHandle
|
||||
closed: bool = False
|
||||
alive: bool = True
|
||||
exit_code: int | None = None
|
||||
@@ -45,6 +38,16 @@ class PtySession:
|
||||
output_budget: int = DEFAULT_SESSION_OUTPUT_BUDGET
|
||||
command: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def master_fd(self) -> int | None:
|
||||
"""POSIX master FD when available (``None`` on Windows ConPTY)."""
|
||||
return self.handle.master_fd
|
||||
|
||||
@property
|
||||
def pid(self) -> int | None:
|
||||
"""POSIX child pid when available (``None`` on Windows ConPTY)."""
|
||||
return self.handle.pid
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PtyReadResult:
|
||||
@@ -114,6 +117,9 @@ class PtyManager:
|
||||
*,
|
||||
cwd: str = ".",
|
||||
) -> PtySession:
|
||||
if not pty_available():
|
||||
msg = "PTY is not available on this platform (Windows needs pywinpty)"
|
||||
raise WorkspaceError(msg)
|
||||
check_command_allowed(command)
|
||||
workdir = resolve_path(cwd)
|
||||
if not workdir.is_dir():
|
||||
@@ -131,37 +137,18 @@ class PtyManager:
|
||||
msg = f"{reason}; close idle sessions or allow a higher limit"
|
||||
raise WorkspaceError(msg)
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
# Parent must not pass master into the child (or future children).
|
||||
with contextlib.suppress(OSError, AttributeError):
|
||||
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
|
||||
pid = os.fork()
|
||||
if pid == 0: # child — keep path fork-then-exec only (no Python after exec fail except _exit)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
_ = os.setsid()
|
||||
_ = os.dup2(slave_fd, 0)
|
||||
_ = os.dup2(slave_fd, 1)
|
||||
_ = os.dup2(slave_fd, _STDERR_FD)
|
||||
if slave_fd > _STDERR_FD:
|
||||
os.close(slave_fd)
|
||||
_ = os.chdir(workdir)
|
||||
os.execvp(command[0], command)
|
||||
except OSError as exc:
|
||||
with contextlib.suppress(OSError):
|
||||
_ = os.write(1, _EXEC_FAIL_MARKER + str(exc).encode(errors="replace") + b"\n")
|
||||
os._exit(127)
|
||||
try:
|
||||
handle = spawn_pty(command, cwd=workdir)
|
||||
except OSError as exc:
|
||||
msg = f"failed to open PTY: {exc}"
|
||||
raise WorkspaceError(msg) from exc
|
||||
|
||||
os.close(slave_fd)
|
||||
with contextlib.suppress(OSError, AttributeError):
|
||||
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
|
||||
with cls._lock:
|
||||
session_id = cls._next_id
|
||||
cls._next_id += 1
|
||||
session = PtySession(
|
||||
session_id=session_id,
|
||||
master_fd=master_fd,
|
||||
pid=pid,
|
||||
handle=handle,
|
||||
command=tuple(command),
|
||||
output_budget=cls.session_output_budget,
|
||||
)
|
||||
@@ -181,20 +168,10 @@ class PtyManager:
|
||||
def _poll_exit(cls, session: PtySession) -> None:
|
||||
if not session.alive or session.closed:
|
||||
return
|
||||
try:
|
||||
waited_pid, status = os.waitpid(session.pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
session.alive = False
|
||||
return
|
||||
if waited_pid == 0:
|
||||
return
|
||||
session.alive = False
|
||||
if os.WIFEXITED(status):
|
||||
session.exit_code = os.WEXITSTATUS(status)
|
||||
elif os.WIFSIGNALED(status):
|
||||
session.exit_code = -os.WTERMSIG(status)
|
||||
else:
|
||||
session.exit_code = status
|
||||
alive, exit_code = session.handle.poll_exit()
|
||||
session.alive = alive
|
||||
if exit_code is not None:
|
||||
session.exit_code = exit_code
|
||||
|
||||
@classmethod
|
||||
def refresh(cls, session_id: int) -> PtySession:
|
||||
@@ -210,13 +187,7 @@ class PtyManager:
|
||||
if session.closed or (session.output_budget - session.bytes_read) <= 0:
|
||||
return b""
|
||||
to_read = min(max_bytes, session.output_budget - session.bytes_read)
|
||||
data = b""
|
||||
try:
|
||||
ready, _, _ = select.select([session.master_fd], [], [], timeout)
|
||||
if ready:
|
||||
data = os.read(session.master_fd, to_read)
|
||||
except OSError, ValueError:
|
||||
data = b""
|
||||
data = session.handle.read_bytes(to_read, timeout)
|
||||
cls._poll_exit(session)
|
||||
if not data:
|
||||
return b""
|
||||
@@ -336,7 +307,7 @@ class PtyManager:
|
||||
msg = f"PTY session process is not alive: {session_id}"
|
||||
raise WorkspaceError(msg)
|
||||
try:
|
||||
_ = os.write(session.master_fd, data.encode())
|
||||
session.handle.write_bytes(data.encode())
|
||||
except OSError as exc:
|
||||
cls._poll_exit(session)
|
||||
msg = f"failed to write PTY: {exc}"
|
||||
@@ -366,27 +337,19 @@ class PtyManager:
|
||||
|
||||
cls._poll_exit(session)
|
||||
if session.alive:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(session.pid, signal.SIGTERM)
|
||||
deadline = time.monotonic() + max(0.0, grace_seconds)
|
||||
while session.alive and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
cls._poll_exit(session)
|
||||
session.handle.terminate()
|
||||
session.handle.wait_reap(timeout=max(0.0, grace_seconds))
|
||||
cls._poll_exit(session)
|
||||
if session.alive:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(session.pid, signal.SIGKILL)
|
||||
with contextlib.suppress(ChildProcessError):
|
||||
_ = os.waitpid(session.pid, 0)
|
||||
session.alive = False
|
||||
session.handle.kill()
|
||||
session.handle.wait_reap(timeout=1.0)
|
||||
cls._poll_exit(session)
|
||||
if session.alive:
|
||||
session.alive = False
|
||||
if session.exit_code is None:
|
||||
session.exit_code = -signal.SIGKILL
|
||||
else:
|
||||
# Ensure reaped
|
||||
with contextlib.suppress(ChildProcessError):
|
||||
_ = os.waitpid(session.pid, os.WNOHANG)
|
||||
session.exit_code = session.handle.poll_exit()[1]
|
||||
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(session.master_fd)
|
||||
session.handle.close_resources()
|
||||
session.closed = True
|
||||
with cls._lock:
|
||||
_ = cls._sessions.pop(session_id, None)
|
||||
|
||||
Reference in New Issue
Block a user