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:
2026-07-15 20:01:43 +08:00
parent 611147b0a5
commit c466d09173
9 changed files with 496 additions and 182 deletions
+4 -3
View File
@@ -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)
+287
View File
@@ -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)
+38 -75
View File
@@ -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)