fix(audio,voice,log): six P0 issues from Korean Windows test

1. Voice Bar 'Leave voice' button removed entirely. TeamSpeak users
   are always in some channel; Discord/Mumble-style leave is the
   wrong model. To stop being heard / hearing, mute mic / speaker.
   To physically move, tap a different channel. The voiceLeave
   bridge call + _onLeaveVoice stay as dead code for now (marked
   unused) so existing tests/integrations don't break.

2. voice_join now confirms the move actually applied server-side
   by polling the snapshot for up to 1.5 s and matching our own
   client's channel against the requested one. If the server
   rejected the move (no permission, wrong password, channel
   full), voice_join rolls the selector back to in_channel=false
   and returns Err so the UI surfaces the failure instead of
   showing a fake 'joined' state.

3. ServerSnapshot + BridgeSnapshot gain own_client_id so the UI
   can identify our row without name-matching. find_own_in reads
   it directly.

4. set_self_muted now also clamps the TransmitModeSelector's
   hard_mute when input is muted server-side. Without this, the
   Opus encoder kept producing frames after setInputMuted(true),
   tsclientlib refused each one with 'Sending audio while muted',
   and the log grew to 200 MB on the Korean host.

5. tsclientlib WARN spam suppressed via tracing filter
   (tsclientlib=error). Belt-and-braces on top of fix 4.

6. Log file is now rotated at every launch (not just when >4 MiB).
   Two generations kept: chanora.log.1 (previous) and
   chanora.log.2 (the one before). The bug that produced 200 MB
   files was a chatty subsystem flooding a single session; the
   per-launch rotate keeps disk use bounded by what one session
   can produce in its lifetime.

Bonus Windows fix (separate from the six but found in the same
log): the Raw Input + Hook backends now signal readiness BEFORE
blocking on GetMessageW. Previously init_tx.send was called after
the loop returned (i.e. on WM_QUIT, which never happens during
arming), so the main thread's 2 s readiness probe always timed
out and the backend reported L0Focused even when registration
succeeded. Both run_raw_input_loop and run_hook_loop now take an
init_tx parameter and call report!(true) right after a successful
registration, and report!(false) on every early-fail return.

cargo check --workspace: clean.
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
This commit is contained in:
EdisonJwa
2026-05-16 01:52:47 +08:00
parent bb3c82e2d5
commit 181b3368d4
10 changed files with 180 additions and 35 deletions
+1 -1
View File
@@ -373,6 +373,7 @@ class _BetaHomeState extends State<_BetaHome> {
}
}
// ignore: unused_element
Future<void> _onLeaveVoice() async {
try {
await rust.voiceLeave();
@@ -867,7 +868,6 @@ class _BetaHomeState extends State<_BetaHome> {
onToggleMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
onConfigure: _onOpenVoiceSettings,
onLeave: _onLeaveVoice,
),
const SizedBox(height: 12),
Expanded(
+10 -2
View File
@@ -475,6 +475,11 @@ class BridgeSnapshot {
/// Clients currently known.
final List<BridgeClient> clients;
/// Our own client id. Useful for the UI to highlight our row
/// in the client list and to know which channel we are in
/// without trusting the optimistic local state.
final BigInt ownClientId;
const BridgeSnapshot({
required this.serverName,
required this.welcomeMessage,
@@ -482,6 +487,7 @@ class BridgeSnapshot {
required this.version,
required this.channels,
required this.clients,
required this.ownClientId,
});
@override
@@ -491,7 +497,8 @@ class BridgeSnapshot {
platform.hashCode ^
version.hashCode ^
channels.hashCode ^
clients.hashCode;
clients.hashCode ^
ownClientId.hashCode;
@override
bool operator ==(Object other) =>
@@ -503,7 +510,8 @@ class BridgeSnapshot {
platform == other.platform &&
version == other.version &&
channels == other.channels &&
clients == other.clients;
clients == other.clients &&
ownClientId == other.ownClientId;
}
/// Voice transmit mode mirror (SDD-095). Schema-controlled enum;
@@ -1172,8 +1172,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 6)
throw Exception('unexpected arr length: expect 6 but see ${arr.length}');
if (arr.length != 7)
throw Exception('unexpected arr length: expect 7 but see ${arr.length}');
return BridgeSnapshot(
serverName: dco_decode_String(arr[0]),
welcomeMessage: dco_decode_String(arr[1]),
@@ -1181,6 +1181,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
version: dco_decode_String(arr[3]),
channels: dco_decode_list_bridge_channel(arr[4]),
clients: dco_decode_list_bridge_client(arr[5]),
ownClientId: dco_decode_u_64(arr[6]),
);
}
@@ -1483,6 +1484,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_version = sse_decode_String(deserializer);
var var_channels = sse_decode_list_bridge_channel(deserializer);
var var_clients = sse_decode_list_bridge_client(deserializer);
var var_ownClientId = sse_decode_u_64(deserializer);
return BridgeSnapshot(
serverName: var_serverName,
welcomeMessage: var_welcomeMessage,
@@ -1490,6 +1492,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
version: var_version,
channels: var_channels,
clients: var_clients,
ownClientId: var_ownClientId,
);
}
@@ -1809,6 +1812,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_String(self.version, serializer);
sse_encode_list_bridge_channel(self.channels, serializer);
sse_encode_list_bridge_client(self.clients, serializer);
sse_encode_u_64(self.ownClientId, serializer);
}
@protected
@@ -29,7 +29,6 @@ class VoiceBar extends StatelessWidget {
required this.onToggleMute,
required this.onToggleOutputMute,
required this.onConfigure,
required this.onLeave,
});
/// True when the session is currently joined to a voice channel.
@@ -85,9 +84,6 @@ class VoiceBar extends StatelessWidget {
/// and intentionally does NOT have its own configure affordance.
final VoidCallback onConfigure;
/// Leave the voice channel.
final VoidCallback onLeave;
String _modeLabel(AppL10n l10n) {
switch (transmitMode) {
case rust.BridgeTransmitMode.ptt:
@@ -236,17 +232,12 @@ class VoiceBar extends StatelessWidget {
backendId: pttBackendId,
boundInputClass: pttBoundInputClass,
),
if (inChannel) ...[
const SizedBox(height: 6),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton.icon(
icon: const Icon(Icons.call_end),
label: Text(l10n.voiceLeaveAction),
onPressed: onLeave,
),
),
],
// Leave-voice button intentionally absent: TeamSpeak's
// model is "user is always in some channel", not
// Discord's join/leave-voice. To stop being heard /
// hearing others, mute mic and/or speaker via the
// icons at the top of the bar. To physically move,
// tap a different channel in the tree below.
],
),
),
+70
View File
@@ -785,6 +785,18 @@ impl ChanoraSession {
audio.set_output_muted(muted);
}
}
// When server-side input mute is engaged we must ALSO stop
// producing outbound voice frames locally — otherwise the
// Opus encoder happily writes packets, the protocol layer
// hands them to tsclientlib, tsclientlib refuses them
// because its own ClientMuted flag is set, and logs
// "Sending audio while muted" once per 20 ms frame. That
// flooded the log to 200 MB on the Korean test host.
// Clamp the transmit-mode selector's hard_mute input so
// the gate goes false too.
if let Some(muted) = input {
self.voice_selector.set_hard_mute(muted);
}
Ok(())
}
@@ -855,13 +867,69 @@ impl ChanoraSession {
channel_id: u64,
password: Option<String>,
) -> Result<(), CoreError> {
// 1. Send the move command. This returns Ok even when the
// server later rejects it with a permission error,
// because the rejection arrives as an asynchronous
// server event the adapter doesn't currently surface.
self.move_to_channel(channel_id, password).await?;
// 2. Bring the audio engine up.
self.ensure_audio_running().await?;
// 3. Verify we are actually in the requested channel by
// polling the snapshot for up to 1.5 s. The TS3 server
// typically broadcasts the channel-update within
// ~50200 ms after the move; if we never see ourselves
// move (no-permission, wrong password, channel full,
// etc.) we surface the failure to the caller so the
// Voice Bar doesn't lie about our membership state.
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(1500);
let confirmed = loop {
if let Ok(snap) = self.snapshot().await {
if let Some((my_id, my_channel)) = self.find_own_in(&snap).await {
if my_channel == channel_id {
info!(
target: "chanora_core",
client_id = my_id,
channel_id,
"voice_join confirmed by snapshot"
);
break true;
}
}
}
if std::time::Instant::now() >= deadline {
break false;
}
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
};
if !confirmed {
// The move did not take effect server-side. Roll
// back the local selector so the UI doesn't display
// a fake "joined" state.
self.voice_selector.set_in_channel(false);
self.emit_voice_state(false).await;
return Err(CoreError::Protocol(
chanora_protocol::ProtocolError::Backend(
"channel move rejected by server (no permission, wrong password, or channel full)".to_string(),
),
));
}
self.voice_selector.set_in_channel(true);
self.emit_voice_state(true).await;
Ok(())
}
/// Find our own client in a snapshot and return `(client_id,
/// channel_id)`. Used by `voice_join` to confirm the server
/// actually applied a channel move.
async fn find_own_in(
&self,
snap: &chanora_protocol::ServerSnapshot,
) -> Option<(u64, u64)> {
let own_id = snap.own_client_id;
let me = snap.clients.iter().find(|c| c.id.0 == own_id)?;
Some((me.id.0, me.channel.0))
}
/// Leave the current voice channel (SDD-094). Marks the
/// selector as out-of-channel (which clamps `transmit_active`
/// to false), tears down the audio engine, and emits
@@ -1472,6 +1540,7 @@ mod tests {
channel: chanora_protocol::ChannelId(1),
name: "u".into(),
}],
own_client_id: 10,
};
let mut b = a.clone();
// User moves from channel 1 → 2. Counts unchanged.
@@ -1505,6 +1574,7 @@ mod tests {
},
],
clients: vec![],
own_client_id: 0,
};
let mut b = a.clone();
b.channels.reverse();
@@ -262,9 +262,14 @@ impl DesktopPttBackend for WindowsRawInputBackend {
});
});
let ok = unsafe { run_raw_input_loop() };
// Run the loop. Pass `init_tx` so the loop can
// signal readiness AFTER RegisterRawInputDevices
// succeeds but BEFORE GetMessageW starts blocking
// — otherwise the main thread's readiness probe
// times out (the signal would only fire when
// WM_QUIT was eventually delivered).
let ok = unsafe { run_raw_input_loop(init_tx) };
armed.store(ok, Ordering::Release);
let _ = init_tx.send(ok);
// If init failed, exit immediately. If it
// succeeded, run_raw_input_loop already ran the
@@ -365,7 +370,20 @@ impl Drop for WindowsRawInputBackend {
/// # Safety
/// Calls into Win32 directly. Must be invoked on the thread that
/// owns the message window (created here).
unsafe fn run_raw_input_loop() -> bool {
unsafe fn run_raw_input_loop(init_tx: std::sync::mpsc::Sender<bool>) -> bool {
// Helper that signals the readiness state to the main thread.
// We send exactly once at the first decisive moment (either an
// early-fail return or right after a successful
// RegisterRawInputDevices). Subsequent sends are no-ops.
let mut signal = Some(init_tx);
macro_rules! report {
($v:expr) => {
if let Some(tx) = signal.take() {
let _ = tx.send($v);
}
};
}
// Create a hidden message-only window. We need it as the
// hwndTarget on the RAWINPUTDEVICE so RIDEV_INPUTSINK delivery
// works even when our process has no visible window focus.
@@ -377,6 +395,7 @@ unsafe fn run_raw_input_loop() -> bool {
error = %e,
"windows ptt: GetModuleHandleW failed"
);
report!(false);
return false;
}
};
@@ -415,6 +434,7 @@ unsafe fn run_raw_input_loop() -> bool {
target: "chanora_audio",
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
);
report!(false);
return false;
}
@@ -445,6 +465,7 @@ unsafe fn run_raw_input_loop() -> bool {
target: "chanora_audio",
"windows ptt: RegisterRawInputDevices failed"
);
report!(false);
return false;
}
@@ -452,6 +473,11 @@ unsafe fn run_raw_input_loop() -> bool {
target: "chanora_audio",
"windows ptt: Raw Input devices registered (keyboard + mouse, INPUTSINK)"
);
// Signal readiness NOW (before we block on GetMessageW). The
// main thread's init probe is waiting for this; the loop runs
// until WM_QUIT and the return value at end-of-life is no
// longer used as a readiness signal.
report!(true);
// Message pump. GetMessageW returns 0 on WM_QUIT, -1 on error.
let mut msg = MSG::default();
@@ -695,9 +721,8 @@ impl DesktopPttBackend for WindowsHookBackend {
});
});
let ok = unsafe { run_hook_loop() };
let ok = unsafe { run_hook_loop(init_tx) };
armed.store(ok, Ordering::Release);
let _ = init_tx.send(ok);
HOOK_CTX.with(|cell| {
*cell.borrow_mut() = None;
@@ -770,7 +795,16 @@ impl Drop for WindowsHookBackend {
/// # Safety
/// Calls Win32 directly; must run on the thread that owns the
/// hook handles.
unsafe fn run_hook_loop() -> bool {
unsafe fn run_hook_loop(init_tx: std::sync::mpsc::Sender<bool>) -> bool {
let mut signal = Some(init_tx);
macro_rules! report {
($v:expr) => {
if let Some(tx) = signal.take() {
let _ = tx.send($v);
}
};
}
let h_instance: HMODULE = match GetModuleHandleW(None) {
Ok(h) => h,
Err(e) => {
@@ -779,6 +813,7 @@ unsafe fn run_hook_loop() -> bool {
error = %e,
"windows ptt: GetModuleHandleW failed (hook)"
);
report!(false);
return false;
}
};
@@ -794,6 +829,7 @@ unsafe fn run_hook_loop() -> bool {
error = %e,
"windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed"
);
report!(false);
return false;
}
};
@@ -806,6 +842,7 @@ unsafe fn run_hook_loop() -> bool {
"windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed"
);
let _ = UnhookWindowsHookEx(kbd_hook);
report!(false);
return false;
}
};
@@ -814,6 +851,10 @@ unsafe fn run_hook_loop() -> bool {
target: "chanora_audio",
"windows ptt: low-level hooks installed (WH_KEYBOARD_LL + WH_MOUSE_LL)"
);
// Signal readiness now, before blocking on GetMessageW. The
// return value at end-of-loop is no longer used by the init
// probe.
report!(true);
let mut msg = MSG::default();
loop {
+30 -9
View File
@@ -55,7 +55,8 @@ fn log_sink() -> &'static chanora_core::InMemoryLogSink {
/// `tsproto::resend` and `tsproto::packet_codec` paths that
/// flood the diagnostic export during transient packet loss;
/// users can still raise verbosity via `RUST_LOG=info`.
const DEFAULT_LOG_FILTER: &str = "info,tsproto::resend=error,tsproto::packet_codec=error";
const DEFAULT_LOG_FILTER: &str =
"info,tsproto::resend=error,tsproto::packet_codec=error,tsclientlib=error";
/// Initialise the bridge. Must be called once on Dart side before
/// any other API call. Sets up panic logging.
@@ -193,17 +194,32 @@ fn open_log_file() -> Option<std::fs::File> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Rotate: if the existing file exceeds 4 MiB rename it to .1 so
// we never grow unbounded. One generation is enough for
// debugging; we are not building a real log-rotation system.
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > 4 * 1024 * 1024 {
let _ = std::fs::rename(&path, path.with_extension("log.1"));
}
// Rotate at every launch (not on size). A single session can
// emit huge logs when a chatty subsystem floods (the original
// bug that surfaced this: tsclientlib emitting one warning per
// outbound voice frame while server-side muted). Truncating on
// every launch keeps per-session disk use bounded by what one
// session can produce in its lifetime; the on-launch rotate
// also gives the previous session's log a stable home at
// `chanora.log.1` for post-mortem inspection.
//
// Two generations kept: `chanora.log.1` (previous launch) and
// `chanora.log.2` (the one before that). Older generations
// are deleted to keep disk use bounded across many launches.
if path.exists() {
let g1 = path.with_extension("log.1");
let g2 = path.with_extension("log.2");
// .2 is dropped; .1 becomes .2; current becomes .1.
let _ = std::fs::remove_file(&g2);
let _ = std::fs::rename(&g1, &g2);
let _ = std::fs::rename(&path, &g1);
}
// Open fresh (truncate if rename somehow failed so we never
// append onto a stale file).
std::fs::OpenOptions::new()
.create(true)
.append(true)
.write(true)
.truncate(true)
.open(&path)
.ok()
}
@@ -251,6 +267,10 @@ pub struct BridgeSnapshot {
pub channels: Vec<BridgeChannel>,
/// Clients currently known.
pub clients: Vec<BridgeClient>,
/// Our own client id. Useful for the UI to highlight our row
/// in the client list and to know which channel we are in
/// without trusting the optimistic local state.
pub own_client_id: u64,
}
impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
@@ -279,6 +299,7 @@ impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
name: c.name,
})
.collect(),
own_client_id: s.own_client_id,
}
}
}
@@ -1348,6 +1348,7 @@ impl SseDecode for crate::api::BridgeSnapshot {
let mut var_version = <String>::sse_decode(deserializer);
let mut var_channels = <Vec<crate::api::BridgeChannel>>::sse_decode(deserializer);
let mut var_clients = <Vec<crate::api::BridgeClient>>::sse_decode(deserializer);
let mut var_ownClientId = <u64>::sse_decode(deserializer);
return crate::api::BridgeSnapshot {
server_name: var_serverName,
welcome_message: var_welcomeMessage,
@@ -1355,6 +1356,7 @@ impl SseDecode for crate::api::BridgeSnapshot {
version: var_version,
channels: var_channels,
clients: var_clients,
own_client_id: var_ownClientId,
};
}
}
@@ -1769,6 +1771,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeSnapshot {
self.version.into_into_dart().into_dart(),
self.channels.into_into_dart().into_dart(),
self.clients.into_into_dart().into_dart(),
self.own_client_id.into_into_dart().into_dart(),
]
.into_dart()
}
@@ -2012,6 +2015,7 @@ impl SseEncode for crate::api::BridgeSnapshot {
<String>::sse_encode(self.version, serializer);
<Vec<crate::api::BridgeChannel>>::sse_encode(self.channels, serializer);
<Vec<crate::api::BridgeClient>>::sse_encode(self.clients, serializer);
<u64>::sse_encode(self.own_client_id, serializer);
}
}
+1
View File
@@ -707,6 +707,7 @@ fn build_snapshot(con: &Connection) -> Result<ServerSnapshot, ProtocolError> {
version: sanitize(&state.server.version),
channels: channels_dto,
clients: clients_dto,
own_client_id: state.own_client.0 as u64,
})
}
+5
View File
@@ -52,6 +52,11 @@ pub struct ServerSnapshot {
pub channels: Vec<ChannelInfo>,
/// All clients currently known.
pub clients: Vec<ClientInfo>,
/// Our own client id as the server published it. Used by the
/// core session to confirm that a `move_to_channel` request
/// actually applied (vs being silently rejected by the
/// server's permission check).
pub own_client_id: u64,
}
impl ChannelId {