feat(prepare-win): stream toolchain/WU output to the console live

pypsrp's execute_ps is fully blocking, so Install-CIToolchain / Windows
Update output only appeared at the end behind a "still running (Ns)"
heartbeat. Add WinRmTransport.run_streaming: drive the script via pypsrp's
incremental begin_invoke/poll_invoke, emitting output, Write-Host
(information) and error stream lines as they arrive over the same WinRM
channel.

_run_with_heartbeat now prefers run_streaming (suppressing the CI_EXITCODE
marker line) and falls back to the blocking heartbeat only on an unexpected
pypsrp error — TransportError still propagates so the 3010 reboot handling
is unaffected.
This commit is contained in:
2026-06-09 21:00:18 +02:00
parent 107bff8c8a
commit e0b1c1ddcc
4 changed files with 181 additions and 4 deletions
+18 -4
View File
@@ -367,12 +367,26 @@ def _run_with_heartbeat(
check: bool = True,
heartbeat: int = 30,
) -> WinRmResult:
"""Run *script* via *transport* and print a heartbeat line every *heartbeat* s.
"""Run *script* and show its output live; fall back to a heartbeat line.
pypsrp's execute_ps is fully blocking — no streaming. The heartbeat
thread reassures the operator that the command is still running during
long-running steps (e.g. Windows Update, which can take 20-40 min).
Prefers :meth:`WinRmTransport.run_streaming` so long steps (toolchain
install, Windows Update — 20-40 min) print progress as it happens. The
``CI_EXITCODE:`` marker line is suppressed from the live echo. If streaming
is unavailable/fails, fall back to the blocking run + periodic heartbeat.
"""
def _echo_line(line: str) -> None:
if not line.strip().startswith("CI_EXITCODE:"):
click.echo(line)
try:
return transport.run_streaming(script, _echo_line, check=check)
except TransportError:
# Connection/command errors must propagate — e.g. the VM rebooting
# mid-update is caught upstream and treated as exit 3010.
raise
except Exception as exc: # pypsrp API quirk — degrade to the blocking path
click.echo(f" [WARN] live streaming failed ({exc}); using heartbeat.", err=True)
result_holder: list[WinRmResult] = []
exc_holder: list[BaseException] = []
stop_event = threading.Event()
+68
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import contextlib
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -44,6 +45,19 @@ class WinRmResult:
return self.returncode == 0
def _info_text(record: Any) -> str:
"""Extract the displayed text of a PSRP information (Write-Host) record."""
data = getattr(record, "message_data", None)
return str(data) if data is not None else str(record)
def _drain(seq: list[Any], start: int, emit: Callable[[str], None]) -> int:
"""Emit ``str()`` of every item in ``seq[start:]``; return the new length."""
for item in seq[start:]:
emit(str(item))
return len(seq)
class WinRmTransport:
"""Thin wrapper around :class:`pypsrp.client.Client`.
@@ -180,6 +194,60 @@ class WinRmTransport:
raise TransportCommandError(result.returncode, result.stdout, result.stderr)
return result
def run_streaming(
self, script: str, on_line: Callable[[str], None], *, check: bool = True
) -> WinRmResult:
"""Run *script* and stream its output/host/error lines live via *on_line*.
Unlike :meth:`run` (which buffers and returns only on completion), this
uses pypsrp's incremental ``poll_invoke`` so long-running scripts (the
toolchain install, Windows Update) print progress as it happens.
"""
from pypsrp.complex_objects import PSInvocationState
from pypsrp.powershell import PowerShell, RunspacePool
client = self._connect()
pool = RunspacePool(client.wsman)
pool.open()
out_lines: list[str] = []
err_lines: list[str] = []
def emit_out(s: str) -> None:
out_lines.append(s)
on_line(s)
def emit_err(s: str) -> None:
err_lines.append(s)
on_line(f"[stderr] {s}")
try:
ps = PowerShell(pool)
ps.add_script(script)
ps.begin_invoke()
out_i = info_i = err_i = 0
running = (PSInvocationState.NOT_STARTED, PSInvocationState.RUNNING)
while ps.state in running:
ps.poll_invoke()
out_i = _drain(ps.output, out_i, emit_out)
info_i = _drain(
[_info_text(r) for r in ps.streams.information], info_i, on_line
)
err_i = _drain([str(e) for e in ps.streams.error], err_i, emit_err)
time.sleep(0.5)
ps.end_invoke()
_drain(ps.output, out_i, emit_out)
_drain([str(e) for e in ps.streams.error], err_i, emit_err)
returncode = 1 if ps.streams.error else 0
finally:
with contextlib.suppress(Exception):
pool.close()
result = WinRmResult(
stdout="\n".join(out_lines), stderr="\n".join(err_lines), returncode=returncode
)
if check and not result.ok:
raise TransportCommandError(result.returncode, result.stdout, result.stderr)
return result
def copy(self, local_path: str | Path, remote_path: str) -> None:
"""Upload a file to the guest via PSRP."""
client = self._connect()