mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-11 23:11:01 +03:00
fix(macos): recover system-stopped audio capture streams (#16123)
* fix(macos): recreate system-stopped audio capture streams Pin CPAL's ScreenCaptureKit stop notifications and retain interruption state with each capture stream. Recreate an interrupted stream through the existing service restart path, outside the backend error callback, and resend its audio format. Late callbacks cannot restart a replacement. A natural -3821 stop was observed with the remote connection still open. Its OS trigger remains unknown and it has no deterministic natural reproducer. Controlled verification stops the real SCStream and delivers an explicitly marked -3821 notification; this is not a natural failure. Dependency: https://github.com/rustdesk-org/cpal/pull/5 Validation: requested macOS Rust and Flutter debug builds; three full-crate regression tests; build check without ScreenCaptureKit; two controlled recreations on one connection with independently recorded receiver audio. * chore(macos): log audio capture startup and resumed samples * Update deps, cpal Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1717,7 +1717,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "cpal"
|
name = "cpal"
|
||||||
version = "0.15.3"
|
version = "0.15.3"
|
||||||
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#41c8a2a75903ffe9cd44cf176ce6b69e5d916d47"
|
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#69ad2578adc9200093fc81cdfbdad63dbc4274f9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alsa",
|
"alsa",
|
||||||
"cidre",
|
"cidre",
|
||||||
|
|||||||
72
src/server/audio_capture_error.rs
Normal file
72
src/server/audio_capture_error.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each stream owns its flag so a late callback cannot restart a replacement.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(super) struct CaptureErrorHandler {
|
||||||
|
interrupted: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureErrorHandler {
|
||||||
|
pub(super) fn handle(&self, error: cpal::StreamError) {
|
||||||
|
if matches!(error, cpal::StreamError::StreamInterrupted { .. }) {
|
||||||
|
// ScreenCaptureKit can stop capture while the remote session stays open.
|
||||||
|
// The observed -3821 error does not identify its underlying trigger.
|
||||||
|
// https://developer.apple.com/documentation/screencapturekit/scstreamdelegate/stream(_:didstopwitherror:)
|
||||||
|
hbb_common::log::error!("Audio capture stream interrupted: {error}");
|
||||||
|
self.interrupted.store(true, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
// Keep frequent sample-buffer errors at the existing trace level.
|
||||||
|
hbb_common::log::trace!("an error occurred on stream: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn needs_restart(&self) -> bool {
|
||||||
|
self.interrupted.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use cpal::{BackendSpecificError, StreamError};
|
||||||
|
|
||||||
|
fn system_interruption() -> StreamError {
|
||||||
|
StreamError::StreamInterrupted {
|
||||||
|
err: BackendSpecificError {
|
||||||
|
description: "ScreenCaptureKit system-stopped capture (-3821)".to_owned(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_interruption_requests_recreation_of_the_active_stream() {
|
||||||
|
let errors = CaptureErrorHandler::default();
|
||||||
|
let callback = errors.clone();
|
||||||
|
assert!(!errors.needs_restart());
|
||||||
|
callback.handle(system_interruption());
|
||||||
|
assert!(errors.needs_restart());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn late_error_from_an_old_stream_does_not_restart_its_replacement() {
|
||||||
|
let old_callback = CaptureErrorHandler::default();
|
||||||
|
let replacement = CaptureErrorHandler::default();
|
||||||
|
old_callback.handle(system_interruption());
|
||||||
|
assert!(old_callback.needs_restart());
|
||||||
|
assert!(!replacement.needs_restart());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_backend_errors_do_not_request_recreation() {
|
||||||
|
let errors = CaptureErrorHandler::default();
|
||||||
|
errors.handle(StreamError::BackendSpecific {
|
||||||
|
err: BackendSpecificError {
|
||||||
|
description: "A sample buffer could not be read".to_owned(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert!(!errors.needs_restart());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,9 +171,14 @@ pub fn is_screen_capture_kit_available() -> bool {
|
|||||||
.any(|host| *host == cpal::HostId::ScreenCaptureKit)
|
.any(|host| *host == cpal::HostId::ScreenCaptureKit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
|
#[path = "audio_capture_error.rs"]
|
||||||
|
mod audio_capture_error;
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
mod cpal_impl {
|
mod cpal_impl {
|
||||||
use self::service::{Reset, ServiceSwap};
|
use self::service::{Reset, ServiceSwap};
|
||||||
|
use super::audio_capture_error::CaptureErrorHandler;
|
||||||
use super::*;
|
use super::*;
|
||||||
use cpal::{
|
use cpal::{
|
||||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||||
@@ -192,7 +197,7 @@ mod cpal_impl {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct State {
|
pub struct State {
|
||||||
stream: Option<(Box<dyn StreamTrait>, Arc<Message>)>,
|
stream: Option<(Box<dyn StreamTrait>, Arc<Message>, CaptureErrorHandler)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl super::service::Reset for State {
|
impl super::service::Reset for State {
|
||||||
@@ -210,8 +215,10 @@ mod cpal_impl {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
if let Some((_, format)) = &state.stream {
|
if let Some((_, format, _)) = &state.stream {
|
||||||
sp.send_shared(format.clone());
|
sp.send_shared(format.clone());
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
log::info!("Audio capture stream recreated; replacement format sent");
|
||||||
}
|
}
|
||||||
RESTARTING.store(false, Ordering::SeqCst);
|
RESTARTING.store(false, Ordering::SeqCst);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -225,7 +232,7 @@ mod cpal_impl {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
if let Some((_, format)) = &state.stream {
|
if let Some((_, format, _)) = &state.stream {
|
||||||
sps.send_shared(format.clone());
|
sps.send_shared(format.clone());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -234,6 +241,13 @@ mod cpal_impl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(sp: EmptyExtraFieldService, state: &mut State) -> ResultType<()> {
|
pub fn run(sp: EmptyExtraFieldService, state: &mut State) -> ResultType<()> {
|
||||||
|
if let Some((_, _, errors)) = &state.stream {
|
||||||
|
if errors.needs_restart() {
|
||||||
|
// Recreate on the service thread, outside the backend's error callback.
|
||||||
|
log::warn!("Recreating interrupted audio capture stream");
|
||||||
|
super::restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
if !RESTARTING.load(Ordering::SeqCst) {
|
if !RESTARTING.load(Ordering::SeqCst) {
|
||||||
run_serv_snapshot(sp, state)
|
run_serv_snapshot(sp, state)
|
||||||
} else {
|
} else {
|
||||||
@@ -353,7 +367,9 @@ mod cpal_impl {
|
|||||||
Ok((device, format))
|
Ok((device, format))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play(sp: &GenericService) -> ResultType<(Box<dyn StreamTrait>, Arc<Message>)> {
|
fn play(
|
||||||
|
sp: &GenericService,
|
||||||
|
) -> ResultType<(Box<dyn StreamTrait>, Arc<Message>, CaptureErrorHandler)> {
|
||||||
use cpal::SampleFormat::*;
|
use cpal::SampleFormat::*;
|
||||||
let (device, config) = get_device()?;
|
let (device, config) = get_device()?;
|
||||||
let sp = sp.clone();
|
let sp = sp.clone();
|
||||||
@@ -371,7 +387,7 @@ mod cpal_impl {
|
|||||||
48000
|
48000
|
||||||
};
|
};
|
||||||
let ch = if config.channels() > 1 { Stereo } else { Mono };
|
let ch = if config.channels() > 1 { Stereo } else { Mono };
|
||||||
let stream = match config.sample_format() {
|
let (stream, errors) = match config.sample_format() {
|
||||||
I8 => build_input_stream::<i8>(device, &config, sp, sample_rate, ch)?,
|
I8 => build_input_stream::<i8>(device, &config, sp, sample_rate, ch)?,
|
||||||
I16 => build_input_stream::<i16>(device, &config, sp, sample_rate, ch)?,
|
I16 => build_input_stream::<i16>(device, &config, sp, sample_rate, ch)?,
|
||||||
I32 => build_input_stream::<i32>(device, &config, sp, sample_rate, ch)?,
|
I32 => build_input_stream::<i32>(device, &config, sp, sample_rate, ch)?,
|
||||||
@@ -385,9 +401,12 @@ mod cpal_impl {
|
|||||||
f => bail!("unsupported audio format: {:?}", f),
|
f => bail!("unsupported audio format: {:?}", f),
|
||||||
};
|
};
|
||||||
stream.play()?;
|
stream.play()?;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
log::info!("Audio capture start call succeeded");
|
||||||
Ok((
|
Ok((
|
||||||
Box::new(stream),
|
Box::new(stream),
|
||||||
Arc::new(create_format_msg(sample_rate, ch as _)),
|
Arc::new(create_format_msg(sample_rate, ch as _)),
|
||||||
|
errors,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,14 +416,15 @@ mod cpal_impl {
|
|||||||
sp: GenericService,
|
sp: GenericService,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
encode_channel: magnum_opus::Channels,
|
encode_channel: magnum_opus::Channels,
|
||||||
) -> ResultType<cpal::Stream>
|
) -> ResultType<(cpal::Stream, CaptureErrorHandler)>
|
||||||
where
|
where
|
||||||
T: cpal::SizedSample + dasp::sample::ToSample<f32>,
|
T: cpal::SizedSample + dasp::sample::ToSample<f32>,
|
||||||
{
|
{
|
||||||
let err_fn = move |err| {
|
let errors = CaptureErrorHandler::default();
|
||||||
// too many UnknownErrno, will improve later
|
let callback_errors = errors.clone();
|
||||||
log::trace!("an error occurred on stream: {}", err);
|
let err_fn = move |err| callback_errors.handle(err);
|
||||||
};
|
#[cfg(target_os = "macos")]
|
||||||
|
let (mut received_samples, mut received_signal) = (false, false);
|
||||||
let sample_rate_0 = config.sample_rate().0;
|
let sample_rate_0 = config.sample_rate().0;
|
||||||
log::debug!("Audio sample rate : {}", sample_rate);
|
log::debug!("Audio sample rate : {}", sample_rate);
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -431,6 +451,25 @@ mod cpal_impl {
|
|||||||
&stream_config,
|
&stream_config,
|
||||||
move |data: &[T], _: &InputCallbackInfo| {
|
move |data: &[T], _: &InputCallbackInfo| {
|
||||||
let buffer: Vec<f32> = data.iter().map(|s| T::to_sample(*s)).collect();
|
let buffer: Vec<f32> = data.iter().map(|s| T::to_sample(*s)).collect();
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
// Starting capture does not guarantee sample delivery or audible data.
|
||||||
|
if !received_samples && !buffer.is_empty() {
|
||||||
|
received_samples = true;
|
||||||
|
log::info!(
|
||||||
|
"Audio capture received first PCM block: {} samples",
|
||||||
|
buffer.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !received_signal
|
||||||
|
&& buffer
|
||||||
|
.iter()
|
||||||
|
.any(|sample| sample.is_finite() && *sample != 0.0)
|
||||||
|
{
|
||||||
|
received_signal = true;
|
||||||
|
log::info!("Audio capture received first nonzero PCM");
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut lock = INPUT_BUFFER.lock().unwrap();
|
let mut lock = INPUT_BUFFER.lock().unwrap();
|
||||||
lock.extend(buffer);
|
lock.extend(buffer);
|
||||||
while lock.len() >= rechannel_len {
|
while lock.len() >= rechannel_len {
|
||||||
@@ -449,7 +488,7 @@ mod cpal_impl {
|
|||||||
err_fn,
|
err_fn,
|
||||||
timeout,
|
timeout,
|
||||||
)?;
|
)?;
|
||||||
Ok(stream)
|
Ok((stream, errors))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user