fix(core,audio,app): Tier B bug-sweep fixes (F-05, F-06, F-10, F-11)

Four confirmed P2 findings from the 2026-06-22 adversarial bug sweep.
None change the wire format / PeerState / GOSSIP_PROTO — all local.

- F-05: re-key the A8 rejoin archive (known_peers) and RecoveryContext
  by topic_id ([u8; 32]) instead of the raw ticket string. A W7-
  restamped member ticket shares the room's topic but not its string,
  so a rejoin-from-Recents previously missed the retained bootstrap
  bucket and dropped to an empty bootstrap — the exact dead-end A8
  fixed. Topic is derived once via PeerSpeakTicket::topic_of in Join;
  a malformed ticket now fails early and clean.
- F-06: an in-call Join no longer leaks the old room's peers/chat into
  the new room, nor strands stale presence on a failed switch. Core
  captures was_in_room, clears current_room at teardown, and emits a
  new local UiEvent::RoomReset on every post-teardown failure path so
  a failed switch lands idle on Home. The UI's room-scoped clearing is
  factored into AppState::reset_room_state(), called by RoomLeft,
  RoomReset, and at the top of RoomJoined — so a successful switch
  clears+repopulates seamlessly on the Room screen (no Home bounce, no
  leave chime).
- F-10: echo-cancel virtual nodes now get per-PID-unique names
  (peerspeak_echocancel_{source,sink}.<pid>); the guard carries them
  and core targets them instead of the fixed constants. unload_stale
  only unloads our modules whose owner PID is dead (/proc check, cfg-
  gated; conservative elsewhere), so enabling AEC in one instance can
  no longer tear down another live instance's call. Pure
  pid_from_ec_args / ec_module_is_stale seams.
- F-11: a recording write failure now stops recording atomically
  (best-effort finalize via stop_recording + one UI Error) instead of
  looping the error at ~50 Hz with silent data loss. Both mixer
  branches release the recorder mutex before calling stop_recording to
  avoid a self-deadlock on the non-reentrant std::Mutex.

407 lib tests pass (+4), clippy --all-targets clean, release build
green. Tests-green only; the rejoin (F-05), in-call switch (F-06),
two-instance AEC (F-10), and disk-full (F-11) paths need a real run.
Implemented by Codex, reviewed + gates re-run by senior.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 15:38:48 -04:00
co-authored by Codex Claude Opus 4.8
parent 49bd2ba687
commit 6100abef33
4 changed files with 322 additions and 63 deletions
+102 -19
View File
@@ -529,6 +529,27 @@ pub struct AppState {
}
impl AppState {
fn reset_room_state(&mut self) {
self.clip_player.stop();
self.peers.clear();
self.audio_levels.clear();
self.locally_muted.clear();
self.chat_messages.clear();
self.chat_input.clear();
self.attachment_data.clear();
self.image_handle_cache.clear();
self.pending_saves.clear();
self.pending_plays.clear();
self.invalid_audio.clear();
self.connecting.clear();
self.ever_connected.clear();
self.recording = false;
self.recording_started = None;
self.call_started = None;
self.mic_level = 0.0;
self.self_sharing = false;
}
fn custom_sound_path(&self, sound: Sound) -> &str {
let opt = match sound {
Sound::SelfJoin => &self.config.custom_sound_self_join,
@@ -1044,6 +1065,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::UiEventReceived(event) => {
match event {
UiEvent::RoomJoined { ticket, self_id } => {
state.reset_room_state();
// Remember this gathering for one-click rejoin (W7 P5). The
// emitted ticket is the canonical room door (topic + member
// addr + label); push_recent de-dupes by topic and persists.
@@ -1065,29 +1087,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref());
}
UiEvent::RoomLeft => {
state.clip_player.stop();
state.reset_room_state();
state.ticket = "".to_string();
state.peers.clear();
state.audio_levels.clear();
state.locally_muted.clear();
state.call_started = None;
state.recording = false;
state.recording_started = None;
state.chat_messages.clear();
state.chat_input.clear();
state.attachment_data.clear();
state.image_handle_cache.clear();
state.pending_saves.clear();
state.pending_plays.clear();
state.invalid_audio.clear();
state.connecting.clear();
state.ever_connected.clear();
state.status_message = "Ready to connect".to_string();
state.current_screen = Screen::Home;
state.mic_level = 0.0;
state.self_sharing = false;
notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref());
}
UiEvent::RoomReset => {
state.reset_room_state();
state.ticket = "".to_string();
state.status_message = "Ready to connect".to_string();
state.current_screen = Screen::Home;
}
UiEvent::PeerJoined { id, state: peer_state } => {
state.peers.insert(id, peer_state);
notify::play(Sound::PeerJoin, state.config.custom_sound_peer_join.as_deref());
@@ -5769,10 +5780,82 @@ impl Program<AppMessage> for Icon {
mod tests {
use super::{
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
set_peer_gate_config, set_peer_volume_config, AppConfig, GateMeter, METER_MAX,
set_peer_gate_config, set_peer_volume_config, AppConfig, AppState, AttachmentState,
ChatEntry, GateMeter, METER_MAX,
};
use iroh::SecretKey;
#[test]
fn reset_room_state_clears_all_room_scoped_state() {
let mut state = AppState::default();
let peer = SecretKey::generate().public();
let attachment_id = [9u8; 32];
let now = std::time::Instant::now();
state.peers.insert(peer, crate::network::PeerState {
name: "Peer".to_string(),
is_muted: false,
addr: iroh::EndpointAddr::from(peer),
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
});
state.audio_levels.insert(peer, 0.5);
state.locally_muted.insert(peer);
state.chat_messages.push(ChatEntry {
name: "Peer".to_string(),
text: "old room".to_string(),
mine: false,
from: Some(peer.to_string()),
attachment: None,
});
state.chat_input = "draft".to_string();
state.attachment_data.insert(attachment_id, AttachmentState::Ready(vec![1]));
state.image_handle_cache.insert(
attachment_id,
iced::widget::image::Handle::from_bytes(vec![1]),
);
state.pending_saves.insert(attachment_id);
state.pending_plays.insert(attachment_id);
state.invalid_audio.insert(attachment_id);
state.connecting.insert(peer);
state.ever_connected.insert(peer);
state.recording = true;
state.recording_started = Some(now);
state.call_started = Some(now);
state.mic_level = 0.75;
state.self_sharing = true;
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
state.reset_room_state();
assert!(state.peers.is_empty());
assert!(state.audio_levels.is_empty());
assert!(state.locally_muted.is_empty());
assert!(state.chat_messages.is_empty());
assert!(state.chat_input.is_empty());
assert!(state.attachment_data.is_empty());
assert!(state.image_handle_cache.is_empty());
assert!(state.pending_saves.is_empty());
assert!(state.pending_plays.is_empty());
assert!(state.invalid_audio.is_empty());
assert!(state.connecting.is_empty());
assert!(state.ever_connected.is_empty());
assert!(!state.recording);
assert!(state.recording_started.is_none());
assert!(state.call_started.is_none());
assert_eq!(state.mic_level, 0.0);
assert!(!state.self_sharing);
for _ in 0..50 {
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
panic!("clip player did not stop during room reset");
}
#[test]
fn peer_gate_persists_when_on_and_clears_when_off() {
let mut config = AppConfig::default();