Skip to content

feat(gui): Runtime Monitor Memory tab — process memory over time (#2554) - #5397

Open
skerker wants to merge 10 commits into
aethersdr:mainfrom
skerker:feat/2554-memory-tab
Open

feat(gui): Runtime Monitor Memory tab — process memory over time (#2554)#5397
skerker wants to merge 10 commits into
aethersdr:mainfrom
skerker:feat/2554-memory-tab

Conversation

@skerker

@skerker skerker commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Part of #2554 — the Memory tab, the third increment of the Runtime Monitor after Threads and Logs (#5246). The dialog gains a Memory tab: a summary line naming what "resident" means on this platform and how many samples the history holds, four readouts (Resident, Peak, Private, Virtual), a timeframe selector (1 min / 5 min / 15 min / 1 hour) and a chart of Resident, Private and Peak over the selected window on the same TimeSeriesGraphWidget the network dialog uses.

The reading comes from the collector the Threads tab already runs on its own thread at 1.5 s: each tick now also emits a MemorySample (a flat copy of ProcessMemorySnapshot::capture(), #4293's telemetry) queued to the GUI, before the thread-table enumeration and its early return, so a platform whose thread enumeration fails still gets a Memory tab. The history is a bounded gui-side ring (MemoryHistoryRing, 2400 samples = one hour raw; 1 s points to five minutes, bucket averages of max(5 s, range/300) beyond, placed at the bucket centre as NetworkDiagnosticsDialog::updateCharts() places its own). MainWindow owns the ring and hands it to the dialog, because the dialog is WA_DeleteOnClose and a trend chart that forgot everything on Close would not be a trend: Close and reopen shows what was sampled before, with a break in each line where nothing was sampled.

What the platform did not measure is shown as a dash, never as 0.0 MB: Windows never reports virtual bytes, Linux reports no private bytes without /proc/self/smaps_rollup, and an invalid sample reports nothing — those readouts read "—", the chart skips them, and an invalid sample's summary names no metric. Acceptance criterion 4 ("the chart must move down as well as up") holds: resident memory is the current footprint, not a peak, and the demo run below shows it dropping.

Deliberate choices, disclosed:

  • The collector samples only while the dialog is open (an always-on enumeration every 1.5 s is observer effect on the thing being measured — the question left open in my 08-25 comment; a maintainer who wants always-on history can start the collector from MainWindow instead, the ring is already there). Samples accrue only while open; the "N samples" count is the ring's size.
  • The timeframe selector lives on the Memory tab, not at the window level as the issue sketches: Threads has a fixed 60 s window and Logs none, so a window-level control would be blank on two of three tabs. It is not persisted, like the network dialog's.
  • Not in this increment: the major page-fault rate (no counter in MemoryTelemetry), the build-flagged heap breakdown, and the seven-day compacting history — those stay with the Overview increment, as in my 08-27 comment.
  • On macOS, Peak is resident_size_peak while Resident is phys_footprint ([automation] Add cross-platform subsystem memory telemetry to the automation bridge #4293's accounting); the two are not the same metric, which is why Peak reads well above Resident there — the readout's tooltip says so.
  • Beyond five minutes the newest partial bucket is not drawn until its 5 s / 12 s edge passes; that is the network dialog's identical rule, kept so the two charts read alike. The readouts always show the newest sample.
  • One shared-widget change: TimeSeriesGraphWidget now computes its left-gutter value hints before the axis ticks and does not paint a tick label that would sit under a hint (previously the label was painted and then partially covered — overlapping digits, visible with three series close together). NetworkDiagnosticsDialog inherits this: a tick label within 18 px of a hint is no longer painted there either.
  • No new gui→core header include (AGENTS.md): the ring keeps its own record and its own copy of the 1.5 s cadence, pinned to the collector's by a static_assert in SystemInfoDialog.cpp; the collector include moves from SystemInfoDialog.cpp to .h. tools/gen_touchpoint_manifest.py --check is up to date on this branch.

CHANGELOG.md is not touched (release-prep file).

Constitution principle honored

Principle VIII — Evidence over assertion: every behavioural claim above is backed by a measured run on the demo simulator or a test that fails when the behaviour is removed; the "dash, not 0.0 MB" rule exists because the tab must not present a number the platform never produced.

Test

Three socket-free targets, declared in tests/tests.cmake (none runs in a PR CI lane; local results below):

  • memory_history_ring_test — retention and eviction order (which sample survives), raw window slicing at both edges, the bucket rule shared with the network dialog, exact bucket averages on full buckets and bucket-centre placement, the line-break threshold per timeframe, and that invalid or zero-valued records are not plotted. All samples constructed (arithmetic and routing only).
  • system_info_collector_test — the real thread wiring (parentless collector moved to a QThread, init() on started, the 1.5 s timer): a memory sample arrives on the caller's thread within 5 s, is timestamped between start and receipt, names one of the snapshot's platform metrics, and on a supported platform is valid with non-zero resident bytes. Measured on the live test process; the peak ≥ resident check is skipped on macOS where the two are different metrics.
  • system_info_dialog_test (existing target, extended) — the Memory tab's widgets and defaults, readouts and summary driven through applyMemorySample() with constructed samples, the chart moving down, timeframe changes, hide/show keeping the history, a Windows-shaped sample (shape from MemoryTelemetry.cpp's Windows branch, values constructed) reading a dash for the unset field, an invalid sample reading four dashes and a metric-less summary, the accessible-name announcement, and two dialogs sharing one injected ring across the first's destruction.

Mutation checks (each restored with the inverse edit, git diff --stat empty, rebuilt green): collector emit dropped → arrival check fails; ring eviction disabled → 3 failures; bucket averaging replaced by last-sample-wins → 1 failure; bucket centre moved to the start → 1; unplottable rule forced true → 2; dialog ring push skipped → 6; dash rule removed → 3; injected ring ignored → 2.

Full local ctest on b15b6a60, the whole registered suite of 341: 335 passed, 3 skipped, 3 failed — bridge_docs_check (stale verb table) and phone_tx_filter_numeric_entry_test (subprocess abort under QT_QPA_PLATFORM=offscreen) fail identically on the merge base; hl2_state_restore_test is #5394, reported failing on main at 10a847b7 the same day. None touches this change.

Proof — agent automation bridge, demo simulator

b15b6a60, clean RelWithDebInfo build (macOS Intel, Qt 6.8.3; About-dialog SHA checked), DEMO-0001, isolated settings store, TX automation off; readouts are the tab's labels read back over the bridge (dumpTree), the count is the summary line.

Step Reading
Open Help → Runtime Monitor → Memory 27 samples at the first reading (~40 s open), three lines at the right edge, Timeframe 5 minutes
Close, wait 12 s, reopen 35 → 46 → 57: the count continues; the chart shows the earlier segment, a gap, the new segment
Disconnect with the tab open 67 → 89 samples, radio connected=false: sampling does not depend on the radio
Reconnect Resident steps down (314.4 → 278.0 MB) — the chart moves down as well as up
Timeframe 1 minute / 15 minutes / 1 hour three lines drawn in each; 1 minute shows the reconnect drop, 15 minutes and 1 hour the two segments and the gap; readouts unchanged by the switch (278.2 / 278.7 / 278.9 MB, the newest sample each time)
Quit with the dialog open process exits; no crash report in ~/Library/Logs/DiagnosticReports

Earlier heads, same rig, one line each: f089456a (pre-review) — close/reopen 37 → 51 → 71 samples; disconnect 71 → 87 → 102; footprint 323 → 287 MB on reconnect; 1-minute, 5-minute and 1-hour views with 784 samples. 7d886163 — the tick-label fix confirmed on the 1-hour view where the digits had overlapped.

Screenshots below: the About dialog with the SHA, the Memory tab after close/reopen (the earlier segment, the gap, the new segment), and the 1-hour view. Evidence bundle aethersdr-pr5397-memory-tab-2026-09-03.zip (attached below; sha256 2be257bd34d03ee9234210a6aae2e13c121f45b21b623918f8dd7b1287ecc6f1): the run transcript with every readout, the whole unedited ctest log, the app log and the five captures, with a README mapping each claim above to its file. Sanitized; no radio, no RF.

aethersdr-2554-about-b15b6a60-2026-09-03 aethersdr-2554-memory-tab-after-reopen-b15b6a60-2026-09-03 aethersdr-2554-memory-tab-1hour-b15b6a60-2026-09-03

aethersdr-pr5397-memory-tab-2026-09-03.zip

What I tried to break

  • Teardown order: MainWindow destroys the ring before its child dialog; the dialog's destructor and hide path never touch the ring (read), and quitting with the dialog open left no crash report (run).
  • The queued sample after hide is dropped by the sampling-generation guard; a dialog given no ring uses its own; two dialogs sharing one ring across the first's destruction (test).
  • Window edges inclusive at 1 s resolution (41 points for 60 s at 1.5 s), no negative x for any offered range, bucket-aligned cutoffs; wall-clock steps and sleep/wake reasoned only — the x axis is wall time on purpose, so a 3 h sleep shows as a gap, not spliced shut.
  • Every MemoryTelemetry.cpp platform branch read for which fields it leaves at zero and when valid is false — that reading is where the dash rule came from.
  • Not driven: Windows (workingSet) and Linux (VmRSS) readouts on their own platforms — reasoned from the telemetry source and pinned by the shaped samples in the dialog test.

Test plan

  • Local build passes (cmake --build build) — clean RelWithDebInfo, macOS Intel, Qt 6.8.3, every head
  • Behavior verified on a real radio if applicable — n/a: process memory of the client itself; the demo run shows sampling is independent of the radio connection
  • Existing tests pass (CI); the three targets above run locally: pass, and fail under the listed mutations
  • Reproduction steps documented if user-reported bug — n/a, feature increment

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md) — SSH
  • No new flat-key AppSettings calls — n/a, no settings touched (the timeframe is not persisted, like the network dialog's)
  • Code is clean-room — yes
  • All meter UI uses MeterSmoothern/a, no meter UI (readouts are 1.5 s samples, not meters)
  • Documentation updated if user-visible behavior changed — docs/architecture/pipelines.md: the collector-thread line now says it samples CPU and process memory. No user doc covers the Runtime Monitor yet. CHANGELOG.md not touched per the template
  • Security-sensitive changes reference a GHSA — n/a

— authored by agent (Claude Code) on behalf of @skerker

skerker and others added 10 commits September 2, 2026 23:08
). Principle VIII.

The Memory tab needs a memory reading on the same 1.5 s tick as the thread
table, taken off the GUI thread for the same reason. The collector now calls
ProcessMemorySnapshot::capture() once per tick — before the thread enumeration
and its early return, since a memory reading needs no previous sample and no
thread table — and publishes a flat MemorySample (wall-clock ms, validity,
the platform's resident-metric name, resident / peak / private / virtual
bytes) through a new queued signal, memorySampleReady. The struct lives in
the collector's own header so the dialog needs neither MemoryTelemetry.h nor
a JSON round-trip to draw a chart; no new gui→core include.

Test: system_info_collector_test drives the real wiring (parentless collector
moved onto a QThread, init on started, the timer) and reads the live process:
a sample arrives within 5 s, is wall-clock stamped between start and receipt,
names one of the snapshot's metrics, and reports non-zero resident bytes with
peak >= current on a supported platform. Mutation: with the emit dropped the
test fails on arrival. system_info_dialog_test links MemoryTelemetry.cpp now
that the collector calls it.

Part of aethersdr#2554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
…hart slicing (aethersdr#2554). Principle VIII.

A header-only, gui-side ring of MemorySample readings: one hour at the
collector's 1.5 s cadence (2400 samples, the selector's longest window),
plus the slicing that turns it into TimeSeriesGraphWidget points for a chosen
timeframe. The slicing copies NetworkDiagnosticsDialog::updateCharts()'s rule
— one-second raw points up to five minutes, bucket averages of
max(5 s, range/300) beyond, x measured from the window's cutoff — so the two
dialogs' charts read alike; bucketMsFor() is exposed so a test pins the rule
and the dialog cannot drift from it.

Why not src/core: a new core header a gui file includes is one more gui→core
touchpoint for the aetherd burndown, for a class only the dialog reads. The
seven-day compacting history the issue describes stays with the Overview
increment; this holds the raw hour and nothing more.

Test: memory_history_ring_test (pure logic, CONSTRUCTED samples) — empty ring,
eviction at capacity keeping the newest, 1 s window slicing with x from the
cutoff and every field in MB, the bucket rule at 1/5/15/60 min, and an hour
bucketing to ~300 averaged points. Mutations: eviction disabled → the
capacity check fails; bucket divisor 300→3000 → the point-count check fails.

Part of aethersdr#2554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
…ethersdr#2554). Principle VIII.

Acceptance criterion 4, "Memory tab shows RSS trending over the selected
timeframe". The tab is TimeSeriesGraphWidget's first consumer outside the
network dialog: Resident, Private and Peak in MB over the selected window,
fed from MemoryHistoryRing by the collector's memorySampleReady signal.
Above the chart, four readouts (Resident / Peak / Private / Virtual) show
the numbers, and the summary line names what "resident" measures on this
platform (physical footprint / working set / VmRSS). Virtual is a readout
only — on the chart its scale would flatten the three lines that move.

The timeframe selector mirrors the network dialog's (objectName
systemInfoTimeframe, the same accessible name, not persisted) with the
issue's four windows — 1 min / 5 min / 15 min / 1 hour, defaulting to
5 minutes — and lives on this tab: Threads has a fixed 60 s window and Logs
none, which the network dialog handles by hiding its combo on those pages.
The chart's window ends at the newest sample rather than the wall clock, so
paused sampling shows the history in place; a break of three missed samples
is drawn as a gap. The memory ring is deliberately NOT cleared when sampling
stops (unlike the 60 s CPU ring): a trend chart is honest about a gap, so
history accrues for the life of the dialog while it is open.

Series colours come from theme tokens (the hardcoded-colour ratchet holds);
every new widget has an accessible name.

The line-break threshold scales with the bucket: three samples' worth at raw
resolution, three buckets' worth beyond five minutes
(MemoryHistoryRing::connectGapSecondsFor). The first smoke of this commit
found the 1-hour view drawing nothing — a fixed 4.5 s threshold isolated
every 12 s bucket point — and the ring test now pins the rule at all four
timeframes.

Test: system_info_dialog_test gains the tab — three tabs in order, the
selector's four items and default, readouts starting as a dash, a driven
sample formatting each field in MB and naming the metric, a second sample
moving the readouts DOWN, the sample count in the summary, a timeframe change
leaving the readouts alone, and hide/show keeping the history. Mutations:
with the ring push skipped, six checks fail (the readouts read from the ring,
not the argument); with the gap threshold fixed at 4.5 s, three ring checks fail.

Part of aethersdr#2554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
…not painted (aethersdr#2554). Principle VIII.

The left gutter carries two kinds of label: the fixed axis ticks and each
visible series' live-value hint at the height of its latest sample. The
hints already spread away from each other, and each is painted over a
gradient patch whose comment promised to "hide whatever decade tick may sit
at the same y" — but the patch's opaque band is 6 px and a tick label is
16 px, so the tick text ghosted out above and below the hint ("237 MB" over
"250 MB", "971 MB" under "1.0k MB"). The Memory tab made it obvious: memory
lines sit still, so a hint parks on a tick for minutes.

The hints are now positioned before the ticks are painted, and a tick whose
label rectangle intersects a hint's keeps its grid line but loses its text —
the live value wins. No colour, layout or data change; the network dialog's
charts get the same fix.

Verified by screenshot on the demo (the widget has no paint test, and a
pixel test is not worth adding for a label rule); the two dialog tests and
the network dialog's object compile and pass against the changed header.

Part of aethersdr#2554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
…). Principle VIII.

Every persistent dialog here is created by showOrRaisePersistent(), which
sets WA_DeleteOnClose: the Close button destroys the Runtime Monitor and the
menu builds a fresh one next time. With the memory ring owned by the dialog,
the first demo smoke read "3 samples" after Close and reopen — a trend chart
that forgot everything on Close. The issue models the history on
NetworkDiagnosticsHistory, which MainWindow owns and which outlives its
dialog, so the memory ring now does the same: MainWindow owns it (created
beside the network history) and hands it to the dialog's constructor;
a null history means "use your own", which is what the tests do. A reopened
dialog refreshes from the ring at construction, so the chart shows what was
sampled before the first new sample arrives. Sampling itself still follows
visibility, so the history has a gap where the dialog was closed.

Test: two dialogs sharing one ring — samples driven into the first land in
the shared ring, the second shows "2 samples" and the last resident value
before any sample of its own, and a dialog given no history starts empty.
Mutation: with the injected ring ignored, the first two checks fail.

Part of aethersdr#2554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
…b adds no gui→core includer (aethersdr#2554). Principle VIII.

The ring included core/SystemInfoCollector.h for MemorySample and the
collector's cadence constant, which made it a second gui includer of that
engine header: tools/gen_touchpoint_manifest.py --check (a CI gate since
aethersdr#5109) reports the manifest stale on the merge result. The ring now stores
its own flat Record, SystemInfoDialog::applyMemorySample() converts, and the
cadence is repeated in the ring with a static_assert in SystemInfoDialog.cpp
— the one TU that sees both headers — pinning it to
SystemInfoCollector::kSampleIntervalMs. The collector include moves from
SystemInfoDialog.cpp to SystemInfoDialog.h (the slot's parameter type, as
core/SystemInfo.h already is for ThreadCpuSample); the header's includer
count stays at one and the manifest needs no regeneration.

Measured: manifest check 'up to date' on the branch; EB3 --strict 0 blocking;
memory_history_ring_test / system_info_dialog_test / system_info_collector_test
green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
…ring_test; compare like metrics in system_info_collector_test (aethersdr#2554). Principle VIII.

memory_history_ring_test case 5 tolerated 51 MB around the expected
average, which a last-sample-wins mutation of the bucket loop survived
(2026-09-03 self-review). Full 12 s buckets hold exactly 4 × 150 + 4 × 250,
so interior points are now checked to 1e-6 and that mutation fails the test
(measured: 1 failure). Case 2 said which sample survives eviction without
asserting it; a raw window whose cutoff is i = 4's timestamp now has to
begin at i = 5 (measured: eviction disabled → 3 failures).
system_info_collector_test compared peakResidentBytes with residentBytes,
which on macOS are resident_size_peak and phys_footprint — two accountings
with no ordering between them; the check is skipped when residentMetric is
physicalFootprint and kept where peak and resident are the same metric.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
…sdr#2554). Principle VIII.

On macOS MemoryTelemetry reports peak as task_vm_info.resident_size_peak
and resident as phys_footprint (aethersdr#4293); the two are different accountings,
which is why Peak reads three to four times Resident there. The tooltip now
says so instead of implying Peak is the maximum of the figure beside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
…orm did not measure (aethersdr#2554). Principle VIII.

MemoryTelemetry's Windows branch never fills virtualBytes, Linux leaves
privateBytes at zero without /proc/self/smaps_rollup, and an invalid sample
fills nothing — yet the readouts printed every field as a number and the
chart drew zero lines (found by the adversarial review pass; this Mac fills
every field, so the smoke could not see it). Zero is never a real reading
for any of the four fields, so it means unset: such a readout is a dash,
MemoryHistoryRing::series() skips invalid and zero-valued records, and an
invalid sample's summary names no metric (Linux sets residentMetric even
when the VmRSS read failed).

Also in this commit, from the same pass: bucket points sit at the bucket
CENTRE as NetworkDiagnosticsDialog::updateCharts() places its own (the ring
claimed to follow it and did not); each readout announces its new value
after setText the way docs/a11y.md asks for live values (accessibleName =
caption + value, then NameChanged — the pattern MainWindow already uses;
1.5 s is far below the doc's throttle threshold); the chart tooltip no
longer attributes every line break to the dialog being hidden (sleep and
late ticks break it too); the hide/show test accepts >= 2 samples because
show() starts the real collector thread and a slow lane could add one.

Tests: ring case 5 pins the centre; new ring case 7 pins the skips; the
dialog test drives a Windows-shaped sample (shape sourced from the Windows
branch of MemoryTelemetry.cpp, values constructed) and an invalid one.
Mutations measured: plottable() forced true → 2 failures; bucket start
→ 1; dash rule removed → 3 dialog failures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
…ss memory (aethersdr#2554). Principle VIII.

pipelines.md described the collector as a per-thread CPU sampler; since the
Memory tab it also takes one ProcessMemorySnapshot per tick, emitted as
memorySampleReady before the thread enumeration and its early return.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ueQQyAUaDyySskBJAPShD
@skerker
skerker marked this pull request as ready for review September 3, 2026 14:51
@skerker
skerker requested a review from a team as a code owner September 3, 2026 14:51

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue fit

Partially — and correctly so. #2554 specs five tabs (Overview / Threads / Memory / Painters / Logs); Threads and Logs already landed, and this PR delivers acceptance criterion 4 ("Memory tab shows RSS trending over the selected timeframe") plus the issue's four timeframes. The header comment in MemoryHistoryRing.h is explicit that the compacting seven-day SystemInfoHistory the issue sketches belongs to the Overview increment, which is the honest framing — this ring holds the selector's longest window raw and nothing more. Nothing the issue asks of a Memory tab is missing, and the sampling is placed off the GUI thread as the issue's implementation sketch wanted.

Test layer is right: MemoryHistoryRing slicing is pure math in a socket-free CTest, the collector test drives the real moveToThread + queued-signal wiring, the dialog test drives applyMemorySample as a slot. All three are registered in tests/tests.cmake (lines 4411, 4426, 3628). All four checks are green on b15b6a6 (build, check-macos, check-windows, Static checks).

Socket tests: none added, none removed, none modified. I grepped the three added/changed test sources for QTcpServer / QUdpSocket / QLocalServer / bind / listen / connectToHost / Fake* — no hits. system_info_dialog_test does start a real QThread (via show()), but no socket. Nothing to surface here.

Scope

File / group What it changes Claimed? Verdict
docs/architecture/pipelines.md one line: the collector thread also samples memory yes In scope
src/core/SystemInfoCollector.{h,cpp} MemorySample, memorySampleReady, one capture() per tick before the enumeration early-return yes In scope
src/gui/MemoryHistoryRing.h new bounded ring + slicing yes In scope
src/gui/SystemInfoDialog.{h,cpp} Memory tab, readouts, timeframe combo yes In scope
src/gui/MainWindow.{h,cpp} owns the ring, injects it through showOrRaisePersistent yes In scope
src/gui/TimeSeriesGraphWidget.h hoists the value-hint pass above the tick loop; a tick label under a hint is no longer painted own commit 09d2877, not in the file list rationale In scope for the feature, but it changes a widget NetworkDiagnosticsDialog also paints with — see nit 2
tests/*, tests/tests.cmake 2 new targets, 1 extended yes In scope

No CHANGELOG.md entry (correct). No new settings keys, no protocol/wire surface, no removed guards — I read the - lines: the only deletion is the value-hint block being moved intact, plus a comment sentence replaced by one that describes the new behaviour.

CodeGuard: the five CG-PATH-001 hits are all at src/gui/MainWindow.cpp:8875–9108. This PR touches MainWindow.cpp only at lines ~106, ~1428 and ~4197 (three hunks). Those findings are pre-existing code the diff does not go near — refuted, not repeated.

Blockers

None.

Nits (non-blocking)

  1. The bucketed window always excludes the newest sample (MemoryHistoryRing.h:94 + SystemInfoDialog.cpp:638) — inline comment. nowMs is the newest sample's timestamp and endMs snaps that down to a bucket edge, so on the 15 min / 1 hour views the newest 0–12 s is never plotted, and right after the dialog opens the chart can read "Collecting graph data" for up to a bucket while the readouts show a live number. NetworkDiagnosticsDialog.cpp:2841 does the same snap but with QDateTime::currentMSecsSinceEpoch(), where it does not bite the same way. memory_history_ring_test case 7 routes around it (nowAligned = ((now/12000)+1)*12000) rather than pinning it.

  2. The tick-label suppression is a rendering change to the Network dialog too (TimeSeriesGraphWidget.h:187) — inline comment. The code motion itself is sound (plot, minY, maxY, m_logScale, visibleSeries are all final at the new location; nothing between the two positions mutated them). The behaviour change is the new underHint test, and it is a genuine improvement over the gradient band's soft edges. But a hint box is 20 px against a 16 px tick, and the network rate graph runs five log-scale series — a cluster there can silence several decade labels. Worth a line in the PR body so the maintainer knows the network charts also change.

  3. Wall-clock timestamps assume monotonicity. MemorySample::wallMs is currentMSecsSinceEpoch(), and series()'s bucketed path assumes the deque is sorted (it flushes on start != bucketStart). A backward NTP step or a suspend/resume clock correction — exactly the "machine asleep" case the chart tooltip names — would emit the same bucket twice and draw a line that doubles back. Cosmetic only, and NetworkDiagnosticsHistory shares the assumption, so this is a note rather than an ask.

  4. None of the three tests run in any CI lane. Every ctest call in ci.yml is -R-filtered and none of the filters match memory_history_ring_test, system_info_collector_test or system_info_dialog_test. Normal for this repo, but "CI is green" here means the code compiled on three platforms, not that these tests passed anywhere but the author's machine.

What I tried to break

  • Dangling ring at shutdown. MainWindow holds std::unique_ptr<MemoryHistoryRing> while the WA_DeleteOnClose dialog holds a raw pointer to it. Member destructors run before ~QObject deletes the child dialog, so the ring dies first — but ~SystemInfoDialog only calls stopSampling() and pauseLogTail(), neither of which touches m_memoryRing, and Qt does not send a QHideEvent during widget destruction. No deref after free on that path. Reverse order (Close, ring survives) is the intended case and is what the test at system_info_dialog_test.cpp:603 pins.
  • Init order of the fallback ring. m_ownMemoryRing is declared before m_memoryRing{&m_ownMemoryRing} (SystemInfoDialog.h:134–135), so the default-member-initializer is not taking the address of an uninitialised member.
  • Late sample after teardown. A memorySampleReady already posted when stopSampling() runs is guarded by the same m_samplingGeneration compare as sampleReady, so it cannot push into the ring after the run it belongs to ended.
  • Spurious combo signal. setCurrentIndex(1) is called before the currentIndexChanged connect, so the default selection does not fire a refresh into half-built state; and refreshMemoryChart() early-returns on an empty ring, so the constructor's call is safe with all widgets already created.
  • Aggregate init of Series. {label, color, {}, " MB"} matches TimeSeriesGraphWidget::Series{label, color, points, unitSuffix, ...} — no silent field shift into stepFunction/maxConnectGapSeconds.
  • The "platform left it unset" claims. Checked against MemoryTelemetry.cpp rather than the comment: the Windows branch (line 82) genuinely never assigns virtualBytes, the Linux branch fills privateBytes only from smaps_rollup (line 124) and sets residentMetric = "vmRss" (line 120) even on the failed-read path where valid is false — which is exactly why the summary suppresses the metric name on an invalid sample. The tests' "Windows-shaped" and "invalid" cases match the real shapes.
  • Tests that would pass against unfixed code. They wouldn't: ring test case 5 checks full buckets average to exactly 200.0 (a last-sample-wins implementation reads 150 or 250), case 2 pins that eviction drops the oldest by asserting the surviving edge sample is 105 MB and not 104, case 6 pins the gap threshold per range. The dialog test's dash-vs-0.0 MB and accessible-name assertions would fail if show() were reduced to plain formatting.
  • Could not check: anything at runtime. I have no build and no display, so every statement above is read from /tmp/aetherclaude/pr-5397 and reasoned from code — nothing was reproduced by executing it. Whether the tick-suppression actually degrades the network charts in practice needs eyes on a running window; I can only say the geometry makes it possible.

Recommendation

Approve with nits. This is careful work: the ring is genuinely gui-side and header-only, the collector's memory read is deliberately placed before the enumeration early-return, the a11y live-value pattern is followed, and the tests are the kind that fail when the code is wrong rather than restating it. Nit 1 is the only one I'd actually like fixed before merge — it is a one-line change to endMs (round the bucket edge up, matching what the ring test already computes as nowAligned), and it removes a blank-chart window an operator will notice. Nits 2–4 are for the maintainer's awareness; nit 2 in particular deserves a sentence in the PR body since it changes a second dialog's rendering.


🤖 aethersdr-agent · cost: $5.0669 · model: claude-opus-5

return points;
}
const qint64 bucketMs = bucketMsFor(rangeSeconds);
const qint64 endMs = bucketMs <= 1000 ? nowMs : (nowMs / bucketMs) * bucketMs;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newest sample is always outside the bucketed window.

SystemInfoDialog::refreshMemoryChart() passes nowMs = latest->wallMs (SystemInfoDialog.cpp:638, deliberately — "the window ends at the newest sample, not at the wall clock"). This line then snaps that end down to a bucket edge. Combining the two means endMs <= nowMs with equality only when the newest timestamp lands exactly on a 5 s / 12 s boundary, so the s.wallMs > endMs filter below drops the newest sample on essentially every refresh at the 15 min and 1 hour timeframes.

Two visible effects, both reasoned from code rather than observed running:

  • Steady state: the rightmost plotted point — and TimeSeriesGraphWidget's "last sample" gutter hint, which reads points.last() — trails the Resident readout by up to one bucket (12 s on the 1 hour view).
  • Just opened, 1 hour selected: with only one or two samples in the ring, both are newer than endMs roughly 7 times in 8, so all three series come back empty and the chart paints "Collecting graph data" while the readouts above it show a live MB figure.

NetworkDiagnosticsDialog.cpp:2842 does the identical snap, but there nowMs is QDateTime::currentMSecsSinceEpoch(), which keeps advancing past the newest sample — so the pattern is safe there and not here. memory_history_ring_test case 7 works around this rather than pinning it (nowAligned = ((now / 12000) + 1) * 12000), which is the tell.

Rounding the edge up closes it and makes the test's nowAligned arithmetic unnecessary:

Suggested change
const qint64 endMs = bucketMs <= 1000 ? nowMs : (nowMs / bucketMs) * bucketMs;
const qint64 endMs = bucketMs <= 1000 ? nowMs : ((nowMs / bucketMs) + 1) * bucketMs;

Non-blocking, but worth doing before merge — and worth a ring-test case asserting that a series taken at rangeSeconds = 3600 with a single sample is not empty.

// visible series gets a colored label at the y-pixel matching
// its most recent value; labels are spread vertically to avoid
// overlap when several streams sit close together (e.g. RX and
// Audio both around ~1 Mbps). Positioned HERE, before the axis

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope note, not a defect.

The move itself checks out — plot, minY, maxY, m_logScale and visibleSeries are all in their final state at this point, and nothing between the old and new positions mutated them, so the hints land at the same y-pixels as before. The behavioural change is the new underHint test in the tick loop below, and it is a real improvement: the gradient band's soft top/bottom edges did let a tick label ghost through, exactly as the replaced comment implied.

What's worth disclosing in the PR body: this widget is also what NetworkDiagnosticsDialog paints its charts with, so the change lands on those charts too, and they are the harder case. A hint box is 20 px (h.y ± 10) against a 16 px tick rect, hints are spread only 14 px apart, and the network rate graph carries five log-scale series whose hints can cluster — enough to silence several decade labels on an axis that only has one tick per decade to begin with. On the Memory tab (three series, four linear ticks, ~37 px spacing at the widget's 220 px minimum height) it should cost at most one or two labels.

I can't judge whether that trades well without seeing it rendered. Flagging it so the maintainer knows a second dialog's appearance changes here, rather than leaving it to be discovered after merge.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (5 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5397/src/gui/MainWindow.cpp:8875
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5397/src/gui/MainWindow.cpp:8987
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5397/src/gui/MainWindow.cpp:9106
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5397/src/gui/MainWindow.cpp:9107
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5397/src/gui/MainWindow.cpp:9108

Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them.


🤖 aethersdr-agent · cost: $6.0423 · model: claude-opus-5

@skerker

skerker commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Linux leg of the Memory tab smoke — the reading the PR body lists as not driven on its own platform (VmRSS).
Clean build of b15b6a60 on Linux (Ubuntu 24.04.4, kernel 7.0, GCC 13.3, Qt 6.8.3, X11), Build SHA captured at
configure time and confirmed in Help → About before any reading. Demo radio only, isolated settings store, TX
automation off; readouts are the tab's labels read back over the bridge (dumpTree). Verdict: all six cues pass;
Linux fills all four readouts; one rendering observation to disclose.

The platform reading. /proc/self/smaps_rollup exists on this box, so Private is populated: summary line
Resident = VmRSS · 33 samples, readouts 489.1 MB / 502.8 MB / 411.0 MB / 15800.8 MB
(Resident / Peak / Private / Virtual). No dash and no 0.0 MB anywhere.

The tab.

  • Close ~10 s, reopen: the count carried on (34 → 45 → 54) with nothing sampled during the closed interval, and the
    chart shows segment · gap · segment.
  • Disconnect with the tab open: sampling continued (70 → 83 samples at radio.connected = false).
  • Reconnect: Resident stepped up here, 488.1 → 506.9 MB (the macOS run saw a step down); Peak rose 502.8 → 535.6 MB.
  • 1 minute / 15 minutes / 1 hour: all three series drawn in each view, readouts unchanged by the switch.
  • Quit with the dialog open on the Memory tab: exit code 0, no crash artifact.

Tests. The three related targets (memory_history_ring_test, system_info_collector_test,
system_info_dialog_test) pass 3/3. Full registered ctest at the PR head: 333/339 passed, 3 skipped, 3 failed
bridge_docs_check (stale verb table), hl2_state_restore_test (#5394), phone_tx_filter_numeric_entry_test
(Qt testlib assert) — the same trio the macOS run reported; none touches this PR's files or tests.

One observation (cosmetic, consistent with the review's nit 2): on every view the 500 MB axis tick label is
suppressed under the 507/536 value hints, so that band of the left axis shows only the hints. The graph stays
readable; noting it because it is the shared-widget change seen rendered rather than reasoned from geometry.

Screenshots below: the Memory tab on first open, and the 1-hour view. Evidence bundle
aethersdr-pr5397-linux-memory-tab-2026-09-03.zip (attached below; sha256
eb3caa74662af4b1467ee862f5930477a7b1f50976e2a564a4b8d4af04369fe5): the run transcript with every readout and
timestamp, the whole unedited ctest log, the configure log, box facts, and six captures, with a README mapping each
claim above to its file. Sanitized; no radio hardware, no RF.

aethersdr-pr5397-linux-memory-tab-cue1-2026-09-03 aethersdr-pr5397-linux-memory-tab-cue5-1hour-2026-09-03

aethersdr-pr5397-linux-memory-tab-2026-09-03.zip

— authored by agent (Claude Code) on behalf of @skerker

@skerker

skerker commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Windows leg of the Memory tab smoke — the reading the PR body lists as not driven on its own platform (Virtual
must read a dash, never 0.0 MB, on a platform that leaves the field unset).
Clean build of b15b6a60 on Windows (Windows 11 Pro build 26200 x64, MSVC 19.44, CMake 3.31.6, Ninja 1.13.2,
Qt 6.8.3 msvc2022_64, RelWithDebInfo), Build SHA captured at configure time and confirmed in Help → About before any
reading; 0 MSVC warnings in any file the PR touches. Demo radio only, isolated settings store, TX automation off;
readouts are the tab's labels read back over the bridge (dumpTree). Verdict: all six cues pass; Windows shows the
dash for Virtual and "working set" as the resident metric; one observation to disclose.

The platform reading. Summary line Resident = working set · 37 samples, readouts 421.2 MB / 532.3 MB /
546.5 MB / (Resident / Peak / Private / Virtual). The dash is a dash, not 0.0 MB. Private sits above Resident
here (private bytes counts committed pages that are not in the working set), which the tooltips' wording already
allows for.

The tab.

  • Close ~12 s, reopen: the count carried on (37 → 57 → 65) with nothing sampled during the closed interval, and the
    chart shows segment · gap · segment.
  • Disconnect with the tab open: sampling continued (65 → 89 samples at radio.connected = false).
  • Reconnect: Resident stepped up here, 408.9 → 420.0 MB (Linux saw up, macOS saw down); Peak unchanged at 532.3 MB.
  • 1 minute / 15 minutes / 1 hour: all three series drawn in each view, readouts unchanged by the switch.
  • Quit with the dialog open on the Memory tab: orderly shutdown (aether.shutdown: phase=application event=complete
    is the log's last line), no Application-log error and no crash dump for the run.

Tests. The three related targets (memory_history_ring_test, system_info_collector_test,
system_info_dialog_test) pass 3/3 (they need the Qt bin directory on PATH to load). Full registered ctest at the
PR head: 327/339 passed, 3 skipped, 9 failed. Six are the Windows-main baseline set this box has reported since 08-27
(crdv_manifest_test, dss_renderer_test, aether_mcp_field_mapping, hl2_state_restore_test = #5000,
connection_panel_size_test, phone_tx_filter_numeric_entry_test), plus vkamp_connection_test (characterised 08-27)
and bridge_docs_check (the stale verb table the macOS and Linux runs also hit). One is new to this box:
ctcss_tone_label_test (added 09-01 by #5259) fails deterministically here, 3/3 in isolation, on
"an uncoded row paints frequency ink and leaves the designation column blank" under the offscreen platform — it did not
exist at the last Windows baseline, and nothing this PR touches is involved. None of the nine touches this PR's files or
tests; the three related targets also pass inside the full run.

One observation (cosmetic, consistent with the review's nit 2 and the Linux report): on every view the 500 MB
axis tick label is suppressed under the 543/532 value hints. The graph stays readable.

Screenshots below: the Memory tab on first open, and the 1-hour view. Evidence bundle
aethersdr-pr5397-windows-memory-tab-2026-09-03.zip (attached below; sha256 420e3fdfb7a5e827b10c11f10e19692ae631999a4ec92602375f4102d8d34682): the run transcript with every
readout and timestamp, the whole unedited ctest log, the configure and build logs, the app log, the Event Viewer check,
box facts, and six captures, with a README mapping each claim above to its file. Sanitized; no radio hardware, no RF.

aethersdr-pr5397-windows-memory-tab-2026-09-03.zip

aethersdr-pr5397-memory-tab-1-windows-2026-09-03 aethersdr-pr5397-memory-tab-5c-1hour-windows-2026-09-03

— authored by agent (Claude Code) on behalf of @skerker

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