fix(template): restore tty state before snapshot prompt to fix ^M issue

pypsrp alters terminal tty settings during WinRM sessions. This causes
readline() at the snapshot prompt to receive \r (not \n) on Enter,
printing ^M repeatedly without ever unblocking.

Fix: save termios attributes at command start (before any WinRM work,
terminal is clean) and restore them with tcsetattr(TCSADRAIN) before
the interactive snapshot prompt. Gracefully skips save/restore when
stdin is not a tty (tests, pipes, CI).

Revert the pre-flight snapshot check approach (commit f6f8ffc) — checking
at start was architecturally wrong (snapshot state may change during
provisioning). The prompt stays at the end where it belongs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 02:19:05 +02:00
parent f6f8ffc3d2
commit d5f362b8dc
+36 -20
View File
@@ -432,26 +432,21 @@ def template_prepare_win(
if not build_password: if not build_password:
build_password = click.prompt(f"New {build_username} password", hide_input=True) build_password = click.prompt(f"New {build_username} password", hide_input=True)
# --- Pre-flight snapshot conflict check (terminal still clean here) --- # Save tty state now (terminal clean) so we can restore it before any
# Must happen before any WinRM/pypsrp calls, which alter tty state and # interactive prompt at the end — pypsrp alters tty settings during
# prevent readline() from receiving \n on Enter. # WinRM sessions, causing readline() to receive \r instead of \n.
_tty_fd = sys.stdin.fileno() if sys.stdin.isatty() else -1
_saved_tty_attrs: list[object] = []
if _tty_fd >= 0: # pragma: no cover
try:
import termios
_saved_tty_attrs.append(termios.tcgetattr(_tty_fd))
except Exception:
pass
config = load_config() config = load_config()
backend = WorkstationVmrunBackend(vmrun_path=config.vmrun_path) backend = WorkstationVmrunBackend(vmrun_path=config.vmrun_path)
handle = VmHandle(identifier=str(vmx_path), name=vmx_path.stem) handle = VmHandle(identifier=str(vmx_path), name=vmx_path.stem)
existing_snapshots = backend.list_snapshots(handle)
do_recreate_snapshot = True
if snapshot in existing_snapshots:
click.echo(f"[prepare-win] Snapshot '{snapshot}' already exists.")
if recreate_snapshot:
click.echo(f"[prepare-win] --recreate-snapshot set: will delete and recreate.")
else:
sys.stdout.write(f"[prepare-win] Delete and recreate '{snapshot}'? [y/N] ")
sys.stdout.flush()
raw = sys.stdin.readline()
do_recreate_snapshot = raw.strip().lower() in ("y", "yes")
if not do_recreate_snapshot:
click.echo("[prepare-win] Keeping existing snapshot. Provisioning aborted.")
return
if not backend.is_running(handle): if not backend.is_running(handle):
click.echo(f"[prepare-win] Starting VM: {vmx_path.name} ...") click.echo(f"[prepare-win] Starting VM: {vmx_path.name} ...")
@@ -680,10 +675,31 @@ Register-ScheduledTask -TaskName 'CI-StaticIp' `
raise click.ClickException("VM did not power off within 120s.") raise click.ClickException("VM did not power off within 120s.")
click.echo("[prepare-win] VM powered off.") click.echo("[prepare-win] VM powered off.")
# Create snapshot — conflict already resolved at pre-flight above. # Restore tty state before interactive prompt — pypsrp alters terminal
# settings and readline() would receive \r instead of \n on Enter.
if _saved_tty_attrs and _tty_fd >= 0: # pragma: no cover
try:
import termios
termios.tcsetattr(_tty_fd, termios.TCSADRAIN, _saved_tty_attrs[0]) # type: ignore[arg-type]
except Exception:
pass
# Create snapshot
vmrun_exe = backend.vmrun_path vmrun_exe = backend.vmrun_path
if snapshot in backend.list_snapshots(handle): existing = backend.list_snapshots(handle)
click.echo(f"[prepare-win] Deleting existing snapshot '{snapshot}' ...") if snapshot in existing:
click.echo(f"[prepare-win] Snapshot '{snapshot}' already exists.")
if recreate_snapshot:
do_recreate = True
else:
sys.stdout.write(f"[prepare-win] Delete and recreate '{snapshot}'? [y/N] ")
sys.stdout.flush()
raw = sys.stdin.readline()
do_recreate = raw.strip().lower() in ("y", "yes")
if not do_recreate:
click.echo("[prepare-win] Keeping existing snapshot. Provisioning complete.")
return
click.echo(f"[prepare-win] Deleting snapshot '{snapshot}' ...")
subprocess.run( subprocess.run(
[vmrun_exe, "-T", "ws", "deleteSnapshot", str(vmx_path), snapshot], [vmrun_exe, "-T", "ws", "deleteSnapshot", str(vmx_path), snapshot],
check=True, check=True,