Skip to content

Slice B: runner robustness under failure — v0.10.0 - #168

Merged
jamesabel merged 2 commits into
masterfrom
slice-b-runner-robustness
Aug 23, 2026
Merged

Slice B: runner robustness under failure — v0.10.0#168
jamesabel merged 2 commits into
masterfrom
slice-b-runner-robustness

Conversation

@jamesabel

Copy link
Copy Markdown
Owner

Summary

Slice B of the crash review: harden the runner's failure paths so resource exhaustion, native crashes, and mid-run control actions can no longer lose tests, wedge the run, or double the process load.

Fix Before After
PytestProcess.start() raises (WinError 1455 at commit exhaustion) Worker thread died silently; test lost (stuck QUEUED); supervisor respawned a replacement every second that died the same way TERMINATED record written; worker backs off (escalating, ≤30 s, stop-interruptible)
Test child dies without writing its result (native crash, os._exit) Test "Running" forever; stall watchdog can't flag a dead PID Worker checks the child's exit code and backstops via mark_test_terminated_if_stale (never overwrites a real result)
set_number_of_processes during wind-down (called every GUI tick) Spawned a second full worker pool into the stopping run — the commit-exhaustion condition itself Refused with a log line; pending soft stop still resizable (Cancel Stop + resize keeps working)
Stale _TestRunner.process between tests Force-stop killed the next test by stale name; hard stop tree-killed a dead/recycled PID Reference cleared in finally; a stop between tests writes the dequeued test's TERMINATED record
PytestRunner.run() DB error before _started_event / during finalization join(None) blocked forever / runner thread died un-finalized Both phases guarded fail-open; run aborts or finalizes cleanly
ProcessMonitor non-daemon Any unguarded error in PytestProcess.run() made the exit handler join the never-ending monitor → test process hung forever daemon=True
Coverage finalization / live-output read in the test child WinError 32 (aggregator holds the RESUME-mode file) or disk-full killed the child before the result record Guarded; result record always written, coverage loss is a logged warning

Tests

tests/test_pytest_runner/test_pytest_runner_robustness.py (5 new): spawn-failure → TERMINATED + is_user_complete, unclean child exit → backstop record, resize-after-stop spawns nothing, process cleared between tests, monitor daemon flag.

Full suite: 497 passed. ruff clean; ty unchanged (13 pre-existing diagnostics).

Behaviour changes

  • A test whose process cannot even start is recorded TERMINATED (previously: silently lost). Under persistent exhaustion the queue drains at the backoff rate with clear Log-tab events instead of within seconds.
  • A hard stop while a worker awaits admission now records the dequeued test TERMINATED instead of tree-killing an unrelated PID and leaving the test QUEUED.
  • Resizing the pool during wind-down is ignored (logged) rather than honored.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KEZ9TpT9VwBujBq1S3v2Jd

jamesabel and others added 2 commits August 23, 2026 14:04
…ard, no stale process refs

Slice B of the crash review — the failure paths that corrupted or wedged a run under
exactly the load pytest-fly targets:

- _TestRunner._run_single_test guards PytestProcess.start(): CreateProcess raising
  OSError (WinError 1455 at commit exhaustion) previously killed the worker thread —
  the dequeued test kept no terminal record and the supervision loop respawned a
  replacement every second that died the same way, draining the queue into lost tests.
  Now the test records TERMINATED and the worker backs off (escalating, capped at 30 s,
  stop-interruptible) before the next dequeue.
- Backstop terminal record for an unclean child exit: a test child that dies without
  writing its result (native crash, os._exit, external kill) left the test "Running"
  forever, and the stall watchdog cannot flag a dead PID. The worker now checks the
  child's exit code and routes through mark_test_terminated_if_stale, which never
  overwrites a real result.
- set_number_of_processes refuses a stopping/stopped runner. reconcile_process_count
  runs every GUI tick and is_running() stays True during wind-down, so a Processes
  change while a new Run wound down the old one started a second full worker pool
  running the old run's queue. A pending soft stop still allows resizing (Cancel Stop
  + resize keeps working).
- _TestRunner.process is cleared between tests: force_stop_test matched a finished
  test's stale name and killed the worker's NEXT test; a hard stop while awaiting
  admission tree-killed the previous test's dead — possibly recycled — PID. A stop
  between tests now writes the dequeued test's TERMINATED record instead of killing
  nothing.
- PytestRunner.run() can no longer die before _started_event is set (join(None) hung
  forever) or during soft-stop finalization; both DB phases are guarded fail-open.
- _write_terminated_record consolidates the worker-side TERMINATED writes, fail-open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZ9TpT9VwBujBq1S3v2Jd
…ation — v0.10.0

- ProcessMonitor is now a daemon child. As a non-daemon child, any unguarded exception
  unwinding PytestProcess.run() made multiprocessing's exit handler *join* the monitor —
  whose loop never ends on its own — so the test process hung forever with no terminal
  record (the exact wedge the liveness machinery exists to prevent).
- Coverage finalization (stop/save/unlink/move) is guarded: in RESUME mode the prior
  run's .coverage file exists and the aggregator child may have it open, so the unlink
  can raise WinError 32; a full disk fails coverage.save() the same way. Coverage data
  for that test is lost, but the result record is still written — previously the raise
  killed the child first and the test sat "Running" forever.
- The live-output read for the final record is guarded the same way.

New tests (test_pytest_runner_robustness.py) cover both commits: spawn-failure →
TERMINATED + completion, unclean child exit → backstop record, resize-after-stop spawns
nothing, process reference cleared between tests, monitor daemon flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZ9TpT9VwBujBq1S3v2Jd
@codecov-commenter

codecov-commenter commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.38356% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.90%. Comparing base (feaf70c) to head (940c709).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/pytest_fly/pytest_runner/pytest_runner.py 75.00% 15 Missing ⚠️
src/pytest_fly/pytest_runner/pytest_process.py 8.33% 11 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #168      +/-   ##
==========================================
- Coverage   86.08%   85.90%   -0.19%     
==========================================
  Files          74       74              
  Lines        6168     6224      +56     
==========================================
+ Hits         5310     5347      +37     
- Misses        858      877      +19     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jamesabel
jamesabel merged commit 9511ae8 into master Aug 23, 2026
3 checks passed
@jamesabel
jamesabel deleted the slice-b-runner-robustness branch August 23, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants