Skip to content

SensorView: 3-thread architecture, embedded LAN dashboard, and Hex Viewer - #17

Merged
manupawickramasinghe merged 6 commits into
masterfrom
feature/web-telemetry
Jul 23, 2026
Merged

SensorView: 3-thread architecture, embedded LAN dashboard, and Hex Viewer#17
manupawickramasinghe merged 6 commits into
masterfrom
feature/web-telemetry

Conversation

@manupawickramasinghe

Copy link
Copy Markdown
Member

Adds the three-thread architecture, an embedded LAN telemetry server, and the Hex Viewer pane — plus a fix for a crash that was taking out every sensor.

Thread architecture

Thread 1  poll::spawn ......... fast lane ~1 s
   SensorSource::snapshot() -> Monitor (min/max/avg) -> TelemetryFrame
   the ONLY writer to TelemetryStore
        |                                    |
   ArcSwap (atomic ptr)            broadcast::Sender<Arc<String>>
        v                                    v
Thread 2  GUI (main thread)        Thread 3  web::spawn
   eframe/egui, store.load()          tokio + axum, one task per WS client

Thread 1b  inventory::spawn_collector ..... slow lane ~30 s
   ACPI / SMBIOS / storage / topology -> ArcSwap, read by Thread 1

The UI previously locked the poller and deep-cloned the whole hardware tree every frame, in every window. Adding a web server on that lock would have made contention the dominant cost. Instead the poller is the sole writer and publishes an immutable Arc<TelemetryFrame> via ArcSwap; readers never block, and the deep clone is gone.

Why it can't deadlock (src/state.rs, src/main.rs):

  1. One writer — only Thread 1 mutates telemetry.
  2. UI mutations are messages, not locks — poll::Command over an mpsc channel.
  3. No lock guard is ever held across .await — enforced by #![deny(clippy::await_holding_lock)] plus a CI gate.
  4. No lock-ordering cycle — the remaining locks (history ring, logger slot) are leaves.
  5. Backpressure stops at the channel — a stalled browser gets Lagged and resyncs; it can never slow the hardware loop.
  6. Ordered shutdown — GUI exit → stop web (release port) → stop poller (release driver) → stop collector.

Web tier (src/web/, feature web, default on)

GET / (embedded SPA) · /api/telemetry · /api/system · /api/history/{id} · /api/health · /metrics (Prometheus) · /ws/telemetry.

Each frame is serialized once into an Arc<String> that every client forwards, so N clients cost O(1) per tick, not O(N).

Security: binds 127.0.0.1:8080 by default. This endpoint exposes drive serials and raw SPD/PCI dumps, so any non-loopback bind makes a per-run token mandatory on every route except /api/health. Verified 401 on /api/telemetry, /, and the WS upgrade without it; 200 via query string, Authorization: Bearer, and WS with it.

Hex Viewer (src/ui/hex_window.rs)

Offset · 16 bytes in 4-byte groups with a mid-row gutter · ASCII gutter, selection synced across columns, rows virtualized through egui_extras::TableBuilder. Beyond a plain dump: ACPI header fields are tinted by kind and named under the cursor, and the selected offset decodes as int8..uint64, float32/64, binary, char and ascii[8] in either byte order. Search (hex or text, wrapping) and go-to-offset.

Data comes from GetSystemFirmwareTable (ACPI + SMBIOS) — plain kernel32, no driver and no elevation — so it works despite the blocked Ring-0 driver on the dev machine. Linux reads /sys/firmware/acpi/tables. Read-only by design: nothing writes to hardware; Export writes a .bin/.txt copy.

Two deliberate departures from the brief

  • Polling is tiered. S.M.A.R.T. reads keep drives out of low-power states and sub-500 ms SMBus polling provokes SMI storms, so storage/SPD/topology live on a 30 s slow lane on their own thread, where a multi-second read cannot delay a 1 s sensor tick.
  • The dashboard is loopback-first. LAN serving is delivered as asked, just not unauthenticated by default.

Bugs found and fixed along the way

Bug Consequence
Sidecar died on non-finite sensor values Every sensor lost; app silently fell back to demo data
PollHandle::stop() only cleared a flag Exit stalled up to a full poll interval (10 s) — the loop parks in recv_timeout
rust-version = "1.77" was stale Cargo silently resolved egui_tiles to a version built against egui 0.30
HexBlob::find clamped its start offset downward Searched backwards; find-next could never leave the last match
Windows keys ACPI tables by signature The same 34 KiB SSDT listed 15 times as distinct tables
Go-to field allocated a fresh String per frame Every keystroke discarded
JSON serialized even with the web tier compiled out Full-tree serialization every second for zero consumers

The sensor outage is worth its own note: with VBS blocking BCLK reads, LHM returned NaN for the per-core clocks, and System.Text.Json throws rather than write non-finite floats — so the sidecar exited 0xE0434352 on its first snapshot. It now maps non-finite readings to null and survives a bad tick.

Verification

  • 72 tests; clippy clean under -D warnings -D clippy::await_holding_lock; GUI-only (--no-default-features) build checked. CI now gates all three.
  • Runtime: two concurrent WebSocket clients tracking the same monotonic seq, priming frame in 65 ms, pingpong; REST + Prometheus + history (404 on unknown sensor); token gate verified both ways; graceful close releases the port with no orphans.
  • Sensors: source: LibreHardwareMonitor bridge, 151 sensors across 12 devices (Ryzen 7 7700, RTX 5060, Radeon iGPU, memory, six NICs) — previously demo data.
  • Hex Viewer: 16 real firmware blobs (MCFG, FACP, APIC, IVRS, SSDT, VFCT 44 KiB, SMBIOS 3.8); window renders and titles itself from the selected blob. Every settings tab, the sensors window and the graph window smoke-tested for panics.

Not included

Docking (egui_tiles resolves but is unused), the topology and storage panes, and the per-OS sensor backends. feature/portable-build remains an open item — the branch exists but its workflow was never written.

Known environmental issue (not a code defect)

Two blockers on the dev machine, confirmed against HWiNFO's own manual §10.5:

Blocker State Breaks Fix
VulnerableDriverBlocklistEnable 1 WinRing0 → SMU/MSR power Install PawnIO
Virtualization-Based Security running, despite Memory Integrity off BCLK → ratio-based clocks Also disable Virtual Machine Platform + Windows Hypervisor Platform

17 sensors report "no value" until those are resolved; the other 134 read correctly.

🤖 Generated with Claude Code

manupawickramasinghe and others added 6 commits July 23, 2026 14:00
Restructures SensorView around three cooperating threads and adds an
embedded HTTP/WebSocket server that streams telemetry to browsers.

Thread 1 (poll.rs) is now the single writer. It builds an immutable
TelemetryFrame and publishes it through the new TelemetryStore (state.rs),
which swaps an ArcSwap pointer and serializes to JSON exactly once per tick.
Thread 2 (GUI) and Thread 3 (web) read that pointer without locking, which
also removes the per-frame deep clone of the whole hardware tree the UI used
to do in every window. UI mutations travel to the poller as poll::Commands
over an mpsc channel instead of contending on a shared lock.

Thread 3 (web/) serves the bundled SPA plus /api/telemetry, /api/system,
/api/history/{id}, /api/health, /metrics and /ws/telemetry. Every WebSocket
client forwards the same Arc<String>, so N clients cost O(1) per tick, and a
slow client receives RecvError::Lagged and resyncs rather than applying
backpressure to the hardware loop.

Two deliberate departures from a naive reading of the brief:

- Polling is tiered. S.M.A.R.T. reads keep drives out of low-power states and
  sub-500 ms SMBus polling provokes SMI storms, so storage/SPD/PCIe live on a
  30 s slow lane (inventory.rs) on their own thread, where a multi-second read
  cannot delay a 1 s sensor tick.
- The dashboard binds loopback by default. It exposes drive serials and raw
  SPD/PCI dumps, so any non-loopback bind makes a per-run access token
  mandatory on every route except /api/health.

Also fixes two defects found along the way: PollHandle::stop() now sends
Command::Shutdown instead of only clearing a flag (the loop parks in
recv_timeout, so exit could stall for a full interval, up to 10 s), and
rust-version was corrected from a stale 1.77 to 1.92 — cargo honours it during
resolution and was silently downgrading new dependencies.

Model gains the shapes the telemetry work needs: NVMe Health Log 0x02 decoded
at spec offsets, ATA S.M.A.R.T. attributes with vendor overrides, PCIe link
state with degradation detection, and raw hex blobs.

57 tests pass; clippy is clean with -D warnings and -D await_holding_lock; the
GUI-only (--no-default-features) build is checked in CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ev hooks

Two follow-ups from reviewing the milestone:

TelemetryStore::publish serialized the whole hardware tree every tick even when
the web tier was compiled out, because json() was cfg-gated but the work that
fed it was not. Nothing read the result, so a --no-default-features build paid a
full-tree serialization per second for no consumer — defeating the point of the
feature flag. The field and the serialization are now both behind feature="web".

The main.rs rewrite had dropped SENSORVIEW_OPEN_GRAPH and
SENSORVIEW_START_LOGGING; restored on top of the new store, and added
SENSORVIEW_SETTINGS_TAB. These exist so windows that normally require a click —
the graph window and each settings tab — can be rendered from a script. Used
them to smoke-test the three windows the unit tests can't reach: sensors,
graph, and all six settings tabs (including the new Remote Access tab with the
token block) render without panic, and CSV logging writes rows from the poll
thread as intended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Symptom: no sensors at all — the app silently fell back to demo data.

Cause: LHM reports the per-core Clock and Factor (multiplier) sensors as
NaN/Infinity on this machine, because the blocked WinRing0 driver leaves the
effective-clock calculation dividing by a zero delta. System.Text.Json refuses
to write non-finite floats and throws, so the very first snapshot raised an
unhandled exception and the sidecar exited with 0xE0434352 before emitting a
single tree. The Rust side correctly reported "sidecar exited early" and fell
back to demo data.

Two fixes, both needed:

- Map non-finite readings to null in MapSensor. A NaN is not a reading, and
  the Rust model already expresses "no reading" as Option<f32>::None.
- Catch non-IO exceptions around the per-tick write, so one bad snapshot skips
  a tick instead of taking the process down. Losing 151 sensors because one
  of them divided by zero is not an acceptable failure mode.

Verified: the sidecar now streams continuously; the app reports source
"LibreHardwareMonitor bridge" with 151 sensors across 12 devices (CPU, both
GPUs, memory, six network adapters) instead of demo data.

Note this is the same blocked-driver root cause as the existing 0 W / 0 MHz
issue, which had escalated from "reads zero" to "kills the sensor engine".
The 17 sensors that now read null are the 8 per-core clocks, 8 per-core
multipliers and one Wi-Fi utilization counter; those stay unavailable until
PawnIO is installed. Everything else reads correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hex inspector from the SYS_KERN_MONITOR mockups, as a native egui window
(toolbar "0x Hex", or SENSORVIEW_SHOW_HEX=1). Read "HEX disassembler" as the
hex viewer the mockups actually specify — the "Follow in Disassembler" button
and "ACPI Disasm" tab there are stubs, and ACPI/SMBIOS bytes are not x86
instructions, so no instruction decoder is implied.

Layout follows the reference: offset · 16 bytes in 4-byte groups with a
mid-row gutter · ASCII gutter, selection synced across both columns. Rows go
through egui_extras::TableBuilder so only visible ones are built — the VFCT
table here is 44 KiB.

Three things make it an inspector rather than a wall of hex:
- Field annotations. Blobs carry HexRegions; the ACPI header's
  signature/length/checksum/OEM fields are tinted by kind and named under the
  cursor.
- Byte decoding. The selected offset is decoded as int8..uint64, float32/64,
  binary, char and ascii[8], in either byte order. Types that would read past
  the end are omitted rather than shown as zero.
- Search (hex or text, wrapping) and go-to-offset.

Data source: src/source/firmware.rs reads ACPI and SMBIOS via
GetSystemFirmwareTable — plain kernel32, no driver and no elevation, so the
viewer works on this machine despite the blocked Ring-0 driver. PCI config
space, the mockup's literal subject, needs that driver and is not readable
yet. Linux reads /sys/firmware/acpi/tables (root only). Demo mode ships a
synthetic PCI-config blob so the window is exercisable anywhere.

Read-only by design: no path writes to hardware. Committing bytes back to SPD
EEPROM or PCI config can brick a board and needs the same missing driver, so
Export writes a .bin and .txt copy instead.

Two defects found while building it:
- HexBlob::find clamped `from` down to the last possible match, so searching
  past the final hit silently searched backwards and re-reported it. Find-next
  could never advance off the last match.
- Windows keys ACPI tables by signature, so requesting 'SSDT' fifteen times
  returns the same bytes fifteen times. Those were being listed as fifteen
  distinct 34 KiB tables. Now read once and labelled "SSDT (1 of 15)". On
  Linux sysfs does expose them separately, so index and count are both real
  there. Also corrected a test that assumed a DSDT: Windows does not publish
  it through this API, verified against the 29 tables this machine lists.

Verified on real firmware: 16 blobs (MCFG, FACP, APIC, IVRS, SSDT, VFCT,
SMBIOS 3.8, …), window renders and titles itself from the selected blob,
72 tests pass, clippy clean, GUI-only build checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ✕, 💾 and ▶ glyphs aren't in Segoe UI or Cascadia Mono as loaded, so they
rendered as empty boxes in the toolbar. Verified by screenshotting the running
window rather than assuming the font had them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cross-checked against HWiNFO's own manual (§10.5, Core Frequency reading in
Windows 11) and verified on this machine: two independent blockers are active,
not one.

- VulnerableDriverBlocklistEnable = 1 blocks WinRing0, killing SMU/MSR power.
- VirtualizationBasedSecurityStatus = 2 (VBS running) blocks the BCLK reads
  every ratio-based clock depends on — even though Memory Integrity is already
  off (HVCI Enabled = 0). The manual notes exactly this case and says the
  Virtual Machine Platform and Windows Hypervisor Platform features must go too.

The second one is what produced the NaN clocks that crashed the sidecar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 10:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@manupawickramasinghe
manupawickramasinghe merged commit ab44123 into master Jul 23, 2026
@manupawickramasinghe
manupawickramasinghe deleted the feature/web-telemetry branch July 23, 2026 14:39
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.

2 participants