From d5f362b8dc53f3cb9fc73addf53249d6a385d8f8 Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 24 May 2026 02:19:05 +0200 Subject: [PATCH] fix(template): restore tty state before snapshot prompt to fix ^M issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ci_orchestrator/commands/template.py | 56 +++++++++++++++--------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py index 59c35ae..d714c37 100644 --- a/src/ci_orchestrator/commands/template.py +++ b/src/ci_orchestrator/commands/template.py @@ -432,26 +432,21 @@ def template_prepare_win( if not build_password: build_password = click.prompt(f"New {build_username} password", hide_input=True) - # --- Pre-flight snapshot conflict check (terminal still clean here) --- - # Must happen before any WinRM/pypsrp calls, which alter tty state and - # prevent readline() from receiving \n on Enter. + # Save tty state now (terminal clean) so we can restore it before any + # interactive prompt at the end — pypsrp alters tty settings during + # 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() backend = WorkstationVmrunBackend(vmrun_path=config.vmrun_path) 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): 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.") 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 - if snapshot in backend.list_snapshots(handle): - click.echo(f"[prepare-win] Deleting existing snapshot '{snapshot}' ...") + existing = backend.list_snapshots(handle) + 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( [vmrun_exe, "-T", "ws", "deleteSnapshot", str(vmx_path), snapshot], check=True,