fix: dedup participants in UI, wait for QUIC close ack before exiting

UI: deduplicate room participants by fingerprint so ghost entries from
stale relay state don't show duplicates.

Engine: after select! ends, call close_now() + connection.closed() with
500ms timeout to wait for the relay to acknowledge the CONNECTION_CLOSE.
Previously the close frame was queued but the runtime died before quinn
could retransmit if the first packet was lost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-06 05:40:06 +00:00
parent 9bbaec6b35
commit aebf9156c0
2 changed files with 32 additions and 14 deletions

View File

@@ -239,13 +239,17 @@ fun InCallScreen(
QualityIndicator(qualityTier, stats.qualityLabel) QualityIndicator(qualityTier, stats.qualityLabel)
if (stats.roomParticipantCount > 0) { if (stats.roomParticipantCount > 0) {
// Dedup by fingerprint — same key = same person, even if
// relay hasn't cleaned up stale entries yet.
val unique = stats.roomParticipants
.distinctBy { it.fingerprint.ifEmpty { it.displayName } }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "${stats.roomParticipantCount} in room", text = "${unique.size} in room",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
stats.roomParticipants.forEach { member -> unique.forEach { member ->
Text( Text(
text = member.displayName, text = member.displayName,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,

View File

@@ -148,21 +148,25 @@ impl WzpEngine {
} }
pub fn stop_call(&mut self) { pub fn stop_call(&mut self) {
info!("stop_call: setting running=false");
self.state.running.store(false, Ordering::Release); self.state.running.store(false, Ordering::Release);
// Close QUIC connection first — queues a CONNECTION_CLOSE frame. // Close QUIC connection — this wakes up all blocked recv/send futures
// Quinn needs the tokio runtime alive to actually send it on the wire, // inside block_on(run_call(...)) on the JNI thread. run_call will then
// so we use shutdown_timeout() to give it time to flush. // wait up to 500ms for the peer to acknowledge the close before returning.
if let Some(transport) = self.state.quic_transport.lock().unwrap().take() { if let Some(transport) = self.state.quic_transport.lock().unwrap().take() {
info!("stop_call: closing QUIC connection");
transport.close_now(); transport.close_now();
} }
let _ = self.state.command_tx.send(EngineCommand::Stop); let _ = self.state.command_tx.send(EngineCommand::Stop);
// Note: the runtime is still blocked in block_on(run_call(...)) on the
// start_call thread. Once run_call exits (triggered by running=false +
// connection close above), block_on returns and stores the runtime in
// self.tokio_runtime. We don't need to shut it down here.
if let Some(rt) = self.tokio_runtime.take() { if let Some(rt) = self.tokio_runtime.take() {
// Give quinn up to 500ms to send the CONNECTION_CLOSE frame. rt.shutdown_timeout(std::time::Duration::from_millis(100));
// The desktop client uses 2s, but we keep it short on Android
// to avoid blocking the UI thread.
rt.shutdown_timeout(std::time::Duration::from_millis(500));
} }
self.call_start = None; self.call_start = None;
info!("stop_call: done");
} }
pub fn set_mute(&self, muted: bool) { pub fn set_mute(&self, muted: bool) {
@@ -585,12 +589,22 @@ async fn run_call(
}; };
tokio::select! { tokio::select! {
_ = send_task => {} _ = send_task => info!("send task ended"),
_ = recv_task => {} _ = recv_task => info!("recv task ended"),
_ = stats_task => {} _ = stats_task => info!("stats task ended"),
_ = signal_task => {} _ = signal_task => info!("signal task ended"),
} }
transport.close().await.ok(); // Send CONNECTION_CLOSE and wait up to 500ms for the peer to acknowledge.
// This ensures the relay sees the close even if the first packet is lost.
info!("closing QUIC connection...");
transport.close_now();
match tokio::time::timeout(
std::time::Duration::from_millis(500),
transport.connection().closed(),
).await {
Ok(_) => info!("QUIC connection closed cleanly"),
Err(_) => info!("QUIC close timed out (relay may not have ack'd)"),
}
Ok(()) Ok(())
} }