Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pip install -r requirements-dev.txt

### GUI layer (`src/pytest_fly/gui/`)
- `gui_main.py` — `FlyAppMainWindow`: 8-tab Qt window with a periodic timer (default 3 s) that pulls updates from the runner and refreshes all tabs.
- `coverage_tracker.py` — `CoverageTracker`: submits coverage recalculation to a worker thread that runs the aggregator child; rate-limited by the Coverage Refresh preference while the run is active, with a final pass once nothing is running.
- Tabs: `run_tab/` (run/stop controls, status, system metrics, failed tests, live output), `graph_tab/` (time-based progress chart), `table_tab/` (per-test status grid), `coverage_tab/` (coverage-over-time chart), `history_tab/` (recent-run summaries — run times, pass/fail statistics, failed-test lists; multi-select rows copy to the clipboard; run count set by the History Run Limit preference), `log_tab/` (live application event log — admission-gate, resource-guard, and stall-watchdog events, each line date/time-prefixed; default view shows tagged `EVENT_EXTRA` events + warnings, Verbose shows all INFO+), `configuration_tab/` (parallelism, thresholds, gates), `about_tab/`.

### Core runner (`src/pytest_fly/pytest_runner/`)
Expand All @@ -62,9 +63,14 @@ pip install -r requirements-dev.txt
- `process_monitor.py` — `ProcessMonitor` subprocess: samples CPU/memory of the test process tree; `SubtreeCpuSampler` (shared persistent-handle CPU sampling).
- `system_monitor.py` — `SystemMonitor` subprocess: system-wide CPU/memory/commit/disk/network sampling for the Run tab charts.
- `commit_memory.py` — Windows commit-charge readers and psutil subtree helpers.
- `coverage.py` — merges per-process coverage data.
- `coverage.py` — merges per-process coverage data (one `cov.report()` pass yields both the percentage and the TOTAL line).
- `coverage_aggregator.py` — `CoverageAggregator` spawn child + `aggregate_coverage()`: runs `calculate_coverage()` out of the GUI process (it has crashed the interpreter natively); a dead/hung child is a logged warning, not a crash.
- `ordering.py` — applies the user's test-ordering aspects; `live_output.py` — per-test live-output file paths.

### Crash diagnostics
- `faults.py` — arms `faulthandler` (parent + every spawn child, via the `PYTEST_FLY_FAULTHANDLER` env var) into `.pytest-fly/logs/faulthandler-<pid>.log`; `report_previous_crashes()` surfaces non-empty dumps at the next launch and archives them as `faulthandler-crash-<pid>-<n>.log`.
- `platform/wer.py` — Windows Error Reporting LocalDumps: read-only status, the elevated (UAC) configure/remove commands offered from the Configuration tab, and a startup sweep of new `*.dmp` files. Machine-wide for all `python.exe`; never applied silently.

### Persistence
- `db/db.py` — stores `PytestProcessInfo` records (status, timing, resource usage) — the foundation for RESUME mode. Two access classes: `PytestProcessInfoDB` (read/write via **msqlite**, whose context manager holds the DB's EXCLUSIVE lock) and `PytestProcessInfoReader` (read-only, lock-free WAL snapshot reads — required for the GUI thread and monitor threads so they never contend with test-process writers).
- `preferences.py` — persists user settings (window geometry, parallelism count, utilization thresholds, run mode) via the **pref** library.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "pytest-fly"
description = "pytest runner and observer"
version = "0.8.1"
version = "0.9.0"
readme = "README.md"
requires-python = ">=3.12"
authors = [
Expand Down
4 changes: 4 additions & 0 deletions src/pytest_fly/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@
# from) into spawned child processes, which re-import modules fresh and so lose the in-process
# binding established by paths.init_workspace().
PYTEST_FLY_WORKSPACE_STRING = "PYTEST_FLY_WORKSPACE"

# Environment variable carrying the resolved faulthandler setting into spawned child processes,
# which re-import modules fresh and so cannot see the parent's preference lookup.
PYTEST_FLY_FAULTHANDLER_STRING = "PYTEST_FLY_FAULTHANDLER"
116 changes: 116 additions & 0 deletions src/pytest_fly/faults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Fatal-error diagnostics.

``faulthandler`` writes the Python stack of every thread to a file descriptor when the process
dies from SIGSEGV / SIGABRT / SIGFPE / SIGBUS / SIGILL (on Windows, also an access violation).
It writes directly to the fd from the signal handler, so the output survives a crash that leaves
no traceback and no log record.

The dump file is opened once per process and the handle is held for the process lifetime —
closing it would leave ``faulthandler`` writing to a dead descriptor. A clean exit leaves an
empty file; :func:`report_previous_crashes` sweeps those up on the next launch and surfaces any
non-empty one (a crash) in the log.
"""

import faulthandler
import os
from pathlib import Path

from tobool import to_bool

from .const import PYTEST_FLY_FAULTHANDLER_STRING
from .logger import EVENT_EXTRA, get_logger
from .paths import get_log_dir

log = get_logger()

_fault_file = None # process-lifetime reference; never close it

_dump_prefix = "faulthandler-"
_crash_prefix = "faulthandler-crash-"
_dump_suffix = ".log"


def faulthandler_dump_path(pid: int | None = None) -> Path:
"""Path of a process's faulthandler dump file (this process when *pid* is ``None``)."""
return Path(get_log_dir(), f"{_dump_prefix}{os.getpid() if pid is None else pid}{_dump_suffix}")


def faulthandler_enabled_by_env() -> bool:
"""Whether faulthandler is requested, per the inherited environment.

The parent stamps ``PYTEST_FLY_FAULTHANDLER`` into ``os.environ`` at startup (see
:func:`enable_faulthandler`), so spawn children need no preference lookup of their own.
Unset means enabled — the handler must be armed before the crash nobody predicted.
"""
value = os.environ.get(PYTEST_FLY_FAULTHANDLER_STRING)
if value is None:
return True
parsed = to_bool(value)
return True if parsed is None else parsed # an unrecognized value keeps the safe default: armed


def enable_faulthandler(export_to_children: bool = False, requested: bool | None = None) -> Path | None:
"""Install the fatal-error handler for this process.

:param export_to_children: parent only — stamp the resolved setting into ``os.environ`` so
spawned children inherit it without reading preferences.
:param requested: explicit on/off; ``None`` means "read the environment".
:return: the dump file path, or ``None`` if faulthandler is disabled or could not be enabled.
"""
global _fault_file
enabled = faulthandler_enabled_by_env() if requested is None else requested
if export_to_children:
os.environ[PYTEST_FLY_FAULTHANDLER_STRING] = "1" if enabled else "0"
if not enabled:
return None
path = faulthandler_dump_path()
try:
# binary, append, unbuffered: faulthandler writes to the raw fd, and append means a
# re-enable in the same process never truncates an earlier dump.
_fault_file = open(path, "ab", buffering=0)
faulthandler.enable(file=_fault_file, all_threads=True)
except OSError as e:
log.warning(f"could not enable faulthandler at {path}: {e}")
return None
return path


def report_previous_crashes(max_chars: int = 4000) -> list[Path]:
"""Log and archive any non-empty faulthandler dumps left by earlier sessions.

A non-empty dump means some process died from a fatal signal. Each is logged at WARNING (so
it reaches the log file and the GUI Log tab) and renamed to ``faulthandler-crash-<pid>-<n>.log``
so it is reported exactly once but never destroyed. Empty dumps from prior runs (clean exits)
are deleted. A file still held by a live process (e.g. a second instance) is skipped.

:param max_chars: cap on how much of each dump is echoed into the log.
:return: the archived crash-dump paths.
"""
archived: list[Path] = []
log_dir = get_log_dir()
own_path = faulthandler_dump_path()
for path in sorted(log_dir.glob(f"{_dump_prefix}*{_dump_suffix}")):
if path == own_path or path.name.startswith(_crash_prefix):
continue
try:
if path.stat().st_size == 0:
path.unlink()
continue
text = path.read_text(encoding="utf-8", errors="replace")
pid = path.name[len(_dump_prefix) : -len(_dump_suffix)]
archive = _next_archive_path(log_dir, pid)
path.rename(archive)
except OSError as e:
log.info(f"skipping faulthandler dump {path}: {e}") # locked by a live process, or already gone
continue
log.warning(f"previous session crashed ({archive.name}):\n{text[:max_chars]}", extra=EVENT_EXTRA)
archived.append(archive)
return archived


def _next_archive_path(log_dir: Path, pid: str) -> Path:
"""First unused ``faulthandler-crash-<pid>-<n>.log`` path (PIDs are reused, so number them)."""
n = 1
while (candidate := Path(log_dir, f"{_crash_prefix}{pid}-{n}{_dump_suffix}")).exists():
n += 1
return candidate
Loading
Loading