[perf](trx-client): three quick-win optimizations

State deduplication via PartialEq + send_if_modified:
  Derive PartialEq on the full RigState / RigSnapshot type tree
  (Freq, Band, RigInfo, RigCapabilities, RigStatus, RigTxStatus,
  RigRxStatus, RigControl, RigVfo, RigVfoEntry, RigFilterState,
  RdsData, SpectrumData, RigState, RigSnapshot).  Use
  state_tx.send_if_modified() in refresh_remote_snapshot() so
  WatchStream only wakes SSE /events subscribers when state
  actually changed; with a stable rig this eliminates ~1.3
  spurious JSON serialisations per second per connected client.

Cache-remote-rigs skip on unchanged list:
  cache_remote_rigs() was rebuilding the Vec and cloning every
  field on every 750 ms poll.  Add a structural check (rig_id,
  display_name, initialized, audio_port) and return early when
  nothing has changed — the common steady-state case.

RDS JSON pre-serialised at ingestion:
  SharedSpectrum.replace() now serialises the optional RDS object
  once and stores it alongside the Arc<SpectrumData> frame.
  Each /spectrum SSE client's 40 ms tick reads the cached string
  instead of calling serde_json::to_string() per-client per-tick.
  Add serde_json to trx-frontend Cargo.toml to support this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-09 22:48:17 +01:00
parent de11c7ff51
commit 9a96ed8236
8 changed files with 57 additions and 27 deletions
+1
View File
@@ -9,5 +9,6 @@ edition = "2021"
[dependencies]
bytes = "1"
serde_json = { workspace = true }
trx-core = { path = "../../trx-core" }
tokio = { workspace = true, features = ["sync"] }
+11 -2
View File
@@ -44,16 +44,25 @@ pub struct SharedSpectrum {
// Arc so that each SSE client gets a cheap pointer clone instead of
// copying the entire bin vector (~8 KB for 2048 f32 bins).
frame: Option<Arc<SpectrumData>>,
// RDS JSON serialised once at ingestion; avoids per-client serde work
// on every 40 ms tick for a field that changes at most once per second.
rds_json: Option<String>,
}
impl SharedSpectrum {
pub fn replace(&mut self, frame: Option<SpectrumData>) {
self.revision = self.revision.wrapping_add(1);
self.rds_json = frame
.as_ref()
.and_then(|f| f.rds.as_ref())
.and_then(|r| serde_json::to_string(r).ok());
self.frame = frame.map(Arc::new);
}
pub fn snapshot(&self) -> (u64, Option<Arc<SpectrumData>>) {
(self.revision, self.frame.clone())
/// Returns `(revision, frame, rds_json)`.
/// `rds_json` is pre-serialised; `None` means no RDS data.
pub fn snapshot(&self) -> (u64, Option<Arc<SpectrumData>>, Option<String>) {
(self.revision, self.frame.clone(), self.rds_json.clone())
}
}
@@ -424,16 +424,13 @@ pub async fn spectrum(
let next = context.spectrum.lock().ok().map(|g| g.snapshot());
let sse_chunk: Option<String> = match next {
Some((revision, _frame)) if last_revision == Some(revision) => None,
Some((revision, Some(frame))) => {
Some((revision, _frame, _rds)) if last_revision == Some(revision) => None,
Some((revision, Some(frame), rds_json)) => {
last_revision = Some(revision);
let mut chunk =
format!("event: b\ndata: {}\n\n", encode_spectrum_frame(&frame));
// Append an `rds` event only when the RDS payload changes.
let rds_json = frame
.rds
.as_ref()
.and_then(|r| serde_json::to_string(r).ok());
// rds_json is pre-serialised at ingestion; append an
// `rds` event only when the payload changed for this client.
if rds_json != last_rds_json {
let data = rds_json.as_deref().unwrap_or("null");
chunk.push_str(&format!("event: rds\ndata: {data}\n\n"));
@@ -441,7 +438,7 @@ pub async fn spectrum(
}
Some(chunk)
}
Some((revision, None)) => {
Some((revision, None, _)) => {
last_revision = Some(revision);
Some("data: null\n\n".to_string())
}