Merge PR #2127 (aepod) into release integration

# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
This commit is contained in:
ruv committed 2026-10-05 11:44:53 -04:00
commit 0aba0fc467
6 files changed
+464 -24

No files matched your search

+1 -1
View File
@@ -204,7 +204,7 @@ jobs:
node-version: '22'
- name: Run UI unit tests
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
# Unit and Integration Tests
# Python pytest matrix — runs against the archived v1 Python tree.
+22 -1
View File
@@ -5,6 +5,8 @@ import { poseService } from '../services/pose.service.js';
import { sensingService } from '../services/sensing.service.js';
export class DashboardTab {
static STREAM_HEALTH_REFRESH_DELAY_MS = 500;
constructor(containerElement) {
this.container = containerElement;
this.statsElements = {};
@@ -65,8 +67,9 @@ export class DashboardTab {
});
// Subscribe to sensing service state changes for data source indicator
this._sensingUnsub = sensingService.onStateChange(() => {
this._sensingUnsub = sensingService.onStateChange((state) => {
this.updateDataSourceIndicator();
this._refreshStreamHealthOn(state);
});
// Also update on data — catches source changes mid-stream
this._sensingDataUnsub = sensingService.onData(() => {
@@ -84,6 +87,23 @@ export class DashboardTab {
healthService.startHealthMonitoring(30000);
}
// #2087: the Streaming card comes from /health, polled every 30 s. The first
// poll runs before this page's own socket has connected (with auth on, the
// ticket round-trip makes that gap wider), so the card read "IDLE, 0
// client(s)" for up to 30 s while data was streaming. Re-read health when
// the socket opens or drops. The short delay lets the server register the
// socket before it is counted.
_refreshStreamHealthOn(state) {
if (state === this._lastSensingState) return;
const wasConnected = this._lastSensingState === 'connected';
this._lastSensingState = state;
if (state !== 'connected' && !wasConnected) return;
clearTimeout(this._streamHealthTimer);
this._streamHealthTimer = setTimeout(() => {
healthService.getSystemHealth().catch(() => { /* next poll retries */ });
}, DashboardTab.STREAM_HEALTH_REFRESH_DELAY_MS);
}
// Update the data source indicator on the dashboard
updateDataSourceIndicator() {
const el = this.container.querySelector('#dashboard-datasource');
@@ -427,6 +447,7 @@ export class DashboardTab {
this.healthSubscription();
}
if (this._sensingUnsub) this._sensingUnsub();
clearTimeout(this._streamHealthTimer);
if (this._sensingDataUnsub) this._sensingDataUnsub();
if (this.statsInterval) {
+70
View File
@@ -0,0 +1,70 @@
// Executed regression test for #2087: the dashboard Streaming card read
// "IDLE, 0 client(s)" while the page's own socket was streaming, because the
// card is fed by a 30 s /health poll whose first read lands before the socket
// connects.
//
// Run: node --test ui/components/DashboardTab.test.mjs
//
// This EXECUTES DashboardTab's refresh wiring in Node with fake timers and a
// stubbed health service. It does not render the card in a browser.
import { test, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({}) });
const { DashboardTab } = await import('./DashboardTab.js');
const { healthService } = await import('../services/health.service.js');
let healthCalls;
let tab;
beforeEach(() => {
healthCalls = 0;
healthService.getSystemHealth = async () => { healthCalls++; return {}; };
mock.timers.enable({ apis: ['setTimeout'] });
tab = new DashboardTab({ querySelector: () => null });
});
const advance = () => mock.timers.tick(DashboardTab.STREAM_HEALTH_REFRESH_DELAY_MS);
test('health is re-read once the sensing socket connects', () => {
tab._refreshStreamHealthOn('connecting');
advance();
assert.equal(healthCalls, 0, 'nothing to report until the socket is up');
tab._refreshStreamHealthOn('connected');
assert.equal(healthCalls, 0, 'waits for the server to register the socket');
advance();
assert.equal(healthCalls, 1);
mock.timers.reset();
});
test('health is re-read when a connected socket drops', () => {
tab._refreshStreamHealthOn('connected');
advance();
tab._refreshStreamHealthOn('reconnecting');
advance();
assert.equal(healthCalls, 2);
mock.timers.reset();
});
test('repeated notifications of the same state do not refetch', () => {
// onStateChange also fires on dataSource changes with an unchanged state.
tab._refreshStreamHealthOn('connected');
tab._refreshStreamHealthOn('connected');
tab._refreshStreamHealthOn('connected');
advance();
assert.equal(healthCalls, 1);
mock.timers.reset();
});
test('retry churn while never connected does not poll health', () => {
for (const s of ['connecting', 'reconnecting', 'connecting', 'reconnecting']) {
tab._refreshStreamHealthOn(s);
advance();
}
assert.equal(healthCalls, 0);
mock.timers.reset();
});
@@ -40,6 +40,8 @@ pub mod rufield_surface;
pub mod rvf_container;
pub mod rvf_pipeline;
pub mod semconv;
/// #2087: measured stream rate and WebSocket client count.
pub mod stream_stats;
pub mod telemetry;
#[allow(dead_code)]
pub mod trainer;
@@ -1926,6 +1926,12 @@ struct AppStateInner {
// (not replacing) the window-aggregated `tx` / `/ws/sensing` pipeline.
intro: wifi_densepose_sensing_server::introspection::IntrospectionState,
intro_tx: broadcast::Sender<String>,
/// #2087: browser WebSocket clients currently connected. `tx` receivers
/// also include the recorder and the MQTT bridge, so they are not a count
/// of clients.
ws_clients: wifi_densepose_sensing_server::stream_stats::WsClientCounter,
/// #2087: measured rate of sensing updates (one per `tick` advance).
update_rate: wifi_densepose_sensing_server::stream_stats::UpdateRateMeter,
total_detections: u64,
start_time: std::time::Instant,
/// Vital sign detector (processes CSI frames to estimate HR/RR).
@@ -2217,6 +2223,14 @@ fn calibration_sequence_order(previous: Option<u32>, sequence: u32) -> Calibrati
}
impl AppStateInner {
/// Advance the sensing tick and record the update for the measured
/// stream rate (#2087). Returns the new tick.
fn advance_tick(&mut self) -> u64 {
self.tick += 1;
self.update_rate.observe(std::time::Instant::now());
self.tick
}
/// Admit at most one raw CSI observation per forward node sequence into
/// the current calibration session. This stateful boundary prevents a
/// caller from advancing the model with a replayed history tail.
@@ -2659,6 +2673,8 @@ impl AppStateInner {
tx: broadcast::channel::<String>(16).0,
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
intro_tx: broadcast::channel::<String>(16).0,
ws_clients: Default::default(),
update_rate: Default::default(),
total_detections: 0,
start_time: std::time::Instant::now(),
vital_detector: VitalSignDetector::new(10.0),
@@ -4639,8 +4655,7 @@ async fn wifi_task(state: SharedState, tick_ms: u64) {
s.rssi_history.pop_front();
}
s.tick += 1;
let tick = s.tick;
let tick = s.advance_tick();
let motion_score = if classification.motion_level == "active" {
0.8
@@ -4805,8 +4820,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
s.rssi_history.pop_front();
}
s.tick += 1;
let tick = s.tick;
let tick = s.advance_tick();
let motion_score = if classification.motion_level == "active" {
0.8
@@ -5364,9 +5378,9 @@ async fn ws_sensing_handler(
}
async fn handle_ws_client(mut socket: WebSocket, state: SharedState) {
let mut rx = {
let (mut rx, _client) = {
let s = state.read().await;
s.tx.subscribe()
(s.tx.subscribe(), s.ws_clients.enter())
};
info!("WebSocket client connected (sensing)");
@@ -5473,9 +5487,9 @@ async fn ws_pose_handler(
}
async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) {
let mut rx = {
let (mut rx, _client) = {
let s = state.read().await;
s.tx.subscribe()
(s.tx.subscribe(), s.ws_clients.enter())
};
info!("WebSocket client connected (pose)");
@@ -5628,7 +5642,7 @@ async fn health(State(state): State<SharedState>) -> Json<serde_json::Value> {
"status": "ok",
"source": s.effective_source(),
"tick": s.tick,
"clients": s.tx.receiver_count(),
"clients": s.ws_clients.get(),
"processing": processing_health(
s.latest_update.as_ref().map(|u| u.timestamp),
now_unix_ms,
@@ -7266,8 +7280,7 @@ async fn health_system(State(state): State<SharedState>) -> Json<serde_json::Val
"message": format!("Source: {}", s.effective_source())
},
"pose": { "status": "healthy", "message": "WiFi-derived pose estimation" },
"stream": { "status": if s.tx.receiver_count() > 0 { "healthy" } else { "idle" },
"message": format!("{} client(s)", s.tx.receiver_count()) },
"stream": stream_health_component(&s, std::time::Instant::now()),
},
"metrics": {
"cpu_percent": 2.5,
@@ -7427,12 +7440,160 @@ async fn pose_zones_summary(State(state): State<SharedState>) -> Json<serde_json
async fn stream_status(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
Json(serde_json::json!({
"active": true,
"clients": s.tx.receiver_count(),
"fps": if s.tick > 1 { 10u64 } else { 0u64 },
Json(stream_status_json(&s, std::time::Instant::now()))
}
/// #2087: measured stream status. `fps` is the rate of sensing updates over
/// the last few seconds (0 when nothing is flowing), `nodes` carries each
/// fresh node's measured CSI frame rate, and `clients` counts browser
/// WebSockets rather than every internal broadcast subscriber.
fn stream_status_json(s: &AppStateInner, now: std::time::Instant) -> serde_json::Value {
use wifi_densepose_sensing_server::stream_stats::round1;
let fps = s.update_rate.rate_hz(now);
let mut ids: Vec<u8> = s
.node_states
.iter()
.filter(|(_, n)| node_is_fresh(n, now))
.map(|(id, _)| *id)
.collect();
ids.sort_unstable();
let nodes: Vec<serde_json::Value> = ids
.iter()
.map(|id| {
let n = &s.node_states[id];
serde_json::json!({
"node_id": id,
// null until the node's EMA has warmed up (5 frames).
"csi_fps": n.measured_sample_rate_hz().map(round1),
"csi_fps_samples": n.csi_fps_samples,
})
})
.collect();
// fold from +0.0: an empty f64 `sum()` is -0.0, which serializes as "-0.0".
let csi_fps_total = ids
.iter()
.filter_map(|id| s.node_states[id].measured_sample_rate_hz())
.fold(0.0, |acc, fps| acc + fps);
serde_json::json!({
"active": fps > 0.0,
"clients": s.ws_clients.get(),
"fps": round1(fps),
"csi_fps_total": round1(csi_fps_total),
"nodes": nodes,
"source": s.effective_source(),
}))
})
}
/// `stream` component of `/health/system`: healthy while updates are flowing
/// to at least one browser client.
fn stream_health_component(s: &AppStateInner, now: std::time::Instant) -> serde_json::Value {
let clients = s.ws_clients.get();
let fps = wifi_densepose_sensing_server::stream_stats::round1(s.update_rate.rate_hz(now));
let status = if clients > 0 && fps > 0.0 {
"healthy"
} else {
"idle"
};
serde_json::json!({
"status": status,
"message": format!("{clients} client(s), {fps} updates/s"),
"clients": clients,
"fps": fps,
})
}
#[cfg(test)]
mod stream_status_tests {
//! #2087: `/api/v1/stream/status` reported a literal 10 fps and counted
//! every broadcast subscriber as a client.
use super::*;
fn warmed_node(fps: f64, seen: std::time::Instant) -> NodeState {
let mut n = NodeState::new();
n.csi_fps_ema = fps;
n.csi_fps_samples = 50;
n.last_frame_time = Some(seen);
n
}
#[test]
fn idle_server_reports_zero_fps_and_no_clients() {
let s = AppStateInner::minimal();
let _recorder = s.tx.subscribe(); // internal subscriber, not a client
let v = stream_status_json(&s, std::time::Instant::now());
assert_eq!(v["fps"], 0.0);
assert_eq!(v["active"], false);
assert_eq!(v["clients"], 0);
assert_eq!(v["nodes"].as_array().unwrap().len(), 0);
assert_eq!(
stream_health_component(&s, std::time::Instant::now())["status"],
"idle"
);
}
#[test]
fn fps_is_the_measured_update_rate() {
let mut s = AppStateInner::minimal();
let t0 = std::time::Instant::now();
for i in 0..=40u64 {
s.tick += 1;
s.update_rate.observe(t0 + Duration::from_millis(50 * i));
}
let v = stream_status_json(&s, t0 + Duration::from_millis(2000));
assert_eq!(v["fps"], 20.0, "a 50 ms cadence must read 20, not 10");
assert_eq!(v["active"], true);
}
#[test]
fn per_node_fps_and_aggregate_cover_fresh_nodes_only() {
let mut s = AppStateInner::minimal();
let now = std::time::Instant::now();
s.node_states.insert(2, warmed_node(46.0, now));
s.node_states.insert(1, warmed_node(41.0, now));
s.node_states.insert(
3,
warmed_node(30.0, now - Duration::from_millis(NODE_STALE_AFTER_MS + 1)),
);
let mut cold = NodeState::new();
cold.last_frame_time = Some(now);
s.node_states.insert(4, cold);
let v = stream_status_json(&s, now);
let nodes = v["nodes"].as_array().unwrap();
let ids: Vec<u64> = nodes
.iter()
.map(|n| n["node_id"].as_u64().unwrap())
.collect();
assert_eq!(ids, vec![1, 2, 4], "stale node 3 excluded, sorted by id");
assert_eq!(nodes[0]["csi_fps"], 41.0);
assert_eq!(nodes[1]["csi_fps"], 46.0);
assert!(
nodes[2]["csi_fps"].is_null(),
"unwarmed EMA is not reported"
);
assert_eq!(v["csi_fps_total"], 87.0);
}
#[test]
fn clients_count_websockets_not_internal_subscribers() {
let mut s = AppStateInner::minimal();
let _recorder = s.tx.subscribe();
let _mqtt = s.tx.subscribe();
let browser = s.ws_clients.enter();
let now = std::time::Instant::now();
for i in 0..10u64 {
s.update_rate
.observe(now - Duration::from_millis(100 * (10 - i)));
}
assert_eq!(stream_status_json(&s, now)["clients"], 1);
let health = stream_health_component(&s, now);
assert_eq!(health["status"], "healthy");
assert_eq!(health["clients"], 1);
drop(browser);
assert_eq!(stream_status_json(&s, now)["clients"], 0);
assert_eq!(stream_health_component(&s, now)["status"], "idle");
}
}
// ── Model Management Endpoints ──────────────────────────────────────────────
@@ -11108,8 +11269,7 @@ async fn udp_receiver_task(
update_edge_node_classification(ns, &vitals);
s.tick += 1;
let tick = s.tick;
let tick = s.advance_tick();
let motion_score = if vitals.motion {
0.8
@@ -11626,8 +11786,7 @@ async fn udp_receiver_task(
// Cross-node fusion: combine features from all active nodes.
let fused_features = fuse_multi_node_features(&features, &s.node_states);
s.tick += 1;
let tick = s.tick;
let tick = s.advance_tick();
let motion_score = if classification.motion_level == "active" {
0.8
@@ -11924,8 +12083,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
continue;
}
s.tick += 1;
let tick = s.tick;
let tick = s.advance_tick();
let frame = generate_simulated_frame(tick);
@@ -13355,6 +13513,8 @@ async fn main() {
tx,
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
intro_tx,
ws_clients: Default::default(),
update_rate: Default::default(),
total_detections: 0,
start_time: std::time::Instant::now(),
vital_detector: VitalSignDetector::new(vital_sample_rate),
@@ -0,0 +1,187 @@
//! Measured streaming statistics for `/api/v1/stream/status` and the
//! `stream` component of `/health/system` (#2087).
//!
//! Two things used to be guessed: the stream rate (a literal 10 fps) and the
//! client count (`broadcast::Sender::receiver_count`, which also counts the
//! recorder and the MQTT bridge and so is not a count of browsers). This module
//! measures both.
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Window over which the sensing-update rate is averaged.
pub const UPDATE_RATE_WINDOW: Duration = Duration::from_secs(5);
/// Hard bound on retained timestamps. At 50 fps per node this covers a
/// 5-node mesh for the whole window; beyond that the rate is taken over a
/// shorter span, which is still a measurement and never an allocation spike.
const UPDATE_RATE_MAX_SAMPLES: usize = 1024;
/// Sliding-window rate of sensing updates (one per `tick` advance).
#[derive(Debug, Default)]
pub struct UpdateRateMeter {
times: VecDeque<Instant>,
}
impl UpdateRateMeter {
pub fn new() -> Self {
Self::default()
}
/// Record one sensing update at `now`.
pub fn observe(&mut self, now: Instant) {
if self.times.len() == UPDATE_RATE_MAX_SAMPLES {
self.times.pop_front();
}
self.times.push_back(now);
self.prune(now);
}
fn prune(&mut self, now: Instant) {
while let Some(&oldest) = self.times.front() {
match now.checked_duration_since(oldest) {
Some(age) if age > UPDATE_RATE_WINDOW => {
self.times.pop_front();
}
_ => break,
}
}
}
/// Updates per second over the window ending at `now`. Zero when fewer
/// than two updates fall inside the window, so a stalled source reads 0
/// instead of holding its last rate.
pub fn rate_hz(&self, now: Instant) -> f64 {
let mut in_window = self.times.iter().filter(|t| {
now.checked_duration_since(**t)
.is_some_and(|age| age <= UPDATE_RATE_WINDOW)
});
let Some(first) = in_window.next() else {
return 0.0;
};
let (count, last) = in_window.fold((1usize, first), |(n, _), t| (n + 1, t));
let span = last.duration_since(*first).as_secs_f64();
if count < 2 || span <= 0.0 {
return 0.0;
}
(count - 1) as f64 / span
}
}
/// Live count of browser-facing WebSocket clients (`/ws/sensing` and
/// `/api/v1/stream/pose`). Ticketed and unticketed sockets count the same:
/// the count is taken after the upgrade, so it only includes sockets the auth
/// layer admitted.
#[derive(Debug, Clone, Default)]
pub struct WsClientCounter(Arc<AtomicUsize>);
impl WsClientCounter {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self) -> usize {
self.0.load(Ordering::Relaxed)
}
/// Count one client until the returned guard is dropped.
pub fn enter(&self) -> WsClientGuard {
self.0.fetch_add(1, Ordering::Relaxed);
WsClientGuard(self.0.clone())
}
}
/// Decrements the client count when the socket task ends, on every exit path.
#[derive(Debug)]
pub struct WsClientGuard(Arc<AtomicUsize>);
impl Drop for WsClientGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
/// Round to one decimal place for JSON output.
pub fn round1(x: f64) -> f64 {
(x * 10.0).round() / 10.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_is_zero_without_samples() {
let m = UpdateRateMeter::new();
assert_eq!(m.rate_hz(Instant::now()), 0.0);
}
#[test]
fn rate_measures_a_steady_25_hz_stream() {
let mut m = UpdateRateMeter::new();
let t0 = Instant::now();
for i in 0..=100 {
m.observe(t0 + Duration::from_millis(40 * i));
}
let now = t0 + Duration::from_millis(4000);
let r = m.rate_hz(now);
assert!((r - 25.0).abs() < 0.01, "expected 25 Hz, got {r}");
}
#[test]
fn rate_is_not_a_constant() {
// The old endpoint said 10 fps for every source. A 40 ms tick must
// read 25 and a 200 ms tick must read 5.
let t0 = Instant::now();
let mut fast = UpdateRateMeter::new();
let mut slow = UpdateRateMeter::new();
for i in 0..=20 {
fast.observe(t0 + Duration::from_millis(40 * i));
slow.observe(t0 + Duration::from_millis(200 * i));
}
assert!((fast.rate_hz(t0 + Duration::from_millis(800)) - 25.0).abs() < 0.01);
assert!((slow.rate_hz(t0 + Duration::from_millis(4000)) - 5.0).abs() < 0.01);
}
#[test]
fn rate_decays_to_zero_when_the_source_stalls() {
let mut m = UpdateRateMeter::new();
let t0 = Instant::now();
for i in 0..50 {
m.observe(t0 + Duration::from_millis(100 * i));
}
let later = t0 + Duration::from_millis(100 * 49) + UPDATE_RATE_WINDOW * 2;
assert_eq!(m.rate_hz(later), 0.0);
}
#[test]
fn meter_memory_is_bounded() {
let mut m = UpdateRateMeter::new();
let t0 = Instant::now();
for i in 0..(UPDATE_RATE_MAX_SAMPLES as u64 * 3) {
m.observe(t0 + Duration::from_micros(i));
}
assert!(m.times.len() <= UPDATE_RATE_MAX_SAMPLES);
}
#[test]
fn client_counter_tracks_guards() {
let c = WsClientCounter::new();
assert_eq!(c.get(), 0);
let a = c.enter();
let b = c.clone().enter();
assert_eq!(c.get(), 2);
drop(a);
assert_eq!(c.get(), 1);
drop(b);
assert_eq!(c.get(), 0);
}
#[test]
fn round1_rounds_to_one_decimal() {
assert_eq!(round1(24.96), 25.0);
assert_eq!(round1(41.04), 41.0);
}
}