fix: harden legacy/tui3 non-tty flows and shutdown stability

This commit is contained in:
thePR0M3TH3AN committed 2026-03-03 21:56:55 -05:00
1 parent d3fc572c4e
commit 033870f2df
9 files changed
+112 -4

No files matched your search

+38 -1
View File
@@ -1709,6 +1709,15 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
print(colored(f"Error: Failed to restore backup: {e}", "red"))
return 1
elif args.command is None and not args.legacy_tui:
if not sys.stdin.isatty():
print(
colored(
"Error: Interactive startup requires a TTY. "
"Run in a terminal or use non-interactive subcommands.",
"red",
)
)
return 1
print("Startup Options:")
print("1. Continue")
print("2. Restore from backup")
@@ -1895,5 +1904,33 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
return 0
def _exit_as_main_process() -> "NoReturn":
"""Execute ``main`` and terminate the process without Python finalizers.
In some runtime builds with native extensions, interpreter teardown can
intermittently raise SIGSEGV after the app has already completed a normal
shutdown path. Using ``os._exit`` here avoids that nondeterministic
interpreter-finalization crash while preserving the intended exit status.
"""
exit_code = 0
try:
exit_code = int(main())
except SystemExit as exc:
code = exc.code
if isinstance(code, int):
exit_code = code
elif code is None:
exit_code = 0
else:
exit_code = 1
finally:
try:
sys.stdout.flush()
sys.stderr.flush()
except Exception:
pass
os._exit(exit_code)
if __name__ == "__main__":
sys.exit(main())
_exit_as_main_process()
+14 -1
View File
@@ -302,8 +302,21 @@ def tui2(
@app.command("tui3")
def tui3(ctx: typer.Context) -> None:
def tui3(
ctx: typer.Context,
check: bool = typer.Option(
False,
"--check",
help="Check whether TUI v3 runtime dependencies are available",
),
) -> None:
"""Launch the experimental TUI v3 scratch architecture."""
if check:
from seedpass.tui_v3 import check_tui3_runtime
typer.echo(json.dumps(check_tui3_runtime(), indent=2, sort_keys=True))
return
fingerprint = (ctx.obj or {}).get("fingerprint")
services, preflight_error = _prime_tui2_service(ctx)
if preflight_error is not None:
+19 -2
View File
@@ -63,6 +63,7 @@ from utils.password_prompt import (
prompt_existing_password,
prompt_new_password,
confirm_action,
PasswordPromptError,
)
from utils import masked_input, prompt_seed_words
from utils.memory_protection import InMemorySecret
@@ -731,6 +732,10 @@ class PasswordManager:
)
print(colored("Q. Exit", "cyan"))
if not sys.stdin.isatty():
raise SeedPassError(
"Interactive seed profile selection requires a TTY"
)
choice = input("Select a seed profile by number: ").strip()
if choice.lower() in {"q", "quit", "exit"}:
raise SeedPassError("Operation cancelled by user")
@@ -766,7 +771,14 @@ class PasswordManager:
current = self.fingerprint_manager.current_fingerprint
except Exception as e:
logger.error(f"Error during seed profile selection: {e}", exc_info=True)
is_interactive_err = isinstance(e, (EOFError, PasswordPromptError)) or (
isinstance(e, SeedPassError) and "requires a TTY" in str(e)
)
logger.error(
"Error during seed profile selection: %s",
e,
exc_info=not is_interactive_err,
)
print(colored(f"Error: Failed to select seed profile: {e}", "red"))
raise SeedPassError(f"Failed to select seed profile: {e}") from e
@@ -1094,7 +1106,12 @@ class PasswordManager:
except KeyboardInterrupt:
raise
except Exception as e:
logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True)
is_interactive_err = isinstance(e, (EOFError, PasswordPromptError))
logger.error(
"Failed to set up EncryptionManager: %s",
e,
exc_info=not is_interactive_err,
)
print(colored(f"Error: Failed to set up encryption: {e}", "red"))
if exit_on_fail:
raise SeedPassError(f"Failed to set up encryption: {e}") from e
+10
View File
@@ -28,6 +28,16 @@ def test_prompt_existing_password(monkeypatch):
assert password_prompt.prompt_existing_password() == "mypassword"
def test_prompt_existing_password_non_tty(monkeypatch):
monkeypatch.setattr(
password_prompt,
"masked_input",
lambda _prompt: (_ for _ in ()).throw(EOFError("no tty")),
)
with pytest.raises(password_prompt.PasswordPromptError, match="requires a TTY"):
password_prompt.prompt_existing_password()
def test_confirm_action_yes_no(monkeypatch):
monkeypatch.setattr(builtins, "input", lambda _: "Y")
assert password_prompt.confirm_action()
+10
View File
@@ -5,6 +5,7 @@ from utils import seed_prompt
def test_masked_input_posix_backspace(monkeypatch, capsys):
seq = iter(["a", "b", "\x7f", "c", "\n"])
monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(seed_prompt.sys.stdin, "read", lambda n=1: next(seq))
monkeypatch.setattr(seed_prompt.sys.stdin, "fileno", lambda: 0)
@@ -36,6 +37,7 @@ def test_masked_input_posix_backspace(monkeypatch, capsys):
def test_masked_input_windows_space(monkeypatch, capsys):
seq = iter(["x", "y", " ", "z", "\r"])
monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: True)
fake_msvcrt = types.SimpleNamespace(getwch=lambda: next(seq))
monkeypatch.setattr(seed_prompt, "msvcrt", fake_msvcrt)
monkeypatch.setattr(seed_prompt.sys, "platform", "win32", raising=False)
@@ -49,6 +51,7 @@ def test_masked_input_windows_space(monkeypatch, capsys):
def test_masked_input_posix_ctrl_c(monkeypatch):
seq = iter(["\x03"])
monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(seed_prompt.sys.stdin, "read", lambda n=1: next(seq))
monkeypatch.setattr(seed_prompt.sys.stdin, "fileno", lambda: 0)
@@ -70,6 +73,7 @@ def test_masked_input_posix_ctrl_c(monkeypatch):
def test_masked_input_windows_ctrl_c(monkeypatch):
seq = iter(["\x03"])
monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: True)
fake_msvcrt = types.SimpleNamespace(getwch=lambda: next(seq))
monkeypatch.setattr(seed_prompt, "msvcrt", fake_msvcrt)
monkeypatch.setattr(seed_prompt.sys, "platform", "win32", raising=False)
@@ -93,6 +97,12 @@ def test_prompt_seed_words_valid(monkeypatch):
assert result == phrase
def test_masked_input_requires_tty(monkeypatch):
monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: False)
with pytest.raises(EOFError, match="requires a TTY"):
seed_prompt.masked_input("Enter: ")
def test_prompt_seed_words_invalid_word(monkeypatch):
from mnemonic import Mnemonic
+12
View File
@@ -1092,6 +1092,18 @@ def test_tui2_check(monkeypatch):
assert payload["textual_available"] is True
def test_tui3_check(monkeypatch):
monkeypatch.setattr(
cli,
"check_tui2_runtime",
lambda: {"status": "ok", "backend": "textual", "textual_available": True},
)
result = runner.invoke(app, ["tui3", "--check"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert "textual_available" in payload
def test_tui2_unavailable_without_fallback(monkeypatch):
monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object())
monkeypatch.setattr(cli, "launch_tui2", lambda **_: False)
+4
View File
@@ -142,6 +142,8 @@ def prompt_new_password(max_retries: int | None = None) -> str:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Password prompt interrupted by user.")
raise PasswordPromptError("Operation cancelled by user")
except EOFError:
raise PasswordPromptError("Interactive password prompt requires a TTY")
except Exception as e:
logging.error(
f"Unexpected error during password prompt: {e}", exc_info=True
@@ -204,6 +206,8 @@ def prompt_existing_password(
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Existing password prompt interrupted by user.")
raise PasswordPromptError("Operation cancelled by user")
except EOFError:
raise PasswordPromptError("Interactive password prompt requires a TTY")
except Exception as e:
logging.error(
f"Unexpected error during existing password prompt: {e}",
+2
View File
@@ -106,6 +106,8 @@ def _masked_input_posix(prompt: str) -> str:
def masked_input(prompt: str) -> str:
"""Return input from the user while masking typed characters."""
if not sys.stdin.isatty():
raise EOFError("Interactive input requires a TTY")
func = _masked_input_windows if sys.platform == "win32" else _masked_input_posix
pause_console_logging()
try: