2026-04-26 14:47:10 +00:00
|
|
|
|
"""Embedded workspace terminal support for Hermes Web UI.
|
|
|
|
|
|
|
|
|
|
|
|
The terminal is intentionally independent from the agent execution path. It
|
|
|
|
|
|
starts a shell with an explicit cwd/env per process and never mutates
|
|
|
|
|
|
process-global os.environ, which avoids expanding the session-env race tracked
|
|
|
|
|
|
in the agent execution layer.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import errno
|
2026-05-19 11:57:07 +00:00
|
|
|
|
import atexit
|
2026-04-26 14:47:10 +00:00
|
|
|
|
import codecs
|
2026-07-19 22:08:29 +00:00
|
|
|
|
import collections
|
2026-04-26 14:47:10 +00:00
|
|
|
|
import os
|
|
|
|
|
|
import queue
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
import signal
|
|
|
|
|
|
import struct
|
|
|
|
|
|
import subprocess
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
|
import sys
|
2026-04-26 14:47:10 +00:00
|
|
|
|
import threading
|
2026-05-24 20:34:21 +00:00
|
|
|
|
import time
|
2026-04-26 14:47:10 +00:00
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
|
_TERMINAL_SUPPORTED = sys.platform != "win32"
|
|
|
|
|
|
|
|
|
|
|
|
if _TERMINAL_SUPPORTED:
|
|
|
|
|
|
import fcntl
|
|
|
|
|
|
import select
|
|
|
|
|
|
import termios
|
|
|
|
|
|
else:
|
|
|
|
|
|
fcntl = None # type: ignore[assignment]
|
|
|
|
|
|
select = None # type: ignore[assignment]
|
|
|
|
|
|
termios = None # type: ignore[assignment]
|
|
|
|
|
|
|
2026-04-26 14:47:10 +00:00
|
|
|
|
|
|
|
|
|
|
def _set_nonblocking(fd: int) -> None:
|
|
|
|
|
|
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
|
|
|
|
|
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _winsize(rows: int, cols: int) -> bytes:
|
|
|
|
|
|
rows = max(8, min(int(rows or 24), 80))
|
|
|
|
|
|
cols = max(20, min(int(cols or 80), 240))
|
|
|
|
|
|
return struct.pack("HHHH", rows, cols, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 20:34:21 +00:00
|
|
|
|
def _safe_close_fd(fd: int) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.close(fd)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 22:08:29 +00:00
|
|
|
|
# Bounds both the replay backlog and each subscriber's live queue: how many
|
|
|
|
|
|
# output chunks are buffered before the oldest is dropped. One value so the
|
|
|
|
|
|
# catch-up backlog and the per-viewer queue stay in lockstep.
|
|
|
|
|
|
_OUTPUT_BUFFER_MAXLEN = 2000
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-26 14:47:10 +00:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class TerminalSession:
|
|
|
|
|
|
session_id: str
|
|
|
|
|
|
workspace: str
|
|
|
|
|
|
proc: subprocess.Popen
|
|
|
|
|
|
master_fd: int
|
|
|
|
|
|
rows: int = 24
|
|
|
|
|
|
cols: int = 80
|
2026-07-19 22:08:29 +00:00
|
|
|
|
# Output is fanned out to every attached viewer. Each SSE consumer
|
|
|
|
|
|
# subscribe()s its own queue; put_output broadcasts to all of them plus a
|
|
|
|
|
|
# bounded backlog that a newly attaching consumer replays. Two tabs/windows
|
|
|
|
|
|
# on the same session therefore each get the FULL byte stream — previously a
|
|
|
|
|
|
# single shared queue was read destructively, so two consumers split the
|
|
|
|
|
|
# output between them and only one saw terminal_closed.
|
|
|
|
|
|
_subscribers: list = field(default_factory=list)
|
|
|
|
|
|
_backlog: collections.deque = field(
|
|
|
|
|
|
default_factory=lambda: collections.deque(maxlen=_OUTPUT_BUFFER_MAXLEN)
|
|
|
|
|
|
)
|
|
|
|
|
|
_sub_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
|
|
|
|
_next_output_seq: int = 1
|
2026-04-26 14:47:10 +00:00
|
|
|
|
closed: threading.Event = field(default_factory=threading.Event)
|
|
|
|
|
|
reader: threading.Thread | None = None
|
2026-07-09 17:54:06 +00:00
|
|
|
|
# Serializes fd-touching ops (os.write, resize ioctl) against os.close, so a
|
|
|
|
|
|
# write can never land on a master_fd that was closed and whose number was
|
|
|
|
|
|
# already recycled by a concurrent openpty — that would inject the user's
|
|
|
|
|
|
# keystrokes into a foreign fd. Holders re-check ``closed`` under this lock
|
|
|
|
|
|
# and bail if the terminal has been torn down.
|
|
|
|
|
|
io_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
|
|
|
|
# Wall-clock of the last input written or output produced. Drives which
|
|
|
|
|
|
# terminal the cap evicts first (least-recently-active): a shell abandoned
|
|
|
|
|
|
# at its prompt has neither, so it sorts oldest and is evicted before an
|
|
|
|
|
|
# actively used one.
|
|
|
|
|
|
last_activity: float = field(default_factory=time.time)
|
2026-07-09 16:54:56 +00:00
|
|
|
|
# Wall-clock of when the terminal last had zero attached viewers, or None
|
|
|
|
|
|
# while at least one is attached. The reaper closes a terminal that has been
|
|
|
|
|
|
# unwatched for longer than the idle grace: a client that drops its output
|
|
|
|
|
|
# stream without POSTing /api/terminal/close (tab close, crash, network drop)
|
|
|
|
|
|
# otherwise leaves the shell running forever (no PDEATHSIG). A terminal is
|
|
|
|
|
|
# born unwatched, so a spawn nobody ever attaches to is reaped too. The grace
|
|
|
|
|
|
# spans transient reconnects (a tab refresh re-attaches and clears it).
|
|
|
|
|
|
unwatched_since: float | None = field(default_factory=time.time)
|
2026-04-26 14:47:10 +00:00
|
|
|
|
|
|
|
|
|
|
def is_alive(self) -> bool:
|
|
|
|
|
|
return not self.closed.is_set() and self.proc.poll() is None
|
|
|
|
|
|
|
2026-07-19 22:08:29 +00:00
|
|
|
|
def subscribe(self, after_seq: int | None = None) -> queue.Queue:
|
|
|
|
|
|
"""Attach a viewer: return a queue seeded with the current backlog and
|
|
|
|
|
|
registered to receive all subsequent output.
|
|
|
|
|
|
|
|
|
|
|
|
A reconnecting EventSource supplies its last received sequence so only
|
|
|
|
|
|
newer backlog entries are replayed. A new viewer leaves ``after_seq``
|
|
|
|
|
|
unset and receives the full bounded backlog.
|
|
|
|
|
|
"""
|
|
|
|
|
|
q: queue.Queue = queue.Queue(maxsize=_OUTPUT_BUFFER_MAXLEN)
|
|
|
|
|
|
with self._sub_lock:
|
|
|
|
|
|
for item in self._backlog:
|
|
|
|
|
|
if after_seq is None or item[0] > after_seq:
|
|
|
|
|
|
q.put_nowait(item)
|
|
|
|
|
|
self._subscribers.append(q)
|
2026-07-09 16:54:56 +00:00
|
|
|
|
self.unwatched_since = None # a viewer is attached
|
2026-07-19 22:08:29 +00:00
|
|
|
|
return q
|
|
|
|
|
|
|
|
|
|
|
|
def unsubscribe(self, q: queue.Queue) -> None:
|
|
|
|
|
|
with self._sub_lock:
|
2026-04-26 14:47:10 +00:00
|
|
|
|
try:
|
2026-07-19 22:08:29 +00:00
|
|
|
|
self._subscribers.remove(q)
|
|
|
|
|
|
except ValueError:
|
2026-04-26 14:47:10 +00:00
|
|
|
|
pass
|
2026-07-09 16:54:56 +00:00
|
|
|
|
if not self._subscribers:
|
|
|
|
|
|
self.unwatched_since = time.time()
|
2026-04-26 14:47:10 +00:00
|
|
|
|
|
2026-07-19 22:08:29 +00:00
|
|
|
|
def put_output(self, event: str, payload: dict) -> None:
|
|
|
|
|
|
self.last_activity = time.time()
|
|
|
|
|
|
with self._sub_lock:
|
|
|
|
|
|
item = (self._next_output_seq, event, payload)
|
|
|
|
|
|
self._next_output_seq += 1
|
|
|
|
|
|
self._backlog.append(item)
|
|
|
|
|
|
# Keep sequence assignment, backlog append, and non-blocking fanout in
|
|
|
|
|
|
# one publication order. Releasing this lock before fanout lets two
|
|
|
|
|
|
# producers enqueue seq N+1 before seq N to a live subscriber.
|
|
|
|
|
|
for q in self._subscribers:
|
|
|
|
|
|
try:
|
|
|
|
|
|
q.put_nowait(item)
|
|
|
|
|
|
except queue.Full:
|
|
|
|
|
|
# Slow viewer: drop its oldest chunk to stay responsive.
|
|
|
|
|
|
# Isolated per subscriber, so one lagging tab can't starve
|
|
|
|
|
|
# another; all queue operations remain non-blocking.
|
|
|
|
|
|
try:
|
|
|
|
|
|
q.get_nowait()
|
|
|
|
|
|
except queue.Empty:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
q.put_nowait(item)
|
|
|
|
|
|
except queue.Full:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-04-26 14:47:10 +00:00
|
|
|
|
|
|
|
|
|
|
_TERMINALS: dict[str, TerminalSession] = {}
|
|
|
|
|
|
_LOCK = threading.RLock()
|
2026-07-09 17:54:06 +00:00
|
|
|
|
# Hard cap on concurrently live embedded terminals. Each holds a shell process,
|
|
|
|
|
|
# a pty master fd, and a reader thread; a client that drops its output stream
|
|
|
|
|
|
# without POSTing /api/terminal/close (tab close, crash, network drop) leaves
|
|
|
|
|
|
# the shell running (no PDEATHSIG — see the note below), so without a ceiling
|
|
|
|
|
|
# these accumulate over a long uptime toward fd/thread exhaustion (#4633). The
|
|
|
|
|
|
# cap evicts the least-recently-active terminal to make room. Generous enough
|
|
|
|
|
|
# that real interactive use never trips it.
|
|
|
|
|
|
_MAX_TERMINALS = 32
|
2026-05-24 20:34:21 +00:00
|
|
|
|
_spawn_queue: queue.Queue = queue.Queue()
|
|
|
|
|
|
_spawn_supervisor_started = False
|
|
|
|
|
|
_spawn_supervisor_lock = threading.Lock()
|
|
|
|
|
|
_spawn_supervisor_thread: threading.Thread | None = None
|
2026-06-07 01:04:16 +00:00
|
|
|
|
_terminal_descendant_reaper_lock = threading.Lock()
|
|
|
|
|
|
_TERMINAL_DESCENDANT_REAPER_LIMIT = 64
|
2026-05-24 20:34:21 +00:00
|
|
|
|
|
2026-07-09 16:54:56 +00:00
|
|
|
|
# Idle-terminal reaper: proactively close terminals whose viewers have all gone
|
|
|
|
|
|
# away, instead of leaving an abandoned shell running until the cap evicts it.
|
|
|
|
|
|
# A terminal unwatched (zero attached output streams) for longer than the grace
|
|
|
|
|
|
# is closed; the grace spans a tab refresh / brief network drop so a real
|
|
|
|
|
|
# reconnect keeps the session. Dead-process terminals are swept too as a
|
|
|
|
|
|
# belt-and-suspenders for the reader-loop retire.
|
|
|
|
|
|
_TERMINAL_IDLE_GRACE_SECONDS = 900 # 15 min unwatched -> reap
|
|
|
|
|
|
_TERMINAL_REAPER_INTERVAL_SECONDS = 60
|
|
|
|
|
|
_terminal_reaper_started = False
|
|
|
|
|
|
_terminal_reaper_lock = threading.Lock()
|
|
|
|
|
|
_terminal_reaper_thread: threading.Thread | None = None
|
|
|
|
|
|
_terminal_reaper_stop = threading.Event()
|
|
|
|
|
|
|
2026-05-24 20:34:21 +00:00
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class _SpawnRequest:
|
|
|
|
|
|
kwargs: dict
|
|
|
|
|
|
done: threading.Event = field(default_factory=threading.Event)
|
|
|
|
|
|
timed_out: threading.Event = field(default_factory=threading.Event)
|
|
|
|
|
|
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
|
|
|
|
proc: subprocess.Popen | None = None
|
|
|
|
|
|
error: BaseException | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reap_abandoned_spawn(proc: subprocess.Popen) -> bool:
|
|
|
|
|
|
if proc.poll() is not None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.killpg(proc.pid, signal.SIGHUP)
|
|
|
|
|
|
except (OSError, ProcessLookupError):
|
|
|
|
|
|
try:
|
|
|
|
|
|
proc.terminate()
|
|
|
|
|
|
except (OSError, ProcessLookupError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
proc.wait(timeout=1.0)
|
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
|
|
|
|
except (OSError, ProcessLookupError):
|
|
|
|
|
|
try:
|
|
|
|
|
|
proc.kill()
|
|
|
|
|
|
except (OSError, ProcessLookupError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
proc.wait(timeout=1.0)
|
|
|
|
|
|
except (subprocess.TimeoutExpired, ProcessLookupError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
if proc.poll() is None:
|
|
|
|
|
|
print("terminal abandoned spawn cleanup failed", flush=True)
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-07 01:04:16 +00:00
|
|
|
|
def _reap_terminal_descendants(
|
|
|
|
|
|
terminal_pgid: int,
|
|
|
|
|
|
limit: int = _TERMINAL_DESCENDANT_REAPER_LIMIT,
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
"""Reap exited descendants that still belong to a terminal-owned process group."""
|
|
|
|
|
|
if not _TERMINAL_SUPPORTED:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
terminal_pgid = abs(int(terminal_pgid))
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
if terminal_pgid <= 0:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
reaped = 0
|
|
|
|
|
|
with _terminal_descendant_reaper_lock:
|
|
|
|
|
|
for _ in range(max(0, int(limit))):
|
|
|
|
|
|
try:
|
|
|
|
|
|
pid, _status = os.waitpid(-terminal_pgid, os.WNOHANG)
|
|
|
|
|
|
except (ChildProcessError, OSError):
|
|
|
|
|
|
break
|
|
|
|
|
|
if pid == 0:
|
|
|
|
|
|
break
|
|
|
|
|
|
reaped += 1
|
|
|
|
|
|
return reaped
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 20:34:21 +00:00
|
|
|
|
def _spawn_supervisor_loop() -> None:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
request = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
request = _spawn_queue.get()
|
|
|
|
|
|
try:
|
|
|
|
|
|
proc = subprocess.Popen(**request.kwargs)
|
|
|
|
|
|
with request.lock:
|
|
|
|
|
|
if request.timed_out.is_set():
|
|
|
|
|
|
_reap_abandoned_spawn(proc)
|
|
|
|
|
|
else:
|
|
|
|
|
|
request.proc = proc
|
|
|
|
|
|
request.done.set()
|
|
|
|
|
|
except BaseException as exc:
|
|
|
|
|
|
with request.lock:
|
|
|
|
|
|
try:
|
|
|
|
|
|
request.error = exc
|
|
|
|
|
|
except BaseException:
|
|
|
|
|
|
pass
|
|
|
|
|
|
request.done.set()
|
|
|
|
|
|
except BaseException as exc:
|
|
|
|
|
|
if request is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
request.error = exc
|
|
|
|
|
|
except BaseException:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
request.done.set()
|
|
|
|
|
|
except BaseException:
|
|
|
|
|
|
pass
|
|
|
|
|
|
time.sleep(0.01)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _spawn_supervisor_entry() -> None:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
_spawn_supervisor_loop()
|
|
|
|
|
|
except BaseException:
|
|
|
|
|
|
time.sleep(0.01)
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_spawn_supervisor() -> None:
|
|
|
|
|
|
global _spawn_supervisor_started, _spawn_supervisor_thread
|
|
|
|
|
|
with _spawn_supervisor_lock:
|
|
|
|
|
|
if _spawn_supervisor_started and _spawn_supervisor_thread and _spawn_supervisor_thread.is_alive():
|
|
|
|
|
|
return
|
|
|
|
|
|
thread = threading.Thread(target=_spawn_supervisor_entry, daemon=True)
|
|
|
|
|
|
thread.start()
|
|
|
|
|
|
_spawn_supervisor_thread = thread
|
|
|
|
|
|
_spawn_supervisor_started = True
|
|
|
|
|
|
|
|
|
|
|
|
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
|
if _TERMINAL_SUPPORTED:
|
|
|
|
|
|
_ensure_spawn_supervisor()
|
2026-04-26 14:47:10 +00:00
|
|
|
|
|
|
|
|
|
|
|
fix(terminal): drop PR_SET_PDEATHSIG preexec_fn that killed every Linux shell
Fixes #2853. The `_terminal_shell_preexec_fn` added in `71d8a8fb` called
`prctl(PR_SET_PDEATHSIG, SIGTERM)` so orphaned PTY shells would die when
the WebUI process crashed. But that signal is **per-thread**, not
per-process, and WebUI runs `ThreadingHTTPServer`: every HTTP request is
handled in its own short-lived worker thread.
Flow that broke every Linux user:
1. User clicks the terminal toggle → frontend hits `POST /api/terminal/start`.
2. ThreadingHTTPServer spins up a worker thread to handle that one request.
3. The worker thread calls `subprocess.Popen(..., preexec_fn=...)`.
4. The shell calls `prctl(PR_SET_PDEATHSIG, SIGTERM)` in its preexec_fn.
Its registered "parent" is now the WebUI worker thread that called Popen.
5. The handler returns its JSON response and the worker thread exits.
6. The kernel sees the pdeathsig-parent thread has died and sends SIGTERM
to the PTY shell. The shell dies within ~10 ms of being created.
7. The reader loop sees EIO on the master FD, emits `terminal_closed`, and
the frontend writes `[terminal closed]`.
macOS users were unaffected because `libc.prctl` doesn't exist there —
`ctypes.CDLL(None)` returns a libc handle, `libc.prctl` raises
`AttributeError`, the bare-`except` swallows it, and the shell starts
with no pdeathsig configured.
Empirical verification on this Linux host (real PTY + `subprocess.Popen`
inside a `threading.Thread` that joins immediately):
with preexec_fn → proc.poll() == -15 (SIGTERM), master FD returns EIO
without preexec_fn → proc.poll() == None (alive), master FD returns "HELLO\\r\\n"
Same shell, same PTY, same threading topology as WebUI.
Fix
---
Drop the `preexec_fn` entirely. The orphan-shell-on-crash case the original
PR was navigating is rare for self-hosted single-user installs, and the
existing `atexit.register(close_all_terminals)` + explicit `close_terminal`
paths cover graceful shutdown. A future fix (option B in the issue) can
re-introduce pdeathsig pinned to a long-lived supervisor thread, but that
is a follow-up — this PR is the smallest unbricks-Linux-today change.
Tests
-----
- Invert `test_terminal_shell_uses_parent_death_signal_preexec` →
`test_terminal_shell_does_not_use_pdeathsig_preexec`: asserts
`preexec_fn` is NOT in the Popen kwargs.
- Add `test_pty_shell_survives_when_spawning_thread_exits`: spawns a
real PTY shell via `start_terminal` from a worker thread, waits for
the worker to join, asserts the shell is still alive after a half-second
grace window. This is the contract the original tests never exercised.
- Update `test_terminal_module_registers_graceful_shutdown_reaper` to
refuse re-introduction of the preexec_fn or the `libc.prctl(1, SIGTERM)`
call (treats either as a regression).
All 27 terminal-related tests pass locally.
Refs #2853
2026-05-24 16:52:19 +00:00
|
|
|
|
# NOTE on parent-death-signal: a previous version of this module set
|
|
|
|
|
|
# PR_SET_PDEATHSIG via a preexec_fn to terminate orphaned PTY shells when the
|
|
|
|
|
|
# WebUI process crashed. That broke every Linux user (#2853): WebUI runs a
|
|
|
|
|
|
# ThreadingHTTPServer, so the Popen call happens on a short-lived per-request
|
|
|
|
|
|
# thread, and PR_SET_PDEATHSIG is per-thread. The PTY shell registered the
|
|
|
|
|
|
# spawning thread as its "parent" and was killed with SIGTERM the instant that
|
|
|
|
|
|
# thread joined — within ~10 ms of opening the terminal — surfacing as the
|
|
|
|
|
|
# `[terminal closed]` banner. The graceful path is covered by
|
|
|
|
|
|
# `atexit.register(close_all_terminals)` and the explicit `close_terminal`
|
|
|
|
|
|
# call sites; hard kills of the WebUI process leak the shell, which is the
|
|
|
|
|
|
# tradeoff for working on Linux at all.
|
2026-05-19 11:57:07 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-26 14:47:10 +00:00
|
|
|
|
def _decode_terminal_output(decoder, data: bytes) -> str:
|
|
|
|
|
|
"""Decode PTY bytes without stripping terminal control sequences."""
|
|
|
|
|
|
return decoder.decode(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _shell_path() -> str:
|
|
|
|
|
|
shell = os.environ.get("SHELL") or ""
|
|
|
|
|
|
if shell and Path(shell).exists():
|
|
|
|
|
|
return shell
|
|
|
|
|
|
return shutil.which("zsh") or shutil.which("bash") or shutil.which("sh") or "/bin/sh"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _shell_argv(shell: str) -> list[str]:
|
|
|
|
|
|
name = Path(shell).name
|
|
|
|
|
|
if name in {"zsh", "bash", "sh"}:
|
|
|
|
|
|
return [shell, "-i"]
|
|
|
|
|
|
return [shell]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reader_loop(term: TerminalSession) -> None:
|
|
|
|
|
|
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
|
|
|
|
try:
|
|
|
|
|
|
while not term.closed.is_set():
|
|
|
|
|
|
if term.proc.poll() is not None:
|
|
|
|
|
|
break
|
|
|
|
|
|
try:
|
|
|
|
|
|
ready, _, _ = select.select([term.master_fd], [], [], 0.25)
|
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
|
break
|
|
|
|
|
|
if not ready:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = os.read(term.master_fd, 8192)
|
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
|
if exc.errno in (errno.EIO, errno.EBADF):
|
|
|
|
|
|
break
|
|
|
|
|
|
raise
|
|
|
|
|
|
if not data:
|
|
|
|
|
|
break
|
|
|
|
|
|
text = _decode_terminal_output(decoder, data)
|
|
|
|
|
|
if text:
|
|
|
|
|
|
term.put_output("output", {"text": text})
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
term.put_output("terminal_error", {"error": str(exc)})
|
|
|
|
|
|
finally:
|
|
|
|
|
|
term.closed.set()
|
|
|
|
|
|
code = term.proc.poll()
|
2026-06-07 01:04:16 +00:00
|
|
|
|
_reap_terminal_descendants(term.proc.pid)
|
2026-04-26 14:47:10 +00:00
|
|
|
|
term.put_output("terminal_closed", {"exit_code": code})
|
2026-07-09 17:54:06 +00:00
|
|
|
|
# The shell has exited (or its pty broke): retire the session so its
|
|
|
|
|
|
# master fd and _TERMINALS entry are released. Previously only an
|
|
|
|
|
|
# explicit close_terminal() / restart / atexit did this, so a shell that
|
|
|
|
|
|
# exited on its own (user typed `exit`, process died) leaked its master
|
|
|
|
|
|
# fd and dict entry for the rest of the WebUI's uptime. ``expected=term``
|
|
|
|
|
|
# makes this a no-op if a restart already replaced the entry with a new
|
|
|
|
|
|
# terminal for the same session id.
|
|
|
|
|
|
close_terminal(term.session_id, expected=term)
|
2026-04-26 14:47:10 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _set_size(term: TerminalSession, rows: int, cols: int) -> None:
|
|
|
|
|
|
term.rows = max(8, min(int(rows or term.rows or 24), 80))
|
|
|
|
|
|
term.cols = max(20, min(int(cols or term.cols or 80), 240))
|
2026-07-09 17:54:06 +00:00
|
|
|
|
# The ioctl touches master_fd, so guard it against a concurrent close (and
|
|
|
|
|
|
# the fd-number recycling that can follow) with the same io_lock as writes.
|
|
|
|
|
|
with term.io_lock:
|
|
|
|
|
|
if not term.closed.is_set():
|
|
|
|
|
|
try:
|
|
|
|
|
|
fcntl.ioctl(term.master_fd, termios.TIOCSWINSZ, _winsize(term.rows, term.cols))
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
2026-04-26 14:47:10 +00:00
|
|
|
|
try:
|
|
|
|
|
|
if term.proc.poll() is None:
|
|
|
|
|
|
os.killpg(term.proc.pid, signal.SIGWINCH)
|
|
|
|
|
|
except (OSError, ProcessLookupError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 17:54:06 +00:00
|
|
|
|
def _enforce_terminal_cap(*, exclude_sid: str | None = None) -> None:
|
|
|
|
|
|
"""Evict terminals until there is room under ``_MAX_TERMINALS``.
|
|
|
|
|
|
|
|
|
|
|
|
Picks a victim under the lock but closes it *outside* the lock (close_terminal
|
|
|
|
|
|
may spend up to a couple of seconds killing/waiting on the shell). Prefers a
|
|
|
|
|
|
dead-process terminal, else the least-recently-active one — an abandoned
|
|
|
|
|
|
shell idle at its prompt sorts oldest and goes first. ``exclude_sid`` is the
|
|
|
|
|
|
session about to reuse/replace its own entry, so it never evicts itself.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not _TERMINAL_SUPPORTED:
|
|
|
|
|
|
return
|
|
|
|
|
|
# Bounded loop: at most the current population; guards against a pathological
|
|
|
|
|
|
# spin if close_terminal somehow can't remove an entry.
|
|
|
|
|
|
for _ in range(_MAX_TERMINALS + 1):
|
|
|
|
|
|
victim_sid = None
|
|
|
|
|
|
victim_term = None
|
|
|
|
|
|
with _LOCK:
|
|
|
|
|
|
# Reuse/restart of an existing sid replaces in place — no growth.
|
|
|
|
|
|
if exclude_sid in _TERMINALS:
|
|
|
|
|
|
return
|
|
|
|
|
|
if len(_TERMINALS) < _MAX_TERMINALS:
|
|
|
|
|
|
return
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
(sid, term) for sid, term in _TERMINALS.items() if sid != exclude_sid
|
|
|
|
|
|
]
|
|
|
|
|
|
if not candidates:
|
|
|
|
|
|
return
|
|
|
|
|
|
dead = [(sid, term) for sid, term in candidates if not term.is_alive()]
|
|
|
|
|
|
victim_sid, victim_term = (
|
|
|
|
|
|
dead[0] if dead else min(candidates, key=lambda kv: kv[1].last_activity)
|
|
|
|
|
|
)
|
|
|
|
|
|
# ``expected=victim_term`` so that if this sid was restarted/replaced in
|
|
|
|
|
|
# the gap between picking it and closing it, we don't tear down the new
|
|
|
|
|
|
# (possibly active) terminal — symmetric with the reader-loop retire.
|
|
|
|
|
|
close_terminal(victim_sid, expected=victim_term)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 16:54:56 +00:00
|
|
|
|
def _terminals_to_reap(now: float) -> list[tuple[str, TerminalSession]]:
|
|
|
|
|
|
"""Return (sid, term) pairs the reaper should close: a dead process, or a
|
|
|
|
|
|
terminal unwatched for longer than the idle grace. Pure/snapshotted under
|
|
|
|
|
|
the lock so it can be unit-tested without threads."""
|
|
|
|
|
|
victims = []
|
|
|
|
|
|
with _LOCK:
|
|
|
|
|
|
for sid, term in _TERMINALS.items():
|
|
|
|
|
|
if not term.is_alive():
|
|
|
|
|
|
victims.append((sid, term))
|
|
|
|
|
|
continue
|
|
|
|
|
|
unwatched = term.unwatched_since
|
|
|
|
|
|
if unwatched is not None and (now - unwatched) >= _TERMINAL_IDLE_GRACE_SECONDS:
|
|
|
|
|
|
victims.append((sid, term))
|
|
|
|
|
|
return victims
|
|
|
|
|
|
|
|
|
|
|
|
|
fix(terminal): close the reaper's selection→reconnect→close TOCTOU
`_terminals_to_reap()` snapshots victims under `_LOCK` and then releases it,
but `subscribe()` clears `unwatched_since` and appends to `_subscribers` under
the terminal's own `_sub_lock`, which the selection pass never held. A viewer
reconnecting in that gap left object identity — all `close_terminal(expected=…)`
ever checked — still true, so the reaper killed a terminal that now had a live
viewer.
- `_claim_reap_victim()` re-establishes the *whole* selection predicate while
holding `_LOCK` and the terminal's `_sub_lock`, and removes the registry
entry in that same critical section: same object, no subscribers, and
`unwatched_since` still set and still expired. A dead process is still
reaped unconditionally — it cannot come back, and a viewer watching a corpse
must not pin the entry forever.
- `attach_terminal()` does the registry lookup and the subscribe atomically
under `_LOCK`, replacing `get_terminal()` + `term.subscribe()` in the SSE
route. It takes the same two locks in the same order as the claim, so
exactly one side wins: the viewer attaches (and the reap is refused), or the
entry is already gone (and the route answers "terminal not running" before
committing a 200, instead of streaming from a corpse). Registration is the
authority — a registered-but-`closed` terminal still attaches so the viewer
receives `terminal_closed`.
- Teardown moved to `_teardown_terminal()` and runs after both locks are
released; `killpg`+`wait` can block ~2.5s and must not pin the registry.
- Dropped the duplicated `self.last_activity = time.time()` the rebase left in
`put_output()`.
Tests: a barrier regression that pauses the reaper after selection, attaches a
viewer through the production path, resumes, and asserts the terminal stays
registered/live and still receives output; the inverse where the reap claims
first and the attach reports not-running; refreshed-grace and replaced-entry
claims; dead-with-viewer; and a probe that `_LOCK` is free during teardown.
Three of them fail against an identity-only claim.
2026-07-24 22:30:05 +00:00
|
|
|
|
def _claim_reap_victim(sid: str, term: TerminalSession, now: float) -> TerminalSession | None:
|
|
|
|
|
|
"""Atomically remove *term* from the registry, or refuse.
|
|
|
|
|
|
|
|
|
|
|
|
``_terminals_to_reap`` snapshots victims and then releases ``_LOCK``, so by
|
|
|
|
|
|
the time we get here a viewer may have reconnected: ``subscribe()`` appends
|
|
|
|
|
|
to ``_subscribers`` and clears ``unwatched_since`` under the terminal's
|
|
|
|
|
|
``_sub_lock``, which the selection pass never held. Object identity alone —
|
|
|
|
|
|
what ``close_terminal(expected=…)`` checks — is still true in that case, so
|
|
|
|
|
|
the reaper would kill a terminal that now has a live viewer.
|
|
|
|
|
|
|
|
|
|
|
|
The claim therefore re-establishes the *whole* selection predicate while
|
|
|
|
|
|
holding both locks, and takes the entry out of ``_TERMINALS`` in the same
|
|
|
|
|
|
critical section. A concurrent ``attach_terminal()`` acquires the same two
|
|
|
|
|
|
locks in the same order, so exactly one of the two wins: either the viewer
|
|
|
|
|
|
is attached (and we refuse) or the entry is already gone (and the attach
|
|
|
|
|
|
reports "not running").
|
|
|
|
|
|
|
|
|
|
|
|
Returns the claimed terminal — the caller owns its teardown — or ``None``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
with _LOCK:
|
|
|
|
|
|
if _TERMINALS.get(sid) is not term:
|
|
|
|
|
|
return None
|
|
|
|
|
|
# A dead process is reaped unconditionally: it cannot come back to life,
|
|
|
|
|
|
# and an attached viewer only means someone is watching a corpse.
|
|
|
|
|
|
if term.is_alive():
|
|
|
|
|
|
with term._sub_lock:
|
|
|
|
|
|
if term._subscribers:
|
|
|
|
|
|
return None
|
|
|
|
|
|
unwatched = term.unwatched_since
|
|
|
|
|
|
if unwatched is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if (now - unwatched) < _TERMINAL_IDLE_GRACE_SECONDS:
|
|
|
|
|
|
return None
|
|
|
|
|
|
del _TERMINALS[sid]
|
|
|
|
|
|
return term
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 16:54:56 +00:00
|
|
|
|
def _reap_idle_terminals(now: float) -> int:
|
fix(terminal): close the reaper's selection→reconnect→close TOCTOU
`_terminals_to_reap()` snapshots victims under `_LOCK` and then releases it,
but `subscribe()` clears `unwatched_since` and appends to `_subscribers` under
the terminal's own `_sub_lock`, which the selection pass never held. A viewer
reconnecting in that gap left object identity — all `close_terminal(expected=…)`
ever checked — still true, so the reaper killed a terminal that now had a live
viewer.
- `_claim_reap_victim()` re-establishes the *whole* selection predicate while
holding `_LOCK` and the terminal's `_sub_lock`, and removes the registry
entry in that same critical section: same object, no subscribers, and
`unwatched_since` still set and still expired. A dead process is still
reaped unconditionally — it cannot come back, and a viewer watching a corpse
must not pin the entry forever.
- `attach_terminal()` does the registry lookup and the subscribe atomically
under `_LOCK`, replacing `get_terminal()` + `term.subscribe()` in the SSE
route. It takes the same two locks in the same order as the claim, so
exactly one side wins: the viewer attaches (and the reap is refused), or the
entry is already gone (and the route answers "terminal not running" before
committing a 200, instead of streaming from a corpse). Registration is the
authority — a registered-but-`closed` terminal still attaches so the viewer
receives `terminal_closed`.
- Teardown moved to `_teardown_terminal()` and runs after both locks are
released; `killpg`+`wait` can block ~2.5s and must not pin the registry.
- Dropped the duplicated `self.last_activity = time.time()` the rebase left in
`put_output()`.
Tests: a barrier regression that pauses the reaper after selection, attaches a
viewer through the production path, resumes, and asserts the terminal stays
registered/live and still receives output; the inverse where the reap claims
first and the attach reports not-running; refreshed-grace and replaced-entry
claims; dead-with-viewer; and a probe that `_LOCK` is free during teardown.
Three of them fail against an identity-only claim.
2026-07-24 22:30:05 +00:00
|
|
|
|
"""Close every terminal selected by ``_terminals_to_reap`` that is *still*
|
|
|
|
|
|
idle when claimed. Returns the count closed."""
|
2026-07-09 16:54:56 +00:00
|
|
|
|
reaped = 0
|
|
|
|
|
|
for sid, term in _terminals_to_reap(now):
|
fix(terminal): close the reaper's selection→reconnect→close TOCTOU
`_terminals_to_reap()` snapshots victims under `_LOCK` and then releases it,
but `subscribe()` clears `unwatched_since` and appends to `_subscribers` under
the terminal's own `_sub_lock`, which the selection pass never held. A viewer
reconnecting in that gap left object identity — all `close_terminal(expected=…)`
ever checked — still true, so the reaper killed a terminal that now had a live
viewer.
- `_claim_reap_victim()` re-establishes the *whole* selection predicate while
holding `_LOCK` and the terminal's `_sub_lock`, and removes the registry
entry in that same critical section: same object, no subscribers, and
`unwatched_since` still set and still expired. A dead process is still
reaped unconditionally — it cannot come back, and a viewer watching a corpse
must not pin the entry forever.
- `attach_terminal()` does the registry lookup and the subscribe atomically
under `_LOCK`, replacing `get_terminal()` + `term.subscribe()` in the SSE
route. It takes the same two locks in the same order as the claim, so
exactly one side wins: the viewer attaches (and the reap is refused), or the
entry is already gone (and the route answers "terminal not running" before
committing a 200, instead of streaming from a corpse). Registration is the
authority — a registered-but-`closed` terminal still attaches so the viewer
receives `terminal_closed`.
- Teardown moved to `_teardown_terminal()` and runs after both locks are
released; `killpg`+`wait` can block ~2.5s and must not pin the registry.
- Dropped the duplicated `self.last_activity = time.time()` the rebase left in
`put_output()`.
Tests: a barrier regression that pauses the reaper after selection, attaches a
viewer through the production path, resumes, and asserts the terminal stays
registered/live and still receives output; the inverse where the reap claims
first and the attach reports not-running; refreshed-grace and replaced-entry
claims; dead-with-viewer; and a probe that `_LOCK` is free during teardown.
Three of them fail against an identity-only claim.
2026-07-24 22:30:05 +00:00
|
|
|
|
claimed = _claim_reap_victim(sid, term, now)
|
|
|
|
|
|
if claimed is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Process/fd teardown runs after both locks are released: killpg + wait
|
|
|
|
|
|
# can take seconds and must not block attaches or spawns.
|
|
|
|
|
|
_teardown_terminal(claimed)
|
|
|
|
|
|
reaped += 1
|
2026-07-09 16:54:56 +00:00
|
|
|
|
return reaped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _terminal_reaper_loop() -> None:
|
|
|
|
|
|
while not _terminal_reaper_stop.wait(_TERMINAL_REAPER_INTERVAL_SECONDS):
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Wall-clock, consistent with unwatched_since / last_activity.
|
|
|
|
|
|
_reap_idle_terminals(time.time())
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# Never let a transient error kill the reaper thread.
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_terminal_reaper() -> None:
|
|
|
|
|
|
global _terminal_reaper_started, _terminal_reaper_thread
|
|
|
|
|
|
if not _TERMINAL_SUPPORTED:
|
|
|
|
|
|
return
|
|
|
|
|
|
with _terminal_reaper_lock:
|
|
|
|
|
|
if (
|
|
|
|
|
|
_terminal_reaper_started
|
|
|
|
|
|
and _terminal_reaper_thread is not None
|
|
|
|
|
|
and getattr(_terminal_reaper_thread, "is_alive", lambda: False)()
|
|
|
|
|
|
):
|
|
|
|
|
|
return
|
|
|
|
|
|
thread = threading.Thread(target=_terminal_reaper_loop, daemon=True)
|
|
|
|
|
|
thread.start()
|
|
|
|
|
|
_terminal_reaper_thread = thread
|
|
|
|
|
|
_terminal_reaper_started = True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-26 14:47:10 +00:00
|
|
|
|
def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = 80, restart: bool = False) -> TerminalSession:
|
|
|
|
|
|
"""Start or return the embedded terminal for a WebUI session."""
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
|
if not _TERMINAL_SUPPORTED:
|
|
|
|
|
|
raise NotImplementedError("Embedded terminal is not supported on Windows")
|
2026-04-26 14:47:10 +00:00
|
|
|
|
sid = str(session_id or "").strip()
|
|
|
|
|
|
if not sid:
|
|
|
|
|
|
raise ValueError("session_id is required")
|
|
|
|
|
|
cwd = str(Path(workspace).expanduser().resolve())
|
|
|
|
|
|
if not Path(cwd).is_dir():
|
|
|
|
|
|
raise ValueError("workspace is not a directory")
|
|
|
|
|
|
|
2026-07-09 17:54:06 +00:00
|
|
|
|
# Enforce the cap before spawning. Done outside the main lock below (the
|
|
|
|
|
|
# eviction's process teardown must not run while _LOCK is held for the whole
|
|
|
|
|
|
# spawn), and skipped for a same-sid reuse/restart, which replaces rather
|
|
|
|
|
|
# than adds an entry.
|
|
|
|
|
|
_enforce_terminal_cap(exclude_sid=sid)
|
|
|
|
|
|
|
2026-04-26 14:47:10 +00:00
|
|
|
|
with _LOCK:
|
|
|
|
|
|
current = _TERMINALS.get(sid)
|
|
|
|
|
|
if current and current.is_alive() and not restart and current.workspace == cwd:
|
|
|
|
|
|
_set_size(current, rows, cols)
|
|
|
|
|
|
return current
|
|
|
|
|
|
if current:
|
|
|
|
|
|
close_terminal(sid)
|
|
|
|
|
|
|
|
|
|
|
|
master_fd, slave_fd = os.openpty()
|
2026-04-29 05:06:34 +00:00
|
|
|
|
# Build a safe env: allowlist common shell vars, strip API keys and secrets.
|
|
|
|
|
|
# The PTY shell is an interactive UI surface — do not leak server credentials.
|
|
|
|
|
|
_SAFE_ENV_KEYS = {
|
|
|
|
|
|
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL",
|
|
|
|
|
|
"LC_CTYPE", "LC_MESSAGES", "LANGUAGE", "TZ", "TMPDIR", "TEMP",
|
|
|
|
|
|
"XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME",
|
|
|
|
|
|
}
|
|
|
|
|
|
env = {k: v for k, v in os.environ.items() if k in _SAFE_ENV_KEYS}
|
2026-04-26 14:47:10 +00:00
|
|
|
|
env.update(
|
|
|
|
|
|
{
|
|
|
|
|
|
"TERM": "xterm-256color",
|
|
|
|
|
|
"COLORTERM": "truecolor",
|
|
|
|
|
|
"COLUMNS": str(cols),
|
|
|
|
|
|
"LINES": str(rows),
|
|
|
|
|
|
"PWD": cwd,
|
|
|
|
|
|
"HERMES_WEBUI_TERMINAL": "1",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
shell = _shell_path()
|
2026-05-24 20:34:21 +00:00
|
|
|
|
# Keep the shell in its own process group for explicit cleanup via
|
|
|
|
|
|
# close_terminal()/close_all_terminals(); do not use PDEATHSIG here.
|
|
|
|
|
|
request = _SpawnRequest(
|
|
|
|
|
|
{
|
|
|
|
|
|
"args": _shell_argv(shell),
|
|
|
|
|
|
"cwd": cwd,
|
|
|
|
|
|
"env": env,
|
|
|
|
|
|
"stdin": slave_fd,
|
|
|
|
|
|
"stdout": slave_fd,
|
|
|
|
|
|
"stderr": slave_fd,
|
|
|
|
|
|
"close_fds": True,
|
|
|
|
|
|
# Required so cleanup can signal the whole interactive shell tree.
|
|
|
|
|
|
"start_new_session": True,
|
|
|
|
|
|
}
|
2026-04-26 14:47:10 +00:00
|
|
|
|
)
|
2026-05-24 20:34:21 +00:00
|
|
|
|
_ensure_spawn_supervisor()
|
2026-07-09 16:54:56 +00:00
|
|
|
|
_ensure_terminal_reaper()
|
2026-05-24 20:34:21 +00:00
|
|
|
|
_spawn_queue.put(request)
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not request.done.wait(timeout=5.0):
|
|
|
|
|
|
timed_out = False
|
|
|
|
|
|
with request.lock:
|
|
|
|
|
|
if not request.done.is_set():
|
|
|
|
|
|
request.timed_out.set()
|
|
|
|
|
|
timed_out = True
|
|
|
|
|
|
if timed_out:
|
|
|
|
|
|
raise TimeoutError("terminal spawn timeout - supervisor unresponsive")
|
|
|
|
|
|
if request.error:
|
|
|
|
|
|
raise request.error
|
|
|
|
|
|
proc = request.proc
|
|
|
|
|
|
if proc is None:
|
|
|
|
|
|
raise RuntimeError("terminal spawn failed without process")
|
|
|
|
|
|
except BaseException:
|
|
|
|
|
|
_safe_close_fd(master_fd)
|
|
|
|
|
|
_safe_close_fd(slave_fd)
|
|
|
|
|
|
raise
|
2026-04-26 14:47:10 +00:00
|
|
|
|
os.close(slave_fd)
|
|
|
|
|
|
_set_nonblocking(master_fd)
|
|
|
|
|
|
|
|
|
|
|
|
term = TerminalSession(
|
|
|
|
|
|
session_id=sid,
|
|
|
|
|
|
workspace=cwd,
|
|
|
|
|
|
proc=proc,
|
|
|
|
|
|
master_fd=master_fd,
|
|
|
|
|
|
rows=rows,
|
|
|
|
|
|
cols=cols,
|
|
|
|
|
|
)
|
|
|
|
|
|
_set_size(term, rows, cols)
|
|
|
|
|
|
term.reader = threading.Thread(target=_reader_loop, args=(term,), daemon=True)
|
|
|
|
|
|
term.reader.start()
|
|
|
|
|
|
_TERMINALS[sid] = term
|
|
|
|
|
|
return term
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_terminal(session_id: str) -> TerminalSession | None:
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
|
if not _TERMINAL_SUPPORTED:
|
|
|
|
|
|
return None
|
2026-04-26 14:47:10 +00:00
|
|
|
|
with _LOCK:
|
|
|
|
|
|
term = _TERMINALS.get(str(session_id or ""))
|
|
|
|
|
|
if term and term.is_alive():
|
|
|
|
|
|
return term
|
|
|
|
|
|
return term
|
|
|
|
|
|
|
|
|
|
|
|
|
fix(terminal): close the reaper's selection→reconnect→close TOCTOU
`_terminals_to_reap()` snapshots victims under `_LOCK` and then releases it,
but `subscribe()` clears `unwatched_since` and appends to `_subscribers` under
the terminal's own `_sub_lock`, which the selection pass never held. A viewer
reconnecting in that gap left object identity — all `close_terminal(expected=…)`
ever checked — still true, so the reaper killed a terminal that now had a live
viewer.
- `_claim_reap_victim()` re-establishes the *whole* selection predicate while
holding `_LOCK` and the terminal's `_sub_lock`, and removes the registry
entry in that same critical section: same object, no subscribers, and
`unwatched_since` still set and still expired. A dead process is still
reaped unconditionally — it cannot come back, and a viewer watching a corpse
must not pin the entry forever.
- `attach_terminal()` does the registry lookup and the subscribe atomically
under `_LOCK`, replacing `get_terminal()` + `term.subscribe()` in the SSE
route. It takes the same two locks in the same order as the claim, so
exactly one side wins: the viewer attaches (and the reap is refused), or the
entry is already gone (and the route answers "terminal not running" before
committing a 200, instead of streaming from a corpse). Registration is the
authority — a registered-but-`closed` terminal still attaches so the viewer
receives `terminal_closed`.
- Teardown moved to `_teardown_terminal()` and runs after both locks are
released; `killpg`+`wait` can block ~2.5s and must not pin the registry.
- Dropped the duplicated `self.last_activity = time.time()` the rebase left in
`put_output()`.
Tests: a barrier regression that pauses the reaper after selection, attaches a
viewer through the production path, resumes, and asserts the terminal stays
registered/live and still receives output; the inverse where the reap claims
first and the attach reports not-running; refreshed-grace and replaced-entry
claims; dead-with-viewer; and a probe that `_LOCK` is free during teardown.
Three of them fail against an identity-only claim.
2026-07-24 22:30:05 +00:00
|
|
|
|
def attach_terminal(
|
|
|
|
|
|
session_id: str, after_seq: int | None = None
|
|
|
|
|
|
) -> tuple[TerminalSession, queue.Queue] | None:
|
|
|
|
|
|
"""Attach a viewer atomically against the idle reaper.
|
|
|
|
|
|
|
|
|
|
|
|
``get_terminal()`` followed by ``term.subscribe()`` leaves a window: the
|
|
|
|
|
|
reaper can claim and tear the terminal down in between, so the viewer ends
|
|
|
|
|
|
up subscribed to a corpse and the caller reports a live stream that will
|
|
|
|
|
|
never produce output. Doing the lookup and the subscribe inside the same
|
|
|
|
|
|
``_LOCK`` section — the same lock, in the same order, that
|
|
|
|
|
|
``_claim_reap_victim`` takes — makes the two mutually exclusive:
|
|
|
|
|
|
|
|
|
|
|
|
* attach wins → ``_subscribers`` is non-empty and ``unwatched_since`` is
|
|
|
|
|
|
``None`` before the reaper can revalidate, so the reap is refused;
|
|
|
|
|
|
* reap wins → the entry is already out of ``_TERMINALS``, so this returns
|
|
|
|
|
|
``None`` and the route answers "terminal not running" instead of hanging.
|
|
|
|
|
|
|
|
|
|
|
|
Registration is the authority, deliberately: both the reaper and
|
|
|
|
|
|
``close_terminal()`` remove the entry *before* tearing the terminal down, so
|
|
|
|
|
|
"still in ``_TERMINALS``" is exactly the condition that cannot race. A
|
|
|
|
|
|
terminal that is registered but already flagged ``closed`` (its shell exited
|
|
|
|
|
|
and the reader loop has not retired it yet) still attaches, so the viewer
|
|
|
|
|
|
receives the ``terminal_closed`` event instead of a bare 404.
|
|
|
|
|
|
|
|
|
|
|
|
Returns ``(term, queue)`` or ``None``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not _TERMINAL_SUPPORTED:
|
|
|
|
|
|
return None
|
|
|
|
|
|
sid = str(session_id or "")
|
|
|
|
|
|
with _LOCK:
|
|
|
|
|
|
term = _TERMINALS.get(sid)
|
|
|
|
|
|
if term is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return term, term.subscribe(after_seq=after_seq)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-26 14:47:10 +00:00
|
|
|
|
def write_terminal(session_id: str, data: str) -> None:
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
|
if not _TERMINAL_SUPPORTED:
|
|
|
|
|
|
raise NotImplementedError("Embedded terminal is not supported on Windows")
|
2026-04-26 14:47:10 +00:00
|
|
|
|
term = get_terminal(session_id)
|
|
|
|
|
|
if not term or not term.is_alive():
|
|
|
|
|
|
raise KeyError("terminal not running")
|
2026-07-09 17:54:06 +00:00
|
|
|
|
# Re-check ``closed`` under io_lock and write while holding it, so the fd
|
|
|
|
|
|
# can't be closed (and its number recycled by another openpty) between the
|
|
|
|
|
|
# check and the write — which would inject this input into a foreign fd.
|
|
|
|
|
|
with term.io_lock:
|
|
|
|
|
|
if term.closed.is_set():
|
|
|
|
|
|
raise KeyError("terminal not running")
|
|
|
|
|
|
os.write(term.master_fd, str(data or "").encode("utf-8", errors="replace"))
|
|
|
|
|
|
term.last_activity = time.time()
|
2026-04-26 14:47:10 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resize_terminal(session_id: str, rows: int, cols: int) -> None:
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
|
if not _TERMINAL_SUPPORTED:
|
|
|
|
|
|
raise NotImplementedError("Embedded terminal is not supported on Windows")
|
2026-04-26 14:47:10 +00:00
|
|
|
|
term = get_terminal(session_id)
|
|
|
|
|
|
if not term:
|
|
|
|
|
|
raise KeyError("terminal not running")
|
|
|
|
|
|
_set_size(term, rows, cols)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 17:54:06 +00:00
|
|
|
|
def close_terminal(session_id: str, *, expected: TerminalSession | None = None) -> bool:
|
|
|
|
|
|
"""Tear down the terminal for *session_id*: kill the shell, close the pty
|
|
|
|
|
|
master fd, reap descendants, and drop the ``_TERMINALS`` entry.
|
|
|
|
|
|
|
|
|
|
|
|
``expected`` guards the retire-from-reader-loop path: only act if the live
|
|
|
|
|
|
entry is still that exact terminal, so an old reader thread finishing after
|
|
|
|
|
|
a restart cannot tear down the *new* terminal that replaced it (the old
|
|
|
|
|
|
one's fd was already closed by the restart's own close_terminal call).
|
|
|
|
|
|
"""
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
|
if not _TERMINAL_SUPPORTED:
|
|
|
|
|
|
return False
|
2026-04-26 14:47:10 +00:00
|
|
|
|
sid = str(session_id or "")
|
|
|
|
|
|
with _LOCK:
|
2026-07-09 17:54:06 +00:00
|
|
|
|
if expected is not None and _TERMINALS.get(sid) is not expected:
|
|
|
|
|
|
return False
|
2026-04-26 14:47:10 +00:00
|
|
|
|
term = _TERMINALS.pop(sid, None)
|
|
|
|
|
|
if not term:
|
|
|
|
|
|
return False
|
fix(terminal): close the reaper's selection→reconnect→close TOCTOU
`_terminals_to_reap()` snapshots victims under `_LOCK` and then releases it,
but `subscribe()` clears `unwatched_since` and appends to `_subscribers` under
the terminal's own `_sub_lock`, which the selection pass never held. A viewer
reconnecting in that gap left object identity — all `close_terminal(expected=…)`
ever checked — still true, so the reaper killed a terminal that now had a live
viewer.
- `_claim_reap_victim()` re-establishes the *whole* selection predicate while
holding `_LOCK` and the terminal's `_sub_lock`, and removes the registry
entry in that same critical section: same object, no subscribers, and
`unwatched_since` still set and still expired. A dead process is still
reaped unconditionally — it cannot come back, and a viewer watching a corpse
must not pin the entry forever.
- `attach_terminal()` does the registry lookup and the subscribe atomically
under `_LOCK`, replacing `get_terminal()` + `term.subscribe()` in the SSE
route. It takes the same two locks in the same order as the claim, so
exactly one side wins: the viewer attaches (and the reap is refused), or the
entry is already gone (and the route answers "terminal not running" before
committing a 200, instead of streaming from a corpse). Registration is the
authority — a registered-but-`closed` terminal still attaches so the viewer
receives `terminal_closed`.
- Teardown moved to `_teardown_terminal()` and runs after both locks are
released; `killpg`+`wait` can block ~2.5s and must not pin the registry.
- Dropped the duplicated `self.last_activity = time.time()` the rebase left in
`put_output()`.
Tests: a barrier regression that pauses the reaper after selection, attaches a
viewer through the production path, resumes, and asserts the terminal stays
registered/live and still receives output; the inverse where the reap claims
first and the attach reports not-running; refreshed-grace and replaced-entry
claims; dead-with-viewer; and a probe that `_LOCK` is free during teardown.
Three of them fail against an identity-only claim.
2026-07-24 22:30:05 +00:00
|
|
|
|
_teardown_terminal(term)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _teardown_terminal(term: TerminalSession) -> None:
|
|
|
|
|
|
"""Kill the shell, close the pty master fd and reap descendants.
|
|
|
|
|
|
|
|
|
|
|
|
Split out of ``close_terminal`` so a caller that has already claimed the
|
|
|
|
|
|
registry entry (the idle reaper) can run the teardown without re-entering
|
|
|
|
|
|
the registry lookup — and, importantly, without holding any lock while
|
|
|
|
|
|
``killpg``/``wait`` block for up to ~2.5s.
|
|
|
|
|
|
"""
|
2026-04-26 14:47:10 +00:00
|
|
|
|
term.closed.set()
|
|
|
|
|
|
try:
|
|
|
|
|
|
if term.proc.poll() is None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.killpg(term.proc.pid, signal.SIGHUP)
|
|
|
|
|
|
except ProcessLookupError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
term.proc.wait(timeout=1.5)
|
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.killpg(term.proc.pid, signal.SIGKILL)
|
|
|
|
|
|
except ProcessLookupError:
|
|
|
|
|
|
pass
|
2026-05-19 11:57:07 +00:00
|
|
|
|
try:
|
|
|
|
|
|
term.proc.wait(timeout=1.0)
|
|
|
|
|
|
except (subprocess.TimeoutExpired, ProcessLookupError):
|
|
|
|
|
|
pass
|
2026-04-26 14:47:10 +00:00
|
|
|
|
finally:
|
2026-07-09 17:54:06 +00:00
|
|
|
|
# ``closed`` is already set above, so a writer/resizer blocked on io_lock
|
|
|
|
|
|
# will see it and bail rather than touch the fd we are about to close.
|
|
|
|
|
|
with term.io_lock:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.close(term.master_fd)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
2026-06-07 01:04:16 +00:00
|
|
|
|
_reap_terminal_descendants(term.proc.pid)
|
2026-05-19 11:57:07 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def close_all_terminals() -> None:
|
|
|
|
|
|
"""Best-effort reap of embedded shells during graceful WebUI shutdown."""
|
|
|
|
|
|
with _LOCK:
|
|
|
|
|
|
session_ids = list(_TERMINALS)
|
|
|
|
|
|
for session_id in session_ids:
|
|
|
|
|
|
close_terminal(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
atexit.register(close_all_terminals)
|