Commit Graph

2 Commits

Author SHA1 Message Date
Conor Luddy 3c71ca938f feat: --raw-capture NDJSON mode + size cap + gzip + aggregate prune (closes #85)
The summarised pipeline (parse → bucket → cluster) is the right shape when
you trust the bucketing, but sometimes you want full-fidelity log data and
ad-hoc jq exploration. --raw-capture spawns 'log stream --style ndjson' and
dumps lines verbatim to raw.ndjson, bypassing the clustering pipeline.

Garbage collection is automatic and layered so a year of daily use ≈ a day:
- Per-session size cap (--max-size-mb, default 10) — worker stops cleanly on
  cap hit, writes size_cap_hit marker, sets extras.truncated.
- Gzip on stop (skippable with --no-gzip) — NDJSON compresses ~15-19x in
  practice. Verified live: 0.96 MB raw → 0.05 MB gzipped on Grapla today.
- TTL prune (existing IOS_SIM_HANG_SESSION_TTL_HOURS=24) runs on every
  --start. New aggregate cap IOS_SIM_HANG_TOTAL_CAP_MB (default 100) runs
  alongside, evicting oldest sessions first when total skill state exceeds.

Verified end-to-end on Grapla: a 12s session captured 737 raw NDJSON lines.
'jq -s group_by(.processImagePath)' shows runningboardd=544, SpringBoard=83,
mediaremoted=39, locationd=39, grapla=28. The grapla-process share (4%) is
exactly why the --bundle-id AND clause in #83 was wrong: it filtered out
96% of useful hang signal originating from system daemons.

Implementation:
- New args: --raw-capture, --max-size-mb, --no-gzip; new env var
  IOS_SIM_HANG_TOTAL_CAP_MB. Plumbed through start_args into meta.args.
- run_worker grew a raw-capture branch that reuses the #84 restart/crash
  machinery: spawn log stream --style ndjson, feed to _read_stream_into_raw,
  which dumps lines verbatim and bails on size cap.
- Non-JSON banners (log stream's 'Filtering the log data ...' first line,
  pwuid_r warnings) are skipped so consumers get strict NDJSON for jq.
- _stop_raw_session gzips raw.ndjson → raw.ndjson.gz (via stdlib gzip +
  shutil.copyfileobj), removes the uncompressed file, records bytes in
  meta.extras. No summary.json for raw sessions.
- _mark_truncated sets extras.truncated when the size cap fires.
- SessionStore.session_total_bytes + prune_to_aggregate_cap: sum sizes
  per session, evict oldest first until total ≤ cap.
- --list-sessions surfaces raw size, truncated flag, capture duration.
- --get-details on a raw session points to the file with a zcat | jq hint.

Tests: 7 new (in tests/test_worker_loop.py) — raw line writing, size-cap
trigger, gzip round-trip + content preservation, --no-gzip preserves uncompressed,
aggregate-cap eviction order, session_total_bytes. 131 → 138 passing.
2026-05-23 11:28:38 +01:00
Conor Luddy 073dc67a42 fix+feat: HangBuster worker EOF detect + bounded auto-restart + capture duration (closes #84)
Pre-fix: log stream EOF after 30s of silence broke the read loop, wrote a
stream_ended event, and exited. SessionStore.persist_worker_counters then
overwrote the status back to 'running' — sessions appeared live forever in
--list-sessions even though the worker had been dead for hours. Verified on
Grapla: a 19s session showed counters.total=1, status='running' indefinitely.

Fix split across three concerns:

1. Crash detection (run_worker rewrite):
   - Removed the 30s silence bail. A quiet log stream is not a dead one;
     bailing on silence was a false-positive death signal.
   - EOF on stdin or proc.poll() != None now triggers a stream_died event
     with exit_code and attempt number.
   - try/finally wrapping ensures mark_crashed runs whenever the worker
     exits involuntarily (subprocess died, restart budget exhausted).
   - Inner read loop extracted into _read_stream_into_events for clarity
     and direct testability.

2. Bounded auto-restart:
   - New env var IOS_SIM_HANG_MAX_RESTARTS (default 3). Each restart logs
     a stream_restart event with attempt number and a 2s backoff prevents
     tight respawn loops.
   - counters now tracks stream_restarts; surfaces in summary + --list-sessions.
   - SIGTERM during backoff or read loop exits cleanly without restart.

3. Capture-duration tracking (hang_sessions.py):
   - mark_crashed now also records stopped_at/stopped_at_ms — so crashed
     sessions have a real end-of-capture timestamp, not just a status flip.
   - persist_worker_counters preserves CRASHED status (alongside STOPPED)
     and no longer clobbers it back to RUNNING.
   - build_summary prefers meta.stopped_at_ms over now() so summaries
     reflect the capture window, not the time of inspection.
   - --list-sessions surfaces 'capture=Xs' and 'restarts=N' columns.

Tests: 8 new (5 in tests/test_worker_loop.py, 3 in tests/test_hang_sessions.py).
Fake subprocess.Popen drives EOF/restart/exhaustion scenarios deterministically.
123 → 131 passing.
2026-05-23 11:12:57 +01:00