Coverage for src/lilbee/runtime/splash.py: 100%

79 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +0000

1"""Splash animation lifecycle: starts and stops the animation subprocess. 

2 

3The animation itself lives in ``_splash_runner.py`` (stdlib-only, zero lilbee 

4imports). This module manages the subprocess, pipe-based IPC, and cleanup. 

5 

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""" 

10 

11from __future__ import annotations 

12 

13import atexit 

14import contextlib 

15import os 

16import subprocess 

17import sys 

18from dataclasses import dataclass 

19from typing import Any 

20 

21from lilbee.runtime._splash_runner import TAKEOVER_BYTE 

22 

23_SPLASH_FD_ENV = "_LILBEE_SPLASH_FD" 

24 

25_SHOW_CURSOR = "\033[?25h" 

26 

27_STOP_TIMEOUT = 3.0 

28 

29 

30@dataclass 

31class SplashHandle: 

32 """Opaque handle returned by ``start()`` for use with ``stop()``.""" 

33 

34 process: subprocess.Popen[bytes] 

35 write_fd: int 

36 

37 

38_active_handle: SplashHandle | None = None 

39 

40 

41def _should_skip() -> bool: 

42 """Return True when the splash animation should be suppressed.""" 

43 if not os.isatty(2): 

44 return True 

45 return bool(os.environ.get("LILBEE_NO_SPLASH", "")) 

46 

47 

48def _spawn_args(read_fd: int) -> tuple[str, dict[str, Any]]: 

49 """Per-platform argv reference and Popen kwargs for the pipe's read end. 

50 

51 POSIX passes the fd itself; pass_fds keeps only read_fd open in the child 

52 (close_fds=False would leak all open descriptors, including any held by 

53 libraries). subprocess cannot pass fds on Windows, so the child gets the 

54 pipe's OS handle number on argv instead and reopens it as a fd 

55 (msvcrt.open_osfhandle in the runner; inherited handles keep their value 

56 in the child). handle_list restricts inheritance to exactly this handle, 

57 the same no-leak guarantee pass_fds gives on POSIX. 

58 """ 

59 if sys.platform == "win32": # pragma: no cover - Windows-only, covered on the Windows CI leg 

60 import msvcrt 

61 

62 handle = msvcrt.get_osfhandle(read_fd) 

63 os.set_handle_inheritable(handle, True) 

64 startupinfo = subprocess.STARTUPINFO() 

65 startupinfo.lpAttributeList = {"handle_list": [handle]} 

66 return str(handle), {"startupinfo": startupinfo} 

67 os.set_inheritable(read_fd, True) 

68 return str(read_fd), {"pass_fds": (read_fd,)} 

69 

70 

71def start() -> SplashHandle | None: 

72 """Launch the splash animation subprocess. 

73 Returns a handle for ``stop()``, or None if the splash was skipped. 

74 The caller must eventually call ``stop(handle)`` to clean up. 

75 """ 

76 global _active_handle 

77 

78 if _should_skip(): 

79 return None 

80 

81 read_fd, write_fd = os.pipe() 

82 pipe_ref, spawn_kwargs = _spawn_args(read_fd) 

83 

84 # Trusted: sys.executable is this interpreter, module path is static, 

85 # the one runtime value (pipe_ref) derives from an int from os.pipe(). 

86 proc = subprocess.Popen( # noqa: S603 

87 [sys.executable, "-m", "lilbee.runtime._splash_runner", pipe_ref], 

88 stderr=None, 

89 stdout=subprocess.DEVNULL, 

90 stdin=subprocess.DEVNULL, 

91 **spawn_kwargs, 

92 ) 

93 

94 os.close(read_fd) 

95 

96 os.environ[_SPLASH_FD_ENV] = str(write_fd) 

97 

98 handle = SplashHandle(process=proc, write_fd=write_fd) 

99 _active_handle = handle 

100 

101 atexit.register(_atexit_cleanup) 

102 

103 return handle 

104 

105 

106def stop(handle: SplashHandle | None) -> None: 

107 """Stop the splash animation and wait for the subprocess to exit.""" 

108 global _active_handle 

109 

110 if handle is None: 

111 return 

112 

113 _close_write_fd(handle.write_fd) 

114 

115 try: 

116 handle.process.wait(timeout=_STOP_TIMEOUT) 

117 except subprocess.TimeoutExpired: 

118 handle.process.kill() 

119 handle.process.wait(timeout=1.0) 

120 

121 os.environ.pop(_SPLASH_FD_ENV, None) 

122 

123 _active_handle = None 

124 

125 _restore_cursor() 

126 

127 

128def dismiss() -> None: 

129 """Signal the splash to stop from the TUI side. 

130 Called once the TUI is ready to paint. Writes the takeover byte so the 

131 subprocess exits without touching the terminal (its frame clear and 

132 cursor-show would land on the Textual alt-screen and leave a visible 

133 cursor for the whole session), then closes the pipe, waits for the 

134 subprocess, and clears the active handle so ``atexit`` does not re-run 

135 ``stop()``. 

136 """ 

137 global _active_handle 

138 

139 fd_str = os.environ.pop(_SPLASH_FD_ENV, None) 

140 if fd_str is not None: 

141 _signal_takeover(int(fd_str)) 

142 _close_write_fd(int(fd_str)) 

143 

144 handle = _active_handle 

145 if handle is None: 

146 return 

147 _active_handle = None 

148 

149 # Signal and close the write end. This may double-signal/close the same 

150 # fd that the env-var path already handled; both helpers suppress 

151 # OSError so that is harmless. 

152 # No _restore_cursor() here: we are inside Textual's alt-screen, 

153 # where writing cursor-show would produce a visible artifact. 

154 _signal_takeover(handle.write_fd) 

155 _close_write_fd(handle.write_fd) 

156 try: 

157 handle.process.wait(timeout=_STOP_TIMEOUT) 

158 except subprocess.TimeoutExpired: 

159 handle.process.kill() 

160 handle.process.wait(timeout=1.0) 

161 

162 

163def _close_write_fd(fd: int) -> None: 

164 """Close a pipe write fd, ignoring errors if already closed.""" 

165 with contextlib.suppress(OSError): 

166 os.close(fd) 

167 

168 

169def _signal_takeover(fd: int) -> None: 

170 """Send the TUI-takeover byte, ignoring errors if the pipe is gone.""" 

171 with contextlib.suppress(OSError): 

172 os.write(fd, TAKEOVER_BYTE) 

173 

174 

175def _restore_cursor() -> None: 

176 """Belt-and-suspenders cursor restore on stderr.""" 

177 try: 

178 sys.stderr.write(_SHOW_CURSOR) 

179 sys.stderr.flush() 

180 except OSError: 

181 pass # stderr may be closed during interpreter shutdown 

182 

183 

184def _atexit_cleanup() -> None: 

185 """Last-resort cleanup if stop() was never called.""" 

186 if _active_handle is not None: 

187 stop(_active_handle)