diff --git a/HANDOFF-tsan.md b/HANDOFF-tsan.md new file mode 100644 index 0000000..19ee820 --- /dev/null +++ b/HANDOFF-tsan.md @@ -0,0 +1,164 @@ +# Handoff — fusil `--tsan` feature set (for Codex) + +Written 2026-07-18. Purpose: give an incoming agent the **off-repo** context around the +ThreadSanitizer (`--tsan`) data-race fuzzing feature — builds, venvs, the sibling catalog, +fleet infra, campaign state, and gotchas that are **not** discoverable from the fusil repo +alone. Complements CLAUDE.md's in-repo `--tsan` section — read that first for the code-side +overview; this file is the surrounding environment. Uncommitted; delete when done. + +Repo states at handoff: **fusil** `main`@`45061ea`; sibling **cpython-tsan-findings** +`main`@`819e740`. + +--- + +## 1. What `--tsan` is, and where it lives in this repo + +`--tsan` turns fusil into a free-threading **data-race** fuzzer (vs the OOM/crash mode). Instead +of the single-threaded OOM sweep, it emits a **concurrency-stress region**: a few SHARED objects +hammered by many worker threads (barrier-released), so concurrent C-level access to one object's +state trips a TSan race. + +In-repo touch points: +- **`fusil/python/write_python_code.py` → `_write_tsan_stress_region()`** — the emitter. + The op-mix is ops **(a)–(i)**; (h) shared-iterator races and (i) read-while-mutate are the + newest (PR #211) and are what just found the bytes-iterator race. Emitter is **gated behind + `--tsan`** — non-`--tsan` generated output (and its goldens) are unchanged, so don't expect + golden regen for tsan-only edits. +- **`fusil/python/tsan_dedup.py`** — the race-report **parser + dedup engine + `Suppressor`**. + `parse_report(stdout)` → `{signature, sites, framework, kind}`, where `signature` is the + sorted `"file:func | file:func"` site pair. **This is a cross-repo contract** (see §4). +- **`fusil/python/__init__.py`** — the `--tsan*` CLI options (`--tsan-threads`, + `--tsan-iterations`, `--tsan-shared-objects`, `--tsan-suppressions`, `--tsan-dedup-catalog`, + `--tsan-dedup-keep`, `--tsan-dedup-prune`). `setupProject()` makes `--tsan` imply `--no-numpy`. +- **`doc/tsan-mode-plan.md`** — the design doc + phase history (Phase 3.1 = the new iterator ops). +- **Tests**: `tests/python/test_tsan_generation.py` (emitter markers + "emitted script compiles") + and `tests/python/test_tsan_dedup.py` (parser/signature/suppression, pure-Python). +- **`fleet/`** — systemd-based multi-instance runner (`fleet up/down/status/report/finds`, + `fleet check` is `--tsan`-aware). README documents the tsan fleet config. + +fusil applies the whole TSan **env recipe internally** when `--tsan` is passed (see §3) — you +don't set it by hand for a fuzzing run, only for manual repros. + +--- + +## 2. Off-repo: builds & venvs + +**CPython build matrix** — `~/projects/python_build_matrix/builds//python`. Built via +`cd ~/projects/python_build_matrix && JOBS=$(nproc) bash build_all.sh `. clang-21 at +`/usr/local/bin`. Builds relevant to TSan work: +- `debug-ft-nojit-tsan` — **the TSan target** (CPython 3.16.0a0, `--disable-gil + --with-thread-sanitizer`). What the fleets run against. +- `release-ft-nojit`, `release-ft-nojit-asan`, `debug-ft-nojit-asan`, `debug-ft-nojit` — used + for crash-repro / severity confirmation of races (ASan gives UAF/OOB backtraces). +- `release-ft-nojit-o0` — a `-O0` **NDEBUG** FT build made for the #153928 OOB-char demo + (`-O2` register-caches the iterator cursor, hiding the OOB window; NDEBUG turns the UAF into a + real segfault). Handy for demonstrating iterator OOB reads. + +**Venvs** (the ones that actually exist and work): +- `~/venvs/fusil_np_verify/bin/python` — **CPython 3.14, GIL**, has `python-ptrace`. Use for + running the test suite and non-FT verification. The `oom_dedup`/`tsan_dedup` tests are + pure-Python and run here fine. +- `~/venvs/matrix_venvs/fusil_debug-ft-nojit_venv/bin/python` — **CPython 3.16 free-threaded**, + has `python-ptrace` + fusil. This is the **fleet runner** venv (a FT runner is required because + `PYTHON_GIL=0` is inherited by the runner too). +- **`~/venvs/fusil_venv` is GONE** — don't use it. + +`python-ptrace` is a hard import-time dependency of `fusil.application`, so the runtime stack +only imports under a venv that has it (the two above). Pure-engine tests (`tsan_dedup`, +`oom_dedup`, generation) don't need the runtime stack. + +--- + +## 3. Off-repo: the TSan env recipe (needed for MANUAL repros) + +fusil sets these itself under `--tsan`; you only need them to reproduce a race by hand against +`debug-ft-nojit-tsan`: + +```sh +setarch -R bash -c "ulimit -v unlimited; PYTHON_GIL=0 \ + TSAN_OPTIONS='halt_on_error=1:symbolize=1:exitcode=66:history_size=4' \ + DEBUGINFOD_URLS= \ + /python repro.py" +``` +- **`setarch -R`** (ASLR off) and **`ulimit -v unlimited`** (RLIMIT_AS) are mandatory for TSan. +- **`DEBUGINFOD_URLS=`** is load-bearing: without it `llvm-symbolizer` hangs for ~120 s on the + blackholed Ubuntu debuginfod server (any local ASan/TSan symbolizer hang = this). gdb is immune. +- exit **66** = a data race was detected. `--no-numpy` (numpy isn't FT-clean; the plugin injects + `import numpy` which the tsan build can't import). + +--- + +## 4. Off-repo: the sibling catalog `cpython-tsan-findings` (CRITICAL LOCKSTEP) + +`~/projects/cpython-tsan-findings` (published: github.com/devdanzin/cpython-tsan-findings) is the +triage/reporting home for the races. It is **coupled to fusil**: + +- `scripts/ingest.py` **imports fusil's `tsan_dedup.py`** via + `FUSIL_TSAN_DEDUP=/home/danzin/projects/fusil/fusil/python/tsan_dedup.py`. So **the signature + format that `tsan_dedup.parse_report` emits is a hard contract** — if you change how signatures + are built (site pair format, framework filtering, the ACCESS/stanza regexes), you change what + matches `catalog/known_races.tsv` and can silently break dedupe across the whole catalog. There + is a matching-order + normalization subtlety (`gen_known_races._normalize` sorts the pair); + keep them in sync. +- `catalog/known_races.tsv` (119 sigs / 34 races) = the dedupe snapshot fusil reads with + `--tsan-dedup-catalog`. Regenerated from `reports/*/meta.json` by `scripts/gen_known_races.py`. +- `catalog/suppressions.txt` = TSan-style suppressions consumed by `tsan_dedup.Suppressor` + (framework noise, glibc/OpenSSL false positives, subinterpreter machinery). Post-hoc regex over + the signature. +- Triage loop: `FUSIL_TSAN_DEDUP=… python3 scripts/ingest.py '/inst-*/python*/*'` buckets + crash dirs by signature; new signatures get a `reports/TSAN-NNNN-*/meta.json` (+ optional + `repro.py` / `backtrace.txt`), then `gen_known_races.py` regenerates the TSV. Catalog convention: + **commit straight to `main`** (no PR), then push. + +**If Codex edits `tsan_dedup.py`, re-run the catalog's `test_tsan_dedup.py` AND re-ingest a fleet +to confirm known races still match.** The parser has been bug-fixed twice for exactly this class +of regression (PR #207: the `ACCESS` regex missed "Previous atomic write" stanzas → fabricated +`A | A` signatures; framework-FP over-matching). + +--- + +## 5. Off-repo: fleet infra + campaign state + +- Fleets run at **`/home/fusil/runs/fusil-tsan_fleet_NN/inst-*/python*/*`** (systemd, as the + `fusil` user). **Ingest glob must be `inst-*/python*/*`** — a restarted instance gets a fresh + `python-2`/`python-3` project dir; `inst-*/python/*` silently ingests only the pre-restart run. +- Campaign so far: fleets **01–06 triaged**, all converged (each ingests 0 new signatures after + its faces are folded). **37 races cataloged (TSAN-0001..0037)**; umbrella issue + **python/cpython#153852** holds 15 findings. +- **PR #211 (the new iterator ops) just paid off**: fleet-06 (first run with them) surfaced + **TSAN-0037 = the `bytes` iterator race** (`striter_next` non-atomic `it_index` + `it_seq` + double-DECREF), 196/223 dirs — the bytes analog of **python/cpython#153928** (the `str` + iterator, johng's issue, which we reproduced + commented on). We just commented on #153928 with + the bytes `it_seq` UAF + the "whole sequence-iterator family" point. The `list`/`tuple`/`range` + iterators share the exact shape and haven't been fleet-caught yet — likely fleet-07 material. +- **Process-leak history**: the fleet leaked python processes owned by the `fusil` user; fixed in + **PRs #208/#209** (`start_new_session=True` + `killpg`; reap-on-self-exit in + `fusil/process/create.py`). If you touch process/child handling, don't regress this. +- **Policy**: do NOT file FT-**subinterpreter** races upstream (per cpython#143232) — they're + suppressed in `suppressions.txt`, not cataloged as bugs. + +--- + +## 6. Running the tests (gotcha) + +Some test modules do `from python._test_options import …`, so run from the **`tests/` dir** or via +discover so the `python` subpackage resolves: +```sh +cd ~/projects/fusil && ~/venvs/fusil_np_verify/bin/python -m unittest discover -s tests +# or a single module (from tests/): +cd tests && ~/venvs/fusil_np_verify/bin/python -m unittest python.test_tsan_generation +``` +CI runs **both** `ruff check` and `ruff format --check` over `fusil/ tests/ +fuzzers/fusil-python-threaded` — `check` passing does not imply `format --check` passes. + +--- + +## 7. If Codex changes TSan things — quick guardrail checklist + +- Edited `_write_tsan_stress_region`? → `test_tsan_generation.py` must still pass (it compiles the + emitted script + asserts op markers); keep the emitter gated behind `--tsan`. +- Edited `tsan_dedup.py`? → `test_tsan_dedup.py` must pass **and** re-ingest a fleet to confirm + `known_races.tsv` still matches (the signature format is the cross-repo contract, §4). +- Edited process/child handling? → don't regress the #208/#209 process-reaping fix. +- New race worth cataloging? → that's the sibling-repo's job (meta.json + `gen_known_races.py`), + not a fusil change. Ping the maintainer (devdanzin) rather than filing upstream directly. diff --git "a/OQz\205=\202\326\371\320\027\244\023\306\372\341\257\027" "b/OQz\205=\202\326\371\320\027\244\023\306\372\341\257\027" new file mode 100644 index 0000000..ade26a5 Binary files /dev/null and "b/OQz\205=\202\326\371\320\027\244\023\306\372\341\257\027" differ diff --git "a/Z@\302\212C" "b/Z@\302\212C" new file mode 100644 index 0000000..ceefd1a Binary files /dev/null and "b/Z@\302\212C" differ diff --git "a/\\WUFEhjxWgstkho\\Scza\\AXo\\BAqHnxyi\\BpSAGZhqEHeUVH" "b/\\WUFEhjxWgstkho\\Scza\\AXo\\BAqHnxyi\\BpSAGZhqEHeUVH" new file mode 100644 index 0000000..4c041b8 Binary files /dev/null and "b/\\WUFEhjxWgstkho\\Scza\\AXo\\BAqHnxyi\\BpSAGZhqEHeUVH" differ diff --git a/co.txt b/co.txt new file mode 100644 index 0000000..e69de29 diff --git a/fleet/fleet-run b/fleet/fleet-run index a2f6945..a0af1c4 100755 --- a/fleet/fleet-run +++ b/fleet/fleet-run @@ -41,11 +41,17 @@ export PYTHONPYCACHEPREFIX="${PYTHONPYCACHEPREFIX:-/tmp/fusil-pycache-root}" # `import fusil` works whether or not fusil is pip-installed in RUNNER_PY's environment. export PYTHONPATH="$(dirname "$(dirname "$FUSIL_PY")")${PYTHONPATH:+:$PYTHONPATH}" -# Output sink: a per-run logfile (truncated each start) or discard. +# Output sink: a per-instance logfile (appended across restarts) or discard. The +# "# fleet-run instance ..." line below marks each run boundary inside it. if [ "${LOG:-file}" = "none" ]; then exec >/dev/null 2>&1 else - exec >"$inst_dir/fusil.out" 2>&1 + # APPEND, never truncate: systemd restarts an instance whenever the cgroup OOM killer + # reaps it (fusil@.service: MemoryMax + OOMPolicy=continue + Restart=always), and a + # truncating redirect destroyed the log of the run that just died -- which is exactly the + # one you need to see why. A 12-instance PyPy fleet restarted 10 times in 7.5 h and left + # 155 KB of log for a 2079-session instance, against 913 KB for one that never restarted. + exec >>"$inst_dir/fusil.out" 2>&1 fi echo "# fleet-run instance $i gil=$gil $(date -u +%FT%TZ) dir=$inst_dir" diff --git a/fusil/process/cpu_probe.py b/fusil/process/cpu_probe.py index f4bae7d..ece8fb0 100644 --- a/fusil/process/cpu_probe.py +++ b/fusil/process/cpu_probe.py @@ -54,7 +54,11 @@ def live(self): if duration < self.max_duration: return - # Success + # Success. NOTE: this does NOT kill the process -- it only scores, logs, and tags the + # session name. The Python fuzzer sets max_score to 0 unless --record-high-cpu, so the + # usual effect is a "cpu_load" name part and nothing else: the session runs to + # completion and is not discarded. The stats counter is named accordingly + # (high_cpu_sessions); its "cpu_load_kills" alias is legacy and was always a misnomer. self.score = self.max_score self.error( "CPU load (%.1f%%) bigger than maximum (%.1f%%) during %.1f sec: score=%.1f%%" diff --git a/fusil/python/fleet_report.py b/fusil/python/fleet_report.py index 9d32786..59fac83 100644 --- a/fusil/python/fleet_report.py +++ b/fusil/python/fleet_report.py @@ -234,7 +234,8 @@ def collect_instance(inst_dir: str, *, now=None, systemd=True) -> dict: "kept_crashes": crash_total, "sidecar_crashes": merged.get("crashes", 0), "timeouts": merged.get("timeouts", 0), - "cpu_load_kills": merged.get("cpu_load_kills", 0), + # Either spelling: v3 sidecars carry "high_cpu_sessions", older ones only the alias. + "high_cpu_sessions": merged.get("high_cpu_sessions", merged.get("cpu_load_kills", 0)), "new": new_candidate_count(by_label), "mode": merged.get("mode") or (current or {}).get("mode"), "plugins": merged.get("plugins") or (current or {}).get("plugins") or [], @@ -409,7 +410,7 @@ def render_instance(r: dict) -> str: to_pct = (100.0 * r["timeouts"] / r["sessions"]) if r["sessions"] else 0.0 find = (1000.0 * r["kept_crashes"] / r["sessions"]) if r["sessions"] else 0.0 L.append( - "sessions %-8d (%s) crashes %-5d (%.1f/1k) timeouts %d (%.1f%%) cpu-kills %d" + "sessions %-8d (%s) crashes %-5d (%.1f/1k) timeouts %d (%.1f%%) high-cpu %d" % ( r["sessions"], format_rate(r["sessions"], r["uptime_s"]), @@ -417,7 +418,7 @@ def render_instance(r: dict) -> str: find, r["timeouts"], to_pct, - r["cpu_load_kills"], + r["high_cpu_sessions"], ) ) L.append("-" * 72) diff --git a/fusil/python/session_stats.py b/fusil/python/session_stats.py index 7ce6558..7db7bf2 100644 --- a/fusil/python/session_stats.py +++ b/fusil/python/session_stats.py @@ -10,7 +10,7 @@ Sidecar schema (v1):: { - "schema": 2, + "schema": 3, "mode": "oom", # active fuzzing mode ("oom"/"tsan"/"rustpython"/ # "concurrency-stress"/"normal"/a "+"-joined combo) "plugins": ["cereggii"], # names of the loaded fusil plugins (entry-point discovered) @@ -22,7 +22,13 @@ "sessions": 3092, # sessions finished "crashes": 112, # sessions scored as a success/finding "timeouts": 70, # sessions the target-process timeout fired on - "cpu_load_kills": 5, # sessions the CPU-load watcher killed + "high_cpu_sessions": 5, # sessions that spent >=10s above 75% CPU. NOT kills: + # CpuProbe only scores + renames, and the Python fuzzer + # sets its max_score to 0 unless --record-high-cpu, so + # the session is neither ended nor discarded. + "cpu_load_kills": 5, # legacy alias of the above, still emitted so older readers + # and archived sidecars keep working. Misnamed: it never + # counted kills. "modules": { # per-target-module breakdown "_blake2": {"hits": 15, "crashes": 3, "timeouts": 0}, ... @@ -41,7 +47,9 @@ import time from typing import Callable -SCHEMA_VERSION = 2 # v2 adds "mode" + "plugins" (additive; readers use .get with defaults) +# v2 adds "mode" + "plugins"; v3 renames "cpu_load_kills" -> "high_cpu_sessions" and keeps the +# old key as an alias (additive both ways; readers use .get with defaults and accept either). +SCHEMA_VERSION = 3 class SessionStats: @@ -74,7 +82,7 @@ def __init__( self.sessions = 0 self.crashes = 0 self.timeouts = 0 - self.cpu_load_kills = 0 + self.high_cpu_sessions = 0 # module name -> {"hits": int, "crashes": int, "timeouts": int} self.modules: dict[str, dict[str, int]] = {} # --tsan shared-object composition -> count (Slice B). Empty for non-tsan runs. @@ -96,7 +104,7 @@ def record( if timeout: self.timeouts += 1 if cpu_load: - self.cpu_load_kills += 1 + self.high_cpu_sessions += 1 if tsan_kind: self.tsan_kinds[tsan_kind] = self.tsan_kinds.get(tsan_kind, 0) + 1 # ``module`` can legitimately be None very early (before the first module loads); @@ -126,7 +134,9 @@ def to_dict(self) -> dict: "sessions": self.sessions, "crashes": self.crashes, "timeouts": self.timeouts, - "cpu_load_kills": self.cpu_load_kills, + "high_cpu_sessions": self.high_cpu_sessions, + # Legacy alias: archived sidecars and any older reader still key on this name. + "cpu_load_kills": self.high_cpu_sessions, "modules": self.modules, "tsan_kinds": self.tsan_kinds, } @@ -158,6 +168,7 @@ def merge(dicts: list[dict]) -> dict: "sessions": 0, "crashes": 0, "timeouts": 0, + "high_cpu_sessions": 0, "cpu_load_kills": 0, "modules": {}, "tsan_kinds": {}, @@ -174,8 +185,12 @@ def merge(dicts: list[dict]) -> dict: out["mode"] = d["mode"] if d.get("plugins"): out["plugins"] = list(d["plugins"]) - for key in ("sessions", "crashes", "timeouts", "cpu_load_kills"): + for key in ("sessions", "crashes", "timeouts"): out[key] += int(d.get(key, 0) or 0) + # Accept either spelling: a v3 sidecar carries both, a pre-v3 one only the alias. + high_cpu = d.get("high_cpu_sessions", d.get("cpu_load_kills", 0)) + out["high_cpu_sessions"] += int(high_cpu or 0) + out["cpu_load_kills"] = out["high_cpu_sessions"] for name, bucket in (d.get("modules") or {}).items(): acc = out["modules"].setdefault(name, {"hits": 0, "crashes": 0, "timeouts": 0}) for field in ("hits", "crashes", "timeouts"): diff --git a/fusil_use_guide.md b/fusil_use_guide.md new file mode 100644 index 0000000..4b79126 --- /dev/null +++ b/fusil_use_guide.md @@ -0,0 +1,88 @@ +# fusil Guide + +This guide will help you install and use fusil, both locally and on the cloud. + +## Installing fusil locally + +To install fusil locally, follow these steps: +```bash +git clone https://github.com/devdanzin/fusil +cd fusil +# Activate the virtual environment, then +pip install -e . +``` + +Then you'll need to create a `fusil` user, under which the fuzzer will run. You can do this using the `adduser` command: +```bash +sudo adduser fusil +``` + +Installing fusil on the cloud is similar. On the OracleArm machine, fusil is already installed and the `fusil` user is already created. + +## Using fusil + +fusil must be launched by root user: it will create a new fusil instance and run the fuzzer scripts as the `fusil` user. This is done to ensure that the fuzzer runs with the correct permissions and does not interfere with the system. + +The easiest way to use fusil is to launch as a fleet from a root console: +```bash +sudo su # Switch to root user +export F=/home/opc/projects/fusil/fleet/fleet # Point this to the fleet executable +export FLEET_CONF=/home/opc/fleet.conf # Point this to a fleet configuration file +$F up 3 # Launch the fleet with 3 workers +``` + +A fusil fleet is a collection of workers that run fuzzing jobs in parallel. Configuration sets the fuzzed modules/extensions and many other options. Here's an example configuration file: +```bash +TARGET_PYTHON=/home/danzin/venvs/matrix_venvs/main-debug-gil-nojit-asan/bin/python +GIL_MODES="1" # GIL build -> clean ASan UAF/free-stacks +RUNNER_PY=/home/danzin/venvs/matrix_venvs/fusil_debug-ft-nojit_venv/bin/python # has fusil + ptrace +FUSIL_PY=/home/danzin/projects/fusil/fuzzers/fusil-python-threaded +FLEET_DIR=/home/fusil/runs/fusil-apsw_asan_01 + +FUSIL_FLAGS="--discover-in-target --modules=apsw --test-private \ +--classes-number 25 --methods-number 12 --functions-number 30 --objects-number 20 \ +--child-memory-limit-mb 4096 --crash-drain-ms=3000 \ +-v --timeout 60 --success=12000 --fast --exitcode-score=1" + +INSTANCES=6 +MEM_MAX=6G +NICE=10 +LOG=file +``` + +In the OracleArm machine, there are a couple configuration files available in `/home/opc/`. You can use these as a starting point for your own configuration. Here's a concrete example in `/home/opc/fleet_multidict_oom_foreign.conf`: +```bash +TARGET_PYTHON=/home/opc/venvs/new_3.14_venv/bin/python +GIL_MODES="1" # allocation-failure paths; deterministic for the gdb resolver +RUNNER_PY=/home/opc/venvs/release_cpython_venv/bin/python +FUSIL_PY=/home/opc/projects/fusil/fuzzers/fusil-python-threaded +CATALOG=/home/opc/projects/cpython-oom-findings/catalog/known_sites.tsv # CPython sites; apsw/SQLite + # crashes won't match -> oomNEW +INGEST=/home/opc/projects/cpython-oom-findings/scripts/ingest.py +FLEET_DIR=/home/fusil/runs/fusil-multidict_oomforeign_04 + +# Shallow sweep bounds: foreign allocations fault at LOW start indices, so a deep set_nomemory-style +# sweep just burns cycles. Sequences (--oom-seq) matter more here than depth: a failed allocation +# mid-Connection/Cursor lifecycle is exactly the cross-call stale-state class. +FUSIL_FLAGS="--modules=multidict --packages=multidict --test-private \ +--classes-number 25 --methods-number 10 --functions-number 25 --objects-number 20 \ +--child-memory-limit-mb 4096 \ +-v --timeout 60 --success=12000 --fast --exitcode-score=1 \ +--oom-fuzz --oom-verbose --oom-foreign --oom-dedup-prune --oom-dedup-catalog=$CATALOG \ +--oom-seq --oom-seq-randomize --oom-seq-len 8 --oom-window 16 --oom-max-start 50 " +INSTANCES=6 +MEM_MAX=4G +NICE=10 +LOG=file +``` + +Once the fleet is up, you can monitor it using the `$F status` command: +```bash +$F status +``` +To stop the fleet, use the `$F down` command: +```bash +$F down +``` + +The findings will be written to the `$FLEET_DIR` directory. There are many findings in `/home/fusil/runs/fusil-multidict_oomforeign_04`, most of which are uninteresting. diff --git "a/po\303\260\024e\024\303\214\303\212V\302\245\303\276\303\272M\303\235\302\262\302\207\302\2628\026\302\262" "b/po\303\260\024e\024\303\214\303\212V\302\245\303\276\303\272M\303\235\302\262\302\207\302\2628\026\302\262" new file mode 100644 index 0000000..3104423 Binary files /dev/null and "b/po\303\260\024e\024\303\214\303\212V\302\245\303\276\303\272M\303\235\302\262\302\207\302\2628\026\302\262" differ diff --git a/ro.txt b/ro.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/python/test_fleet_report.py b/tests/python/test_fleet_report.py index 1bbee26..0927047 100644 --- a/tests/python/test_fleet_report.py +++ b/tests/python/test_fleet_report.py @@ -27,7 +27,7 @@ def _sidecar(path, **kw): "sessions": 0, "crashes": 0, "timeouts": 0, - "cpu_load_kills": 0, + "high_cpu_sessions": 0, "modules": {}, } data.update(kw) diff --git a/tests/python/test_session_stats.py b/tests/python/test_session_stats.py index 5c7776f..ccf641e 100644 --- a/tests/python/test_session_stats.py +++ b/tests/python/test_session_stats.py @@ -30,10 +30,33 @@ def test_counters_and_per_module(self): self.assertEqual(s.sessions, 4) self.assertEqual(s.crashes, 2) self.assertEqual(s.timeouts, 1) - self.assertEqual(s.cpu_load_kills, 1) + self.assertEqual(s.high_cpu_sessions, 1) self.assertEqual(s.modules["json"], {"hits": 3, "crashes": 2, "timeouts": 0}) self.assertEqual(s.modules["sqlite3"], {"hits": 1, "crashes": 0, "timeouts": 1}) + def test_high_cpu_key_is_emitted_under_both_names(self): + """v3 renamed the counter; the old key stays as an alias. + + `cpu_load_kills` was always a misnomer -- `CpuProbe` only scores and renames the + session, and the Python fuzzer zeroes its score unless --record-high-cpu, so nothing + is killed or discarded. Renaming it outright would break archived sidecars and any + reader still keying on the old name, so both are emitted. + """ + s = self._stats() + s.record("json", cpu_load=True) + d = s.to_dict() + self.assertEqual(d["high_cpu_sessions"], 1) + self.assertEqual(d["cpu_load_kills"], 1) + + def test_merge_reads_pre_v3_sidecars(self): + """A sidecar written before the rename carries only the alias; merge must still count it.""" + legacy = {"sessions": 10, "crashes": 1, "timeouts": 2, "cpu_load_kills": 5} + current = {"sessions": 10, "crashes": 1, "timeouts": 2, "high_cpu_sessions": 3} + out = SessionStats.merge([legacy, current]) + self.assertEqual(out["high_cpu_sessions"], 8) + # the alias mirrors it, so an old reader aggregating a mixed fleet still agrees + self.assertEqual(out["cpu_load_kills"], 8) + def test_tsan_kinds_counted(self): # Slice B: --tsan sessions record their shared-object composition; non-tsan sessions # (tsan_kind=None) never touch the counter. @@ -84,6 +107,7 @@ def test_to_dict_schema(self): "sessions", "crashes", "timeouts", + "high_cpu_sessions", "cpu_load_kills", "modules", ): diff --git a/tests/python/test_stats_agent.py b/tests/python/test_stats_agent.py index e0c5c3a..d6f0c40 100644 --- a/tests/python/test_stats_agent.py +++ b/tests/python/test_stats_agent.py @@ -112,7 +112,7 @@ def test_timeout_and_cpu_load_via_rename(self): agent.on_session_start() # resets rename parts agent.on_session_rename("cpu_load") agent.on_session_done(0.0) - self.assertEqual(agent.stats.cpu_load_kills, 1) + self.assertEqual(agent.stats.high_cpu_sessions, 1) def test_tsan_kind_recorded_from_source(self): # Slice B: StatsAgent reads the --tsan shared-object composition off the PythonSource and diff --git "a/\263\023\303\301%\023b\341\204\022\366\254\365\225E\343" "b/\263\023\303\301%\023b\341\204\022\366\254\365\225E\343" new file mode 100644 index 0000000..7dfd720 Binary files /dev/null and "b/\263\023\303\301%\023b\341\204\022\366\254\365\225E\343" differ diff --git "a/\302\235\302\201" "b/\302\235\302\201" new file mode 100644 index 0000000..c80ad0c Binary files /dev/null and "b/\302\235\302\201" differ diff --git "a/\303\255\"\0010\303\252\023f\020\303\217\302\216\n\002\302\256<\303\210\303\242" "b/\303\255\"\0010\303\252\023f\020\303\217\302\216\n\002\302\256<\303\210\303\242" new file mode 100644 index 0000000..3104423 Binary files /dev/null and "b/\303\255\"\0010\303\252\023f\020\303\217\302\216\n\002\302\256<\303\210\303\242" differ diff --git "a/\345\234\266\341\217\272\356\255\243\355\226\226\356\264\270\345\256\225\345\234\235\340\255\235\357\266\225\347\270\233\354\244\240\341\213\226\341\255\260\346\244\251\347\216\226\353\204\222\342\260\260" "b/\345\234\266\341\217\272\356\255\243\355\226\226\356\264\270\345\256\225\345\234\235\340\255\235\357\266\225\347\270\233\354\244\240\341\213\226\341\255\260\346\244\251\347\216\226\353\204\222\342\260\260" new file mode 100644 index 0000000..06d9f1f Binary files /dev/null and "b/\345\234\266\341\217\272\356\255\243\355\226\226\356\264\270\345\256\225\345\234\235\340\255\235\357\266\225\347\270\233\354\244\240\341\213\226\341\255\260\346\244\251\347\216\226\353\204\222\342\260\260" differ