fix(prepare-win): detect reboot by boot-time, not TCP-down

A fast reboot never showed TCP/5986 "down" between polls, so the old
down→up wait could hang on "waiting for VM to go down" until timeout.

Replace it: record the guest LastBootUpTime (FileTime ticks) before the
reboot, then poll until the port is back AND the boot time is newer — a
signal immune to reboot speed. Applies to both the 3010 loop and the final
reboot. Drops _wait_tcp_down in favour of _tcp_open + _guest_boot_ticks.
This commit is contained in:
2026-06-09 21:39:36 +02:00
parent 5fe68589f5
commit 54feb43513
2 changed files with 64 additions and 60 deletions
+46 -27
View File
@@ -292,42 +292,61 @@ def _wait_tcp(host: str, port: int, timeout: float) -> bool:
return False
def _wait_tcp_down(host: str, port: int, timeout: float, interval: float = 5.0) -> bool:
"""Return True once TCP *port* on *host* stops accepting connections (host rebooting).
Used after issuing a reboot: a pending Windows Update / CBS install can delay
the actual restart for minutes while the WinRM session is still alive, so we
must observe the port go *down* before waiting for it to come back — otherwise
we'd treat the still-up pre-reboot session as "ready".
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=5.0):
time.sleep(interval)
except OSError:
def _tcp_open(host: str, port: int, timeout: float = 3.0) -> bool:
"""True if *port* on *host* currently accepts a TCP connection."""
try:
with socket.create_connection((host, port), timeout=timeout):
return True
return False
except OSError:
return False
def _guest_boot_ticks(transport: WinRmTransport) -> int | None:
"""Read the guest's last boot time as FileTime ticks; None if unreachable.
Used as a reboot signal that is immune to how fast the box restarts —
comparing boot time before/after a reboot is reliable even when the WinRM
port never appears "down" between polls.
"""
try:
result = transport.run(
"(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToFileTimeUtc()",
check=False,
)
except TransportError:
return None
try:
return int(result.stdout.strip())
except (ValueError, AttributeError):
return None
def _reboot_and_wait(
transport: WinRmTransport, host: str, port: int, winrm_timeout: int
) -> None:
"""Reboot the guest and wait for it to actually go down then come back ready."""
"""Reboot the guest and wait until it comes back with a *newer* boot time.
A TCP "down" probe can miss a fast reboot entirely, so instead we record the
boot time before the reboot and poll until WinRM answers with a later one.
"""
boot_before = _guest_boot_ticks(transport)
with contextlib.suppress(TransportError):
transport.run("Restart-Computer -Force", check=False)
transport.close()
click.echo("[prepare-win] waiting for VM to go down ...")
if _wait_tcp_down(host, port, timeout=300.0):
click.echo("[prepare-win] VM down — waiting for it to come back ...")
else:
click.echo(
"[prepare-win] [WARN] WinRM still reachable after reboot request; "
"proceeding anyway."
)
if not _wait_tcp(host, port, float(winrm_timeout)):
raise click.ClickException(f"TCP/{port} on {host} not reachable after reboot.")
_wait_winrm(transport, winrm_timeout)
click.echo("[prepare-win] waiting for guest to reboot (boot-time change) ...")
deadline = time.monotonic() + float(winrm_timeout) + 300.0
while time.monotonic() < deadline:
time.sleep(5)
if not _tcp_open(host, port):
continue # still down / not back yet
ticks = _guest_boot_ticks(transport)
if ticks is not None and (boot_before is None or ticks > boot_before):
click.echo("[prepare-win] guest rebooted — WinRM back.")
_wait_winrm(transport, winrm_timeout)
return
raise click.ClickException(
f"Guest did not reboot / WinRM not back on {host}:{port} within timeout."
)
def _parse_exit_marker(stdout: str) -> int: