test(install-e2e/windows): the first chat turn exits under the pseudoconsole
Every Windows leg of every scheduled run since 2026-09-25 was cancelled at
the 60-minute job timeout in its Install step. The user-state phase runs
`hermes chat -q "..."` under pty-run.py (a ConPTY, so the CLI sees a real
TTY). Since a5c7eed (shipped in v2026.9.21) `-q` on a TTY seeds an
interactive session and only answers-and-exits with --oneshot; the turn
answered ("Hello from the mock inference server!") and then sat at the
prompt. pty-run.py's --timeout 900 never fired either: it checked the
deadline only between blocking PtyProcess.read() calls, and an idle prompt
never returns from read().
- windows-e2e.ps1 passes --oneshot when `chat --help` advertises it (older
tags have no flag and exit after -q on their own), lowers the turn budget
to 300s, and names a 124 as "never exited" instead of a generic failure.
- pty-run.py drains the pty on a daemon thread and waits on the queue with
the deadline, so --timeout holds whatever the child does, and writes a
TIMEOUT line into the captured log before killing it.
This commit is contained in:
@@ -24,41 +24,69 @@ Exits with the child's exit code (124 if the timeout killed it).
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def _run_under_pty(argv: list[str], out_path: str, cwd: str | None, cols: int, rows: int,
|
||||
timeout: float | None) -> int | None:
|
||||
"""Return the child's exit code, or None when pywinpty is unavailable."""
|
||||
"""Return the child's exit code, or None when pywinpty is unavailable.
|
||||
|
||||
``PtyProcess.read`` blocks until the child writes, so the deadline cannot be
|
||||
checked between reads: a child that sits idle (an interactive prompt waiting
|
||||
for input) would never return control and the step would hang until the
|
||||
job's own timeout cancelled it with no diagnosis. A daemon thread drains the
|
||||
pty into a queue instead, and this thread waits on the queue with the
|
||||
deadline, so ``--timeout`` holds no matter what the child does.
|
||||
"""
|
||||
try:
|
||||
from winpty import PtyProcess
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
proc = PtyProcess.spawn(argv, cwd=cwd, dimensions=(rows, cols))
|
||||
chunks: queue.Queue[str | None] = queue.Queue()
|
||||
|
||||
def _drain() -> None:
|
||||
try:
|
||||
while True:
|
||||
chunk = proc.read(4096)
|
||||
if not chunk and not proc.isalive():
|
||||
break
|
||||
if chunk:
|
||||
chunks.put(chunk)
|
||||
except EOFError:
|
||||
pass
|
||||
finally:
|
||||
chunks.put(None)
|
||||
|
||||
threading.Thread(target=_drain, name="pty-run-drain", daemon=True).start()
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
timed_out = False
|
||||
with open(out_path, "w", encoding="utf-8", errors="replace") as log:
|
||||
while True:
|
||||
if deadline is not None and time.monotonic() > deadline:
|
||||
timed_out = True
|
||||
proc.terminate(force=True)
|
||||
break
|
||||
wait = None if deadline is None else max(0.0, deadline - time.monotonic())
|
||||
try:
|
||||
chunk = proc.read(4096)
|
||||
except EOFError:
|
||||
chunk = chunks.get(timeout=wait)
|
||||
except queue.Empty:
|
||||
timed_out = True
|
||||
break
|
||||
if not chunk:
|
||||
if chunk is None:
|
||||
break
|
||||
log.write(chunk)
|
||||
log.flush()
|
||||
sys.stdout.write(chunk)
|
||||
sys.stdout.flush()
|
||||
if not proc.isalive():
|
||||
break
|
||||
if timed_out:
|
||||
note = (f"\npty-run: TIMEOUT after {timeout:g}s: the child was still running and had "
|
||||
"stopped writing (waiting for input?); killing it.\n")
|
||||
log.write(note)
|
||||
sys.stdout.write(note)
|
||||
sys.stdout.flush()
|
||||
proc.terminate(force=True)
|
||||
proc.wait()
|
||||
if timed_out:
|
||||
return 124
|
||||
|
||||
@@ -1220,15 +1220,24 @@ function Invoke-UserStateActions {
|
||||
if (-not ($chatHelp -match '(^|\s)-q(\s|,|$)' -or $chatHelp -match '--quiet')) {
|
||||
throw 'the installed CLI has no one-shot chat flag; this leg cannot produce a session through the user path'
|
||||
}
|
||||
# Since a5c7eed (v2026.9.21+) `-q` on a real TTY seeds an INTERACTIVE session and
|
||||
# only answers-and-exits with --oneshot (or on non-TTY stdio). The pseudoconsole
|
||||
# below IS a TTY, so without --oneshot the turn answers and then sits at the
|
||||
# prompt forever. Older tags have no --oneshot and exit after -q on their own.
|
||||
$oneshot = @()
|
||||
if ($chatHelp -match '--oneshot') { $oneshot = @('--oneshot') }
|
||||
$before = Get-UserStateSessionCount
|
||||
$log = Join-Path $WorkRoot 'logs\user-state-chat.log'
|
||||
# A released tag prints through prompt_toolkit, whose Windows output object needs
|
||||
# a console screen buffer: piping the CLI's stdout into the log takes that away
|
||||
# and the turn dies with NoConsoleScreenBufferError. Run it under a real
|
||||
# pseudoconsole (pty-run.py) and keep the capture.
|
||||
& python -B (Join-Path $AssetsDir 'pty-run.py') --out $log --timeout 900 -- $hermes chat -q "Reply with the single word: ok"
|
||||
& python -B (Join-Path $AssetsDir 'pty-run.py') --out $log --timeout 300 -- $hermes chat -q "Reply with the single word: ok" @oneshot
|
||||
$chatExit = $LASTEXITCODE
|
||||
Write-LogGroup 'first real chat turn' $log
|
||||
if ($chatExit -eq 124) {
|
||||
throw "the first chat turn never exited (still running after 300s; flags: chat -q $($oneshot -join ' ')); see $log"
|
||||
}
|
||||
if ($chatExit -ne 0) { throw "the first chat turn failed (exit $chatExit); see $log" }
|
||||
$after = Get-UserStateSessionCount
|
||||
# A fresh install has no state.db until the first turn: -1 means "no
|
||||
|
||||
Reference in New Issue
Block a user