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

21 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Task cancellation sentinel shared between core library and TUI. 

2 

3Raised by progress callbacks when the UI signals a long-running 

4background task should abort. Lives in a neutral module so that 

5``lilbee.catalog`` (core) can let it propagate without importing TUI code. 

6""" 

7 

8from __future__ import annotations 

9 

10import signal 

11import threading 

12from collections.abc import Iterator 

13from contextlib import contextmanager 

14from typing import Protocol 

15 

16 

17class TaskCancelledError(Exception): 

18 """Raised inside a progress callback to abort a long-running task.""" 

19 

20 

21class CancelSignal(Protocol): 

22 """Anything a long-running run polls to learn it should stop. 

23 

24 Structural so a run driven from another process can be cancelled by a 

25 ``multiprocessing`` event as readily as by a ``threading`` one. 

26 """ 

27 

28 def is_set(self) -> bool: ... 

29 

30 

31@contextmanager 

32def sigint_cancel() -> Iterator[threading.Event]: 

33 """Turn Ctrl-C into a token the wiki pass polls, not a mid-page abort. 

34 

35 A build runs for hours and writes pages as it goes, so the default 

36 KeyboardInterrupt drops it wherever the interpreter happened to be. Setting 

37 a token instead lets it stop at a source boundary with what it wrote intact. 

38 The previous handler is restored as soon as it fires, so a second Ctrl-C 

39 still hard-exits a pass that is not checking the token. 

40 

41 signal.signal only works on the main thread; off it (pytest-xdist workers) 

42 the token is simply never set and Ctrl-C keeps its default behaviour. 

43 """ 

44 token = threading.Event() 

45 if threading.current_thread() is not threading.main_thread(): 

46 yield token 

47 return 

48 previous = signal.getsignal(signal.SIGINT) 

49 

50 def _on_sigint(_signum: int, _frame: object) -> None: 

51 signal.signal(signal.SIGINT, previous) 

52 token.set() 

53 

54 signal.signal(signal.SIGINT, _on_sigint) 

55 try: 

56 yield token 

57 finally: 

58 signal.signal(signal.SIGINT, previous)