-
Notifications
You must be signed in to change notification settings - Fork 0
Rectify: CLI Startup Update Prompt — Terminal Freeze Immunity #783
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b931f88
d6a833d
685329e
cffb0f0
059b37b
a0e1ec8
9738abd
cb82a94
9607e81
69e5381
4bba434
c6d7cd0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| """Timed prompt primitive with TTY guard, ANSI formatting, and timeout. | ||
|
|
||
| Every user-facing ``input()`` in the CLI layer must go through | ||
| ``timed_prompt()`` rather than calling ``input()`` directly. A structural | ||
| test (``test_input_tty_contracts.py``) enforces this invariant. | ||
|
|
||
| ``status_line()`` is the "pre-flight feedback" primitive — it prints a | ||
| single status message before any blocking I/O so the user always sees | ||
| output immediately. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import select | ||
| import sys | ||
|
|
||
| from autoskillit.cli._ansi import supports_color | ||
| from autoskillit.cli._init_helpers import _require_interactive_stdin | ||
|
|
||
|
|
||
| def timed_prompt( | ||
| text: str, | ||
| *, | ||
| default: str = "", | ||
| timeout: int = 30, | ||
| label: str = "prompt", | ||
| ) -> str: | ||
| """Display a formatted prompt with a bounded wait. | ||
|
|
||
| Composes three invariants into one call: | ||
|
|
||
| 1. **TTY guard** — calls ``_require_interactive_stdin(label)``. | ||
| 2. **ANSI formatting** — bold prompt, dim timeout hint. | ||
| 3. **Timeout** — ``select.select`` bounds the wait; returns *default* | ||
| on expiry instead of blocking forever. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| text | ||
| The prompt text shown to the user (plain text — ANSI is applied | ||
| automatically based on ``supports_color()``). | ||
| default | ||
| Value returned when the user does not respond within *timeout* | ||
| seconds, or on ``EOFError``. | ||
| timeout | ||
| Maximum seconds to wait for input. ``0`` disables the timeout | ||
| (waits indefinitely — use only for prompts the user explicitly | ||
| initiated). | ||
| label | ||
| Human-readable label for the TTY-guard error message. | ||
| """ | ||
| _require_interactive_stdin(label) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [warning] defense: TTY guard fires unconditionally, including when timeout=0 (documented as "user explicitly initiated"). A user-initiated prompt could legitimately come from piped input. Should timeout=0 callers bypass the guard?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Investigated — this is intentional. The docstring documents timeout=0 as 'use only for prompts the user explicitly initiated', presupposing a TTY. The module-level invariant is that ALL timed_prompt calls require an interactive terminal. Zero call sites in the codebase use timeout=0, making this a theoretical concern about an untested path. The TTY guard is coherent with the design contract. |
||
|
|
||
| color = supports_color() | ||
| _B = "\x1b[1m" if color else "" | ||
| _D = "\x1b[2m" if color else "" | ||
| _R = "\x1b[0m" if color else "" | ||
|
|
||
| hint = f" {_D}(auto-continues in {timeout}s){_R}" if timeout else "" | ||
| formatted = f"{_B}{text}{_R}{hint} " | ||
| print(formatted, end="", flush=True) | ||
|
|
||
| if timeout: | ||
| try: | ||
| ready, _, _ = select.select([sys.stdin], [], [], timeout) | ||
| except (OSError, TypeError, ValueError): | ||
| # stdin lacks fileno() or fileno() returns non-int | ||
| # (e.g. pytest capture, piped input) — fall back to blocking input(). | ||
| ready = [sys.stdin] | ||
| if not ready: | ||
| print(f"\n{_D}(timed out, continuing...){_R}", flush=True) | ||
| return default | ||
|
|
||
| try: | ||
| return input("").strip() | ||
| except EOFError: | ||
| return default | ||
|
|
||
|
|
||
| def status_line(message: str) -> None: | ||
| """Print a single-line status message with appropriate formatting. | ||
|
|
||
| This is the "pre-flight feedback" primitive: call it before any | ||
| blocking I/O so the terminal is never silent. | ||
| """ | ||
| color = supports_color() | ||
| _D = "\x1b[2m" if color else "" | ||
| _R = "\x1b[0m" if color else "" | ||
| print(f"{_D}{message}{_R}", flush=True) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,7 +45,6 @@ | |
| _DISMISS_FILE = "update_check.json" | ||
| _STABLE_DISMISS_WINDOW = timedelta(days=7) | ||
| _DEV_DISMISS_WINDOW = timedelta(hours=12) | ||
| _SNOOZE_WINDOW = timedelta(hours=1) | ||
|
|
||
| # Disposable on-disk cache for GitHub API GETs. This is intentionally a plain | ||
| # JSON dict (not ``write_versioned_json``) — it is a transient performance | ||
|
|
@@ -639,7 +638,7 @@ def run_update_checks(home: Path | None = None) -> None: | |
| InstallType.UNKNOWN, | ||
| InstallType.LOCAL_PATH, | ||
| InstallType.LOCAL_EDITABLE, | ||
| ): | ||
| ) and not os.environ.get("AUTOSKILLIT_FORCE_UPDATE_CHECK"): | ||
| return | ||
|
|
||
| import autoskillit as _pkg | ||
|
|
@@ -649,6 +648,11 @@ def run_update_checks(home: Path | None = None) -> None: | |
| window = dismissal_window(info) | ||
| state = _read_dismiss_state(_home) | ||
|
|
||
| # Pre-flight feedback: ensure the terminal is never silent during network I/O | ||
| from autoskillit.cli._timed_input import status_line | ||
|
|
||
| status_line("Checking for updates...") | ||
|
|
||
| # Gather signals | ||
| raw_signals: list[Signal | None] = [ | ||
| _binary_signal(info, _home, current), | ||
|
|
@@ -673,12 +677,19 @@ def run_update_checks(home: Path | None = None) -> None: | |
| return | ||
|
|
||
| # Consolidated prompt | ||
| from autoskillit.cli._timed_input import timed_prompt | ||
|
|
||
| _TIMEOUT_SENTINEL = "__timeout__" | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [warning] cohesion: _TIMEOUT_SENTINEL string sentinel is asymmetric with all other timed_prompt call sites that treat the returned default as a valid answer. If timeout-vs-empty-input distinction is needed here, consider adding a return-type flag to timed_prompt itself rather than leaking sentinel detection into callers.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Investigated — this is intentional. The _TIMEOUT_SENTINEL implements a deliberate 3-way branch: (1) timeout → sentinel returned → early return WITHOUT writing dismissal, so the prompt reappears next invocation; (2) empty/y/yes → update runs; (3) n/no → dismissal record written. Simplifying to default='' would conflate timeout with user pressing Enter (the Y path), triggering an unwanted update. Commit 9b7dbb5 explicitly documents this contract. |
||
| bullet_lines = "\n".join(f" - {s.message}" for s in firing_signals) | ||
| prompt_text = f"\nAutoSkillit has updates available:\n{bullet_lines}\nUpdate now? [Y/n] " | ||
| print(prompt_text, end="", flush=True) | ||
| answer = input("").strip().lower() | ||
| prompt_text = f"\nAutoSkillit has updates available:\n{bullet_lines}\nUpdate now? [Y/n]" | ||
| answer = timed_prompt(prompt_text, default=_TIMEOUT_SENTINEL, timeout=30, label="update check") | ||
|
|
||
| # On timeout: proceed to app() without writing a dismissal record so the | ||
| # prompt reappears on the next invocation. | ||
| if answer == _TIMEOUT_SENTINEL: | ||
| return | ||
|
|
||
| if answer in ("", "y", "yes"): | ||
| if answer.lower() in ("", "y", "yes"): | ||
| _run_update_sequence(info, current, _home, state, _skip_env) | ||
| return | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[critical] arch: _timed_input.py imports _require_interactive_stdin from _init_helpers.py at module level, while _init_helpers.py lazily imports timed_prompt from _timed_input.py. This creates a latent circular dependency. As the foundational prompt primitive, _timed_input.py must not depend on peer CLI modules. Inline the TTY guard or extract _require_interactive_stdin to a shared utility.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Investigated — this is intentional. _init_helpers.py has NO top-level import of _timed_input. All four timed_prompt imports are strictly inside function bodies (lazy): _prompt_recipe_choice, _prompt_test_command, _prompt_github_repo, _check_secret_scanning. Python's import machinery resolves these lazily only when those functions are called, not when _timed_input is first loaded. No circular import cycle exists at module load time.