Skip to content

M1: extract the core metrics layer - #1

Merged
Fatin-Ishraq merged 4 commits into
mainfrom
m1/core-extraction
Aug 16, 2026
Merged

Fatin-Ishraq merged 4 commits into
mainfrom
m1/core-extraction

Conversation

@Fatin-Ishraq

Copy link
Copy Markdown
Owner

Moves metric collection out of the widgets and onto a worker thread. This is
milestone M1 of the v2 plan.

No visible redesign — same layout, panels, themes and keys. What changes is
that the numbers agree with each other and the UI stops hitching.

What this fixes

Problem Before After
Inconsistent numbers The CPU panel, Insight, the main view and the process table each called cpu_percent() in the same refresh — four different CPU numbers could be on screen at once One Snapshot per tick, shared by every panel
UI stutter Reading /proc, walking the socket table and stat-ing mount points all ran on the render thread; zero workers in the codebase Sampling runs on a worker; an overrunning tick is skipped, not queued
Wrong rates Each panel tracked its own counters and divided by an assumed 1s, so a 4s refresh read 4× high Derived once from the real elapsed time between snapshots
Jumping cursors DataTables were cleared and rebuilt every tick, with cursor restore that mostly didn't hold Rows diffed and updated in place
Silent failures A subsystem that failed to sample was swallowed by a bare except Degrades its own panel and is reported in the Insight view
Aether overhead Sampled psutil itself at 20 FPS Takes metrics from the shared snapshot

Architecture

pulse/core/           # no Textual or Rich imports
├── models.py         # frozen dataclasses; one tick is one Snapshot
├── sources/          # system.py (real) · mock.py (deterministic)
├── sampler.py        # one poll -> one Snapshot, failures isolated per subsystem
└── store.py          # latest Snapshot + history, rates, peaks

Panels read self.snapshot / self.history / self.rates from the base class
and sample nothing.

Testing

The source is injectable, so the whole UI can be driven from a MockSource
with no real system access — which is why the panels are testable at all now.

  • 199 tests, up from 94
  • Coverage 39% → 73%; CI floor raised to 65%, with 85% on the action/config
    modules and a new 90% gate on pulse.core
  • Architectural tests parse imports and fail if anything under panels/,
    screens/ or aether/ reaches for psutil or subprocess, or if pulse.core
    imports a UI library
  • Integration tests assert the properties directly: every panel shares one
    snapshot object, each subsystem is polled exactly once per tick, sampling
    happens off the event loop thread, and snapshots are applied back on it

Not in this PR

The full-screen views still probe panels with hasattr for eight optional
methods, and the keymap still has collisions (f means three things, r means
four). That is M3.

🤖 Generated with Claude Code

Fatin-Ishraq and others added 4 commits August 17, 2026 00:43
Introduces pulse.core: immutable models, pluggable sources, a fault-isolating
sampler, and a store that owns history and derived rates. Nothing in the
package imports Textual or Rich, so it is testable with no real system access
and reusable by a future exporter or headless mode.

- models: frozen dataclasses; one tick is one Snapshot
- sources: SystemSource (the only module that touches psutil/direct_os) and a
  deterministic MockSource
- sampler: reads each subsystem independently, so a restricted net_connections
  degrades its own panel instead of blanking the tick
- store: rates derived once per tick from real elapsed time, rather than each
  panel tracking its own counters and assuming a one-second interval

No UI changes yet; panels still sample directly. 75 tests, 95% coverage on the
package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports every panel off direct psutil calls and onto the shared store, so one
tick produces one Snapshot that the whole UI renders from.

What this fixes:
- Four panels used to call cpu_percent() independently in the same refresh,
  so four different CPU numbers could be on screen at once. Now there is one.
- Sampling blocked the event loop: reading /proc, walking the socket table and
  stat-ing mount points all ran on the render thread. It now runs on a worker,
  and an overrunning tick is skipped instead of queueing behind itself.
- Each panel derived its own rates against an assumed one-second interval.
  Rates are now computed once from the real elapsed time between snapshots.
- DataTables were cleared and rebuilt every tick with cursor restore that
  mostly did not hold. Rows are diffed and updated in place.
- A failing subsystem was swallowed by a bare except. It now degrades its own
  panel and is reported in the Insight view.
- The Aether visualisation sampled psutil at 20 FPS on its own; it now takes
  metrics from the shared snapshot.

Because the source is injectable, the entire UI can be driven from a
deterministic MockSource with no real system access. That is what took
coverage from 39% to 73% - the panels were previously untestable.

Architectural tests parse imports and fail if anything under panels/, screens/
or aether/ reaches for psutil or subprocess, or if pulse.core imports Textual
or Rich.

199 tests (was 94). CI floor raised to 65%, with 85% on the action and config
modules and 90% on pulse.core.

No visible redesign: same layout, panels, themes and keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_rates_are_available_after_two_ticks raced the app's own refresh timer: if
an interval fired between advancing the mock machine and taking the sample,
both snapshots landed on the same mock tick, the counter delta was genuinely
zero, and the rate assertion failed. It passed on ubuntu 3.10 and windows 3.12
purely on timing.

The fault was in the test, not the sampling code. Tests now freeze the app
after boot and drive each sample through a `tick()` helper that waits on the
worker, so the two snapshots being compared are the two the test produced.

Also adds the inverse case: an idle machine must report a zero rate rather
than noise.

Verified with six consecutive runs of the integration suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SystemSource passed an unsorted limit straight to direct_os, so on a host with
more processes than the cap it kept an arbitrary prefix - the 300 lowest PIDs.
Every high PID was dropped, including the heaviest processes the panel exists
to show, and Pulse itself.

The snapshot now holds the union of the heaviest processes by CPU and by
memory, so whichever way a view sorts, what it wants is present. The expensive
per-process psutil lookups run only for that selection rather than for an
arbitrary slice.

Caught by the macOS CI jobs, where the runner has enough processes to cross the
cap; ubuntu and windows runners stayed under it and hid the bug. Added tests
that reproduce it directly with a synthetic oversized machine, so it no longer
depends on how busy the runner happens to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Fatin-Ishraq
Fatin-Ishraq merged commit 580138d into main Aug 16, 2026
12 checks passed
@Fatin-Ishraq
Fatin-Ishraq deleted the m1/core-extraction branch August 16, 2026 19:28
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.

1 participant