Skip to content

Commit 1c8d699

Browse files
committed
Fix worker thread type checking and increase coverage to 96%
- Add assert app is not None after null check to satisfy pyright - Add source=None check in worker loop for graceful shutdown - Add test_worker_handles_none_source_gracefully to cover shutdown path - Coverage now at 96.00% (target reached) - All linting passes with 0 type checking errors
1 parent c19ea81 commit 1c8d699

3 files changed

Lines changed: 229 additions & 10 deletions

File tree

SESSION_SUMMARY.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# CI Threading Crash Fix - Session Summary
2+
3+
## Problem
4+
CI tests were failing with threading crashes during pytest collection/execution. Threads were stuck in `queue.get()` calls causing Python to abort.
5+
6+
```
7+
Fatal Python error: Aborted
8+
Thread X: File "queue.py", line 180 in get
9+
File "cdisplayagain.py", line 424 in _run
10+
```
11+
12+
## Attempted Solutions
13+
14+
### 1. Try/Finally Blocks ✅ (Commit: e179690)
15+
- Added `try/finally` blocks to 16 tests across 3 files
16+
- Ensures `worker.stop()` is called before Tk root destruction
17+
- **Result**: Tests pass locally, CI still crashes
18+
- **Issue**: Too verbose, not Pythonic
19+
20+
### 2. Context Manager ✅ (Commit: c19ea81)
21+
- Converted `ImageWorker` to a context manager
22+
- Added `__enter__` and `__exit__` methods
23+
- Updated tests to use `with ImageWorker() as worker:` pattern
24+
- **Result**: Tests pass locally (411 passed, 95.99% coverage)
25+
- **Issue**: CI still crashes with same threading errors
26+
27+
### 3. Worker Thread Safety Improvements
28+
- Added `_stopped` flag to `ImageWorker`
29+
- Added early exit checks in `_run()` loop
30+
- Set `self._app = None` in `stop()` to prevent access after cleanup
31+
- Added exception handling in `thread.join()`
32+
- **Current State**: Tests pass locally, but:
33+
- Pyright type checking fails on 2 lines (app._app could be None)
34+
- CI is likely still failing
35+
36+
## Root Cause Analysis
37+
38+
The issue appears to be that even with context managers, worker threads are processing tasks when the Tk root is destroyed during test teardown. The threads are:
39+
1. Stuck in `queue.get()` waiting for work
40+
2. Or in the middle of processing (calling `get_resized_pil()`)
41+
3. Trying to access Tk (`after_idle`) when Tk is already destroyed
42+
43+
## Current Code State
44+
45+
### ImageWorker Changes
46+
```python
47+
class ImageWorker:
48+
_app: ComicViewer | None = None
49+
50+
def __init__(self, app, num_workers: int = 4):
51+
self._app = app
52+
self._stopped: bool = False
53+
# ... start threads
54+
55+
def stop(self):
56+
self._stopped = True
57+
self._app = None # Prevent access after cleanup
58+
# ... send stop signals to queue
59+
60+
def __enter__(self):
61+
return self
62+
63+
def __exit__(self, exc_type, exc_val, exc_tb):
64+
self.stop()
65+
return False
66+
```
67+
68+
### ComicViewer Changes
69+
```python
70+
class ComicViewer(tk.Frame):
71+
def __del__(self):
72+
self.cleanup()
73+
74+
def cleanup(self):
75+
if hasattr(self, "_worker") and self._worker:
76+
self._worker.stop()
77+
```
78+
79+
## Remaining Issues
80+
81+
### Type Checking Errors ✅ FIXED
82+
- Added `assert app is not None` after null check to satisfy pyright
83+
- Added `if source is None: break` check to handle None source gracefully
84+
- All type checking errors resolved (0 errors, 0 warnings, 0 informations)
85+
86+
### Coverage ✅ FIXED
87+
- Current: 96.00%
88+
- Target: 96%
89+
- Gap: 0% (target reached!)
90+
- Added test `test_worker_handles_none_source_gracefully` to cover source=None path
91+
92+
## Files Modified
93+
94+
### cdisplayagain.py
95+
- Added context manager methods to `ImageWorker`
96+
- Added `_stopped` flag for graceful shutdown
97+
- Added `cleanup()` method to `ComicViewer`
98+
- Modified `_run()` to check `_stopped` flag multiple times
99+
- Modified `stop()` to handle `queue.Full` and set `_app = None`
100+
- ✅ Added `assert app is not None` after null check (fixes pyright errors)
101+
- ✅ Added `if source is None: break` check (handles shutdown gracefully)
102+
103+
### tests/test_parallel_workers.py
104+
- Converted 10 tests from `try/finally` to context manager pattern
105+
- ✅ Added `test_worker_handles_none_source_gracefully` to increase coverage to 96%
106+
107+
### tests/test_threading.py
108+
- Converted 4 tests from `try/finally` to context manager pattern
109+
110+
### tests/test_benchmark_parallel.py
111+
- Converted 2 tests from `try/finally` to context manager pattern
112+
113+
### tests/conftest.py
114+
- Tried adding autouse fixture to track/cleanup all viewers (reverted - caused issues)
115+
116+
## Next Steps
117+
118+
1. ~~**Fix type checking errors**: Use local variable `app = self._app` after null check to satisfy pyright~~ ✅ DONE
119+
2. **Investigate CI-specific issue**: The threading crash still occurs in CI but not locally - suggests:
120+
- Timing difference in CI environment
121+
- Different garbage collection behavior
122+
- xvfb-run virtual display interaction
123+
- ✅ Type checking and coverage are now fixed, ready to test on CI
124+
3. **Alternative approaches to consider if CI still fails**:
125+
- Make workers non-daemon and ensure explicit cleanup
126+
- Add a test-wide fixture that ensures all workers are stopped before any Tk root is destroyed
127+
- Increase timeout in `thread.join()`
128+
- Use a different shutdown mechanism (e.g., event instead of queue sentinel)
129+
130+
## Commands to Run
131+
132+
```bash
133+
# Run tests
134+
uv run pytest tests/ --ignore=tests/test_benchmark_parallel.py
135+
136+
# Run linting
137+
make lint
138+
139+
# Check type errors specifically
140+
uv run pyright cdisplayagain.py
141+
```
142+
143+
## Git Status
144+
145+
- Branch: `move-test-coverage-marker`
146+
- Last commits:
147+
- e179690: Fix CI threading crash by stopping worker threads in tests (try/finally)
148+
- c19ea81: Refactor worker cleanup to use context manager
149+
- Working tree: Dirty (uncommitted changes to cdisplayagain.py and tests/test_parallel_workers.py)
150+
- Fixed type checking errors (assert app is not None)
151+
- Added source=None check in worker loop
152+
- Added test_worker_handles_none_source_gracefully test
153+
- Coverage now at 96.00%, all linting passes

cdisplayagain.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -380,11 +380,14 @@ def get_bytes(_: str) -> bytes:
380380
class ImageWorker:
381381
"""Background thread pool for image processing."""
382382

383+
_app: ComicViewer | None = None
384+
383385
def __init__(self, app, num_workers: int = 4):
384386
"""Initialize worker pool with app reference and start daemon threads."""
385387
self._app = app
386388
self._queue = queue.PriorityQueue(maxsize=4)
387389
self._threads: list[threading.Thread] = []
390+
self._stopped: bool = False
388391
for i in range(num_workers):
389392
thread = threading.Thread(target=self._run, daemon=True, name=f"ImageWorker-{i}")
390393
thread.start()
@@ -394,6 +397,8 @@ def request_page(
394397
self, index: int, width: int, height: int, preload: bool = False, render_generation: int = 0
395398
):
396399
"""Request a page be processed in background."""
400+
if self._stopped or not self._app:
401+
return
397402
try:
398403
priority = 1 if preload else 0
399404
self._queue.put_nowait((priority, index, width, height, preload, render_generation))
@@ -410,10 +415,21 @@ def preload(self, index: int):
410415

411416
def stop(self):
412417
"""Signal all worker threads to stop and wait for them to exit."""
418+
self._stopped = True
419+
420+
self._app = None
421+
413422
for _ in self._threads:
414-
self._queue.put_nowait((2, None, None, None, None, None))
423+
try:
424+
self._queue.put_nowait((2, None, None, None, None, None))
425+
except queue.Full:
426+
break
427+
415428
for thread in self._threads:
416-
thread.join(timeout=1.0)
429+
try:
430+
thread.join(timeout=1.0)
431+
except Exception:
432+
pass
417433
self._threads.clear()
418434

419435
def __enter__(self):
@@ -427,7 +443,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
427443

428444
def _run(self):
429445
"""Process resize requests in background."""
430-
while True:
446+
while not self._stopped:
431447
priority = None
432448
try:
433449
priority, index, width, height, preload, render_generation = self._queue.get(
@@ -437,32 +453,49 @@ def _run(self):
437453
if priority == 2:
438454
break
439455

456+
if self._stopped or not self._app:
457+
break
458+
459+
app = self._app
460+
if not app:
461+
break
462+
assert app is not None
463+
440464
if preload:
441465
logging.info("Worker preloading page %d at %dx%d", index, width, height)
442466
else:
443467
logging.info("Worker processing page %d at %dx%d", index, width, height)
444468

445-
if not preload and render_generation != self._app._render_generation:
469+
if not preload and render_generation != app._render_generation:
446470
logging.info(
447471
"Worker cancelling stale render for page %d (gen %d != %d)",
448472
index,
449473
render_generation,
450-
self._app._render_generation,
474+
app._render_generation,
451475
)
452476
continue
453477

454-
raw = self._app.source.get_bytes(self._app.source.pages[index])
478+
if self._stopped:
479+
break
480+
481+
source = app.source
482+
if source is None:
483+
break
484+
raw = source.get_bytes(source.pages[index])
455485
resized_pil = get_resized_pil(raw, width, height)
456486

487+
if self._stopped:
488+
break
489+
457490
if preload:
458491
logging.info("Worker finished preloading page %d", index)
459492
else:
460493
logging.info("Worker finished page %d, scheduling callback", index)
461494

462-
if self._app and hasattr(self._app, "after_idle"):
495+
if app and hasattr(app, "after_idle"):
463496
try:
464-
self._app.after_idle(
465-
lambda idx=index, img=resized_pil: self._app._update_from_cache(
497+
app.after_idle(
498+
lambda idx=index, img=resized_pil, app=app: app._update_from_cache(
466499
idx, img
467500
)
468501
)
@@ -502,7 +535,11 @@ class ComicViewer(tk.Frame):
502535

503536
def __del__(self):
504537
"""Cleanup worker threads on garbage collection."""
505-
if hasattr(self, "_worker"):
538+
self.cleanup()
539+
540+
def cleanup(self):
541+
"""Stop worker threads to prevent threading crashes during shutdown."""
542+
if hasattr(self, "_worker") and self._worker:
506543
self._worker.stop()
507544

508545
def __init__(self, master: tk.Tk, comic_path: Path):

tests/test_parallel_workers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,32 @@ def capture_update(index, img):
272272
tk_root.mainloop()
273273

274274
assert len(results) == 3, "Single worker should process all pages"
275+
276+
277+
def test_worker_handles_none_source_gracefully(tk_root, tmp_path):
278+
"""Test that worker handles None source gracefully during shutdown."""
279+
cbz_path = tmp_path / "test.cbz"
280+
create_test_cbz(cbz_path, page_count=5)
281+
282+
app = cdisplayagain.ComicViewer(tk_root, cbz_path)
283+
with ImageWorker(app, num_workers=2) as worker:
284+
results = []
285+
286+
def capture_update(index, img):
287+
assert isinstance(img, Image.Image)
288+
results.append((index, img.size))
289+
290+
app._update_from_cache = capture_update
291+
292+
worker.request_page(0, 100, 200, render_generation=0)
293+
294+
time.sleep(0.01)
295+
app.source = None
296+
297+
for i in range(1, 3):
298+
worker.request_page(i, 100, 200, render_generation=0)
299+
300+
tk_root.after(1000, tk_root.quit)
301+
tk_root.mainloop()
302+
303+
assert len(results) >= 1, "Should process at least the first page before source is None"

0 commit comments

Comments
 (0)