feat(gui): Runtime Monitor Memory tab — process memory over time (#2554) - #5397
feat(gui): Runtime Monitor Memory tab — process memory over time (#2554)#5397skerker wants to merge 10 commits into
Conversation
). 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
There was a problem hiding this comment.
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)
-
The bucketed window always excludes the newest sample (
MemoryHistoryRing.h:94+SystemInfoDialog.cpp:638) — inline comment.nowMsis the newest sample's timestamp andendMssnaps 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:2841does the same snap but withQDateTime::currentMSecsSinceEpoch(), where it does not bite the same way.memory_history_ring_testcase 7 routes around it (nowAligned = ((now/12000)+1)*12000) rather than pinning it. -
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,visibleSeriesare all final at the new location; nothing between the two positions mutated them). The behaviour change is the newunderHinttest, 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. -
Wall-clock timestamps assume monotonicity.
MemorySample::wallMsiscurrentMSecsSinceEpoch(), andseries()'s bucketed path assumes the deque is sorted (it flushes onstart != 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, andNetworkDiagnosticsHistoryshares the assumption, so this is a note rather than an ask. -
None of the three tests run in any CI lane. Every
ctestcall inci.ymlis-R-filtered and none of the filters matchmemory_history_ring_test,system_info_collector_testorsystem_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.
MainWindowholdsstd::unique_ptr<MemoryHistoryRing>while theWA_DeleteOnClosedialog holds a raw pointer to it. Member destructors run before~QObjectdeletes the child dialog, so the ring dies first — but~SystemInfoDialogonly callsstopSampling()andpauseLogTail(), neither of which touchesm_memoryRing, and Qt does not send aQHideEventduring widget destruction. No deref after free on that path. Reverse order (Close, ring survives) is the intended case and is what the test atsystem_info_dialog_test.cpp:603pins. - Init order of the fallback ring.
m_ownMemoryRingis declared beforem_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
memorySampleReadyalready posted whenstopSampling()runs is guarded by the samem_samplingGenerationcompare assampleReady, so it cannot push into the ring after the run it belongs to ended. - Spurious combo signal.
setCurrentIndex(1)is called before thecurrentIndexChangedconnect, so the default selection does not fire a refresh into half-built state; andrefreshMemoryChart()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"}matchesTimeSeriesGraphWidget::Series{label, color, points, unitSuffix, ...}— no silent field shift intostepFunction/maxConnectGapSeconds. - The "platform left it unset" claims. Checked against
MemoryTelemetry.cpprather than the comment: the Windows branch (line 82) genuinely never assignsvirtualBytes, the Linux branch fillsprivateBytesonly fromsmaps_rollup(line 124) and setsresidentMetric = "vmRss"(line 120) even on the failed-read path wherevalidis 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 MBand accessible-name assertions would fail ifshow()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-5397and 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; |
There was a problem hiding this comment.
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 readspoints.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
endMsroughly 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:
| 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 |
There was a problem hiding this comment.
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.
|
Cisco CodeGuard — static analysis of this PR (5 finding(s))
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 |
|
Linux leg of the Memory tab smoke — the reading the PR body lists as not driven on its own platform ( The platform reading. The tab.
Tests. The three related targets ( One observation (cosmetic, consistent with the review's nit 2): on every view the Screenshots below: the Memory tab on first open, and the 1-hour view. Evidence bundle
aethersdr-pr5397-linux-memory-tab-2026-09-03.zip — authored by agent (Claude Code) on behalf of @skerker |
|
Windows leg of the Memory tab smoke — the reading the PR body lists as not driven on its own platform (Virtual The platform reading. Summary line The tab.
Tests. The three related targets ( One observation (cosmetic, consistent with the review's nit 2 and the Linux report): on every view the Screenshots below: the Memory tab on first open, and the 1-hour view. Evidence bundle aethersdr-pr5397-windows-memory-tab-2026-09-03.zip
— authored by agent (Claude Code) on behalf of @skerker |




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
TimeSeriesGraphWidgetthe 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 ofProcessMemorySnapshot::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 asNetworkDiagnosticsDialog::updateCharts()places its own).MainWindowowns the ring and hands it to the dialog, because the dialog isWA_DeleteOnCloseand 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:
MainWindowinstead, the ring is already there). Samples accrue only while open; the "N samples" count is the ring's size.MemoryTelemetry), the build-flagged heap breakdown, and the seven-day compacting history — those stay with the Overview increment, as in my 08-27 comment.resident_size_peakwhile Resident isphys_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.TimeSeriesGraphWidgetnow 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).NetworkDiagnosticsDialoginherits this: a tick label within 18 px of a hint is no longer painted there either.static_assertinSystemInfoDialog.cpp; the collector include moves fromSystemInfoDialog.cppto.h.tools/gen_touchpoint_manifest.py --checkis up to date on this branch.CHANGELOG.mdis 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 aQThread,init()onstarted, 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 throughapplyMemorySample()with constructed samples, the chart moving down, timeframe changes, hide/show keeping the history, a Windows-shaped sample (shape fromMemoryTelemetry.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 --statempty, 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
ctestonb15b6a60, the whole registered suite of 341: 335 passed, 3 skipped, 3 failed —bridge_docs_check(stale verb table) andphone_tx_filter_numeric_entry_test(subprocess abort underQT_QPA_PLATFORM=offscreen) fail identically on the merge base;hl2_state_restore_testis #5394, reported failing onmainat10a847b7the 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.connected=false: sampling does not depend on the radio~/Library/Logs/DiagnosticReportsEarlier 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; sha2562be257bd34d03ee9234210a6aae2e13c121f45b21b623918f8dd7b1287ecc6f1): 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-pr5397-memory-tab-2026-09-03.zip
What I tried to break
MainWindowdestroys 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).MemoryTelemetry.cppplatform branch read for which fields it leaves at zero and whenvalidis false — that reading is where the dash rule came from.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
cmake --build build) — clean RelWithDebInfo, macOS Intel, Qt 6.8.3, every headChecklist
docs/COMMIT-SIGNING.md) — SSHAppSettingscalls — n/a, no settings touched (the timeframe is not persisted, like the network dialog's)MeterSmoother— n/a, no meter UI (readouts are 1.5 s samples, not meters)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.mdnot touched per the template— authored by agent (Claude Code) on behalf of @skerker