[feat](trx-rs): persistent multi-channel virtual channels with OOB eviction
Allow users to allocate multiple virtual channels independently of browser tab count. Channels survive SDR center-frequency retuning as long as they stay within the capture bandwidth; channels that fall outside the SDR span are automatically destroyed. Changes: - trx-core: add AUDIO_MSG_VCHAN_DESTROYED (0x12) wire constant; add default subscribe_destroyed() to VirtualChannelManager trait - trx-backend-soapysdr: update_center_hz() detects OOB channels, removes them, fires destroyed_tx broadcast; add destroyed_sender() and subscribe_destroyed() override - trx-server/audio: recv_destroyed() helper avoids select! busy-loop for non-SDR backends; send AUDIO_MSG_VCHAN_DESTROYED to client when a channel is evicted server-side - trx-client/audio_client: persist active_subs across TCP reconnects, re-subscribe on reconnect; handle AUDIO_MSG_VCHAN_DESTROYED by pruning vchan_audio map and forwarding UUID via vchan_destroyed_tx - trx-frontend/lib: add vchan_destroyed broadcast field to FrontendRuntimeContext - trx-client/main: wire vchan_destroyed_tx into audio client and frontend runtime context - trx-frontend-http/vchan: remove per-session one-channel limit in allocate(); replace auto-evict in release_session_on_rig() with subscriber-count-only update; add remove_by_uuid() for server- triggered OOB destruction (skips redundant VChanAudioCmd::Remove) - trx-frontend-http/server: spawn background task that forwards vchan_destroyed broadcast to ClientChannelManager.remove_by_uuid() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -236,6 +236,10 @@ pub struct FrontendRuntimeContext {
|
||||
/// forwards `VCHAN_SUB` / `VCHAN_UNSUB` frames over the audio TCP connection.
|
||||
/// `None` when no audio connection is active.
|
||||
pub vchan_audio_cmd: Arc<Mutex<Option<mpsc::Sender<VChanAudioCmd>>>>,
|
||||
/// Broadcast sender that fires whenever the server destroys a virtual
|
||||
/// channel (e.g. out-of-bandwidth after center-frequency retune).
|
||||
/// The HTTP frontend subscribes to clean up `ClientChannelManager`.
|
||||
pub vchan_destroyed: Option<broadcast::Sender<Uuid>>,
|
||||
}
|
||||
|
||||
impl FrontendRuntimeContext {
|
||||
@@ -281,6 +285,7 @@ impl FrontendRuntimeContext {
|
||||
},
|
||||
vchan_audio: Arc::new(RwLock::new(HashMap::new())),
|
||||
vchan_audio_cmd: Arc::new(Mutex::new(None)),
|
||||
vchan_destroyed: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use actix_web::{
|
||||
web, App, HttpServer,
|
||||
};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, info};
|
||||
|
||||
@@ -92,6 +92,24 @@ async fn serve(
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn a task that removes channels destroyed server-side (OOB) from the
|
||||
// client-side registry so the SSE channel list stays in sync.
|
||||
if let Some(ref destroyed_tx) = context.vchan_destroyed {
|
||||
let mut destroyed_rx = destroyed_tx.subscribe();
|
||||
let mgr_for_destroyed = vchan_mgr.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match destroyed_rx.recv().await {
|
||||
Ok(uuid) => {
|
||||
mgr_for_destroyed.remove_by_uuid(uuid);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let server = build_server(addr, state_rx, rig_tx, callsign, context, scheduler_store, scheduler_status, vchan_mgr)?;
|
||||
let handle = server.handle();
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -203,9 +203,6 @@ impl ClientChannelManager {
|
||||
freq_hz: u64,
|
||||
mode: &str,
|
||||
) -> Result<ClientChannel, VChanClientError> {
|
||||
// Release any existing channel owned by this session on this rig.
|
||||
self.release_session_on_rig(session_id, rig_id);
|
||||
|
||||
let mut rigs = self.rigs.write().unwrap();
|
||||
let channels = rigs.entry(rig_id.to_string()).or_default();
|
||||
|
||||
@@ -318,27 +315,9 @@ impl ClientChannelManager {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Collect IDs of non-permanent channels about to be evicted (0 subscribers).
|
||||
let to_remove: Vec<Uuid> = channels
|
||||
.iter()
|
||||
.filter(|c| !c.permanent && c.session_ids.is_empty())
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
// Remove non-permanent channels with no subscribers.
|
||||
let before = channels.len();
|
||||
channels.retain(|c| c.permanent || !c.session_ids.is_empty());
|
||||
if channels.len() != before {
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
self.broadcast_change(rig_id, channels);
|
||||
}
|
||||
drop(rigs);
|
||||
// Notify the audio-client task so it can tear down the server-side
|
||||
// DSP pipeline and Opus encoder for each evicted channel.
|
||||
for id in to_remove {
|
||||
self.send_audio_cmd(VChanAudioCmd::Remove(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly delete a channel by UUID (any session may do this).
|
||||
@@ -373,6 +352,42 @@ impl ClientChannelManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a channel by UUID across all rigs (called when the server destroys
|
||||
/// it due to out-of-band center-frequency change). Does NOT send a
|
||||
/// `VChanAudioCmd::Remove` since the server-side channel is already gone.
|
||||
pub fn remove_by_uuid(&self, channel_id: Uuid) {
|
||||
let evicted_sessions: Vec<Uuid>;
|
||||
let rig_id_opt: Option<String>;
|
||||
{
|
||||
let mut rigs = self.rigs.write().unwrap();
|
||||
let mut found = false;
|
||||
let mut evicted = Vec::new();
|
||||
let mut found_rig = None;
|
||||
for (rig_id, channels) in rigs.iter_mut() {
|
||||
if let Some(pos) = channels.iter().position(|c| c.id == channel_id) {
|
||||
evicted = channels[pos].session_ids.clone();
|
||||
channels.remove(pos);
|
||||
self.broadcast_change(rig_id, channels);
|
||||
found_rig = Some(rig_id.clone());
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
evicted_sessions = evicted;
|
||||
rig_id_opt = found_rig;
|
||||
let _ = found; // suppress warning
|
||||
}
|
||||
// Clean up session → channel mapping for sessions that were subscribed.
|
||||
if rig_id_opt.is_some() {
|
||||
let mut sessions = self.sessions.write().unwrap();
|
||||
for sid in evicted_sessions {
|
||||
if matches!(sessions.get(&sid), Some((_, ch)) if *ch == channel_id) {
|
||||
sessions.remove(&sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update freq/mode metadata for a channel.
|
||||
pub fn set_channel_freq(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user