Coverage for src / lilbee / splash.py: 100%
69 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""Splash animation lifecycle — starts and stops the animation subprocess.
3The animation itself lives in ``_splash_runner.py`` (stdlib-only, zero lilbee
4imports). This module manages the subprocess, pipe-based IPC, and cleanup.
6IPC uses an OS pipe: parent holds the write end, child polls the read end.
7When the parent closes the write end (or dies), the child sees EOF and exits.
8This guarantees no orphan processes — the OS closes the pipe on parent death.
9"""
11from __future__ import annotations
13import atexit
14import contextlib
15import os
16import subprocess
17import sys
18from dataclasses import dataclass
20_SPLASH_FD_ENV = "_LILBEE_SPLASH_FD"
22_SHOW_CURSOR = "\033[?25h"
24_STOP_TIMEOUT = 3.0
27@dataclass
28class SplashHandle:
29 """Opaque handle returned by ``start()`` for use with ``stop()``."""
31 process: subprocess.Popen[bytes]
32 write_fd: int
35_active_handle: SplashHandle | None = None
38def _should_skip() -> bool:
39 """Return True when the splash animation should be suppressed."""
40 if not os.isatty(2):
41 return True
42 return bool(os.environ.get("LILBEE_NO_SPLASH", ""))
45def start() -> SplashHandle | None:
46 """Launch the splash animation subprocess.
47 Returns a handle for ``stop()``, or None if the splash was skipped.
48 The caller must eventually call ``stop(handle)`` to clean up.
49 """
50 global _active_handle
52 if _should_skip():
53 return None
55 read_fd, write_fd = os.pipe()
56 os.set_inheritable(read_fd, True)
58 # Trusted: sys.executable is this interpreter, module path is static,
59 # the one runtime value (read_fd) is an int from os.pipe().
60 proc = subprocess.Popen( # noqa: S603
61 [sys.executable, "-m", "lilbee._splash_runner", str(read_fd)],
62 close_fds=False,
63 stderr=None,
64 stdout=subprocess.DEVNULL,
65 stdin=subprocess.DEVNULL,
66 )
68 os.close(read_fd)
70 os.environ[_SPLASH_FD_ENV] = str(write_fd)
72 handle = SplashHandle(process=proc, write_fd=write_fd)
73 _active_handle = handle
75 atexit.register(_atexit_cleanup)
77 return handle
80def stop(handle: SplashHandle | None) -> None:
81 """Stop the splash animation and wait for the subprocess to exit."""
82 global _active_handle
84 if handle is None:
85 return
87 _close_write_fd(handle.write_fd)
89 try:
90 handle.process.wait(timeout=_STOP_TIMEOUT)
91 except subprocess.TimeoutExpired:
92 handle.process.kill()
93 handle.process.wait(timeout=1.0)
95 os.environ.pop(_SPLASH_FD_ENV, None)
97 _active_handle = None
99 _restore_cursor()
102def dismiss() -> None:
103 """Signal the splash to stop from the TUI side.
104 Called by the chat screen's ``on_show()`` to dismiss the splash once
105 the TUI is ready to paint. Closes the pipe so the subprocess sees EOF,
106 waits for it to exit, and clears the active handle so ``atexit`` does
107 not re-run ``stop()`` (which would write ``\\033[?25h`` into the
108 Textual alt-screen and leave a cursor artifact at (0,0)).
109 """
110 global _active_handle
112 fd_str = os.environ.pop(_SPLASH_FD_ENV, None)
113 if fd_str is not None:
114 _close_write_fd(int(fd_str))
116 handle = _active_handle
117 if handle is None:
118 return
119 _active_handle = None
121 # Close the write end so the subprocess sees EOF. This may double-close
122 # the same fd that the env-var path already closed; _close_write_fd
123 # suppresses OSError so that is harmless.
124 # No _restore_cursor() here: we are inside Textual's alt-screen,
125 # where writing cursor-show would produce a visible artifact.
126 _close_write_fd(handle.write_fd)
127 try:
128 handle.process.wait(timeout=_STOP_TIMEOUT)
129 except subprocess.TimeoutExpired:
130 handle.process.kill()
131 handle.process.wait(timeout=1.0)
134def _close_write_fd(fd: int) -> None:
135 """Close a pipe write fd, ignoring errors if already closed."""
136 with contextlib.suppress(OSError):
137 os.close(fd)
140def _restore_cursor() -> None:
141 """Belt-and-suspenders cursor restore on stderr."""
142 try:
143 sys.stderr.write(_SHOW_CURSOR)
144 sys.stderr.flush()
145 except OSError:
146 pass
149def _atexit_cleanup() -> None:
150 """Last-resort cleanup if stop() was never called."""
151 if _active_handle is not None:
152 stop(_active_handle)