Skip to content

Commit cb43d87

Browse files
authored
Merge pull request #499 from orottier/bugfix/double-suspend-hangs
Fix hanging UI thread for certain AudioContext::suspend/stop actions
2 parents 28af973 + 1c36f8e commit cb43d87

File tree

3 files changed

+189
-29
lines changed

3 files changed

+189
-29
lines changed

src/context/concrete_base.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,10 +351,8 @@ impl ConcreteBaseAudioContext {
351351

352352
/// Updates state of current context
353353
pub(super) fn set_state(&self, state: AudioContextState) {
354-
// Only to be used from OfflineAudioContext, the online one should emit the state changes
355-
// from the render thread
356-
debug_assert!(self.offline());
357-
354+
// Only used from OfflineAudioContext or suspended AudioContext, otherwise the state
355+
// changed are spawned from the render thread
358356
let current_state = self.state();
359357
if current_state != state {
360358
self.inner.state.store(state as u8, Ordering::Release);

src/context/online.rs

Lines changed: 119 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -279,21 +279,26 @@ impl AudioContext {
279279
/// is currently not implemented.
280280
#[allow(clippy::needless_collect, clippy::missing_panics_doc)]
281281
pub fn set_sink_id_sync(&self, sink_id: String) -> Result<(), Box<dyn Error>> {
282+
log::debug!("SinkChange requested");
282283
if self.sink_id() == sink_id {
284+
log::debug!("SinkChange: no-op");
283285
return Ok(()); // sink is already active
284286
}
285287

286288
if !is_valid_sink_id(&sink_id) {
287289
Err(format!("NotFoundError: invalid sinkId {sink_id}"))?;
288290
};
289291

292+
log::debug!("SinkChange: locking backend manager");
290293
let mut backend_manager_guard = self.backend_manager.lock().unwrap();
291294
let original_state = self.state();
292295
if original_state == AudioContextState::Closed {
296+
log::debug!("SinkChange: context is closed");
293297
return Ok(());
294298
}
295299

296300
// Acquire exclusive lock on ctrl msg sender
301+
log::debug!("SinkChange: locking message channel");
297302
let ctrl_msg_send = self.base.lock_control_msg_sender();
298303

299304
// Flush out the ctrl msg receiver, cache
@@ -303,13 +308,17 @@ impl AudioContext {
303308
let graph = if matches!(pending_msgs.first(), Some(ControlMessage::Startup { .. })) {
304309
// Handle the edge case where the previous backend was suspended for its entire lifetime.
305310
// In this case, the `Startup` control message was never processed.
311+
log::debug!("SinkChange: recover unstarted graph");
312+
306313
let msg = pending_msgs.remove(0);
307314
match msg {
308315
ControlMessage::Startup { graph } => graph,
309316
_ => unreachable!(),
310317
}
311318
} else {
312319
// Acquire the audio graph from the current render thread, shutting it down
320+
log::debug!("SinkChange: recover graph from render thread");
321+
313322
let (graph_send, graph_recv) = crossbeam_channel::bounded(1);
314323
let message = ControlMessage::CloseAndRecycle { sender: graph_send };
315324
ctrl_msg_send.send(message).unwrap();
@@ -321,6 +330,7 @@ impl AudioContext {
321330
graph_recv.recv().unwrap()
322331
};
323332

333+
log::debug!("SinkChange: closing audio stream");
324334
backend_manager_guard.close();
325335

326336
// hotswap the backend
@@ -330,10 +340,12 @@ impl AudioContext {
330340
sink_id,
331341
render_size_hint: AudioContextRenderSizeCategory::default(), // todo reuse existing setting
332342
};
343+
log::debug!("SinkChange: starting audio stream");
333344
*backend_manager_guard = io::build_output(options, self.render_thread_init.clone());
334345

335346
// if the previous backend state was suspend, suspend the new one before shipping the graph
336347
if original_state == AudioContextState::Suspended {
348+
log::debug!("SinkChange: suspending audio stream");
337349
backend_manager_guard.suspend();
338350
}
339351

@@ -352,6 +364,7 @@ impl AudioContext {
352364
// trigger event when all the work is done
353365
let _ = self.base.send_event(EventDispatch::sink_change());
354366

367+
log::debug!("SinkChange: done");
355368
Ok(())
356369
}
357370

@@ -422,18 +435,30 @@ impl AudioContext {
422435
/// * The audio device is not available
423436
/// * For a `BackendSpecificError`
424437
pub async fn suspend(&self) {
425-
// First, pause rendering via a control message
438+
// Don't lock the backend manager because we can't hold is across the await point
439+
log::debug!("Suspend called");
440+
441+
if self.state() != AudioContextState::Running {
442+
log::debug!("Suspend no-op - context is not running");
443+
return;
444+
}
445+
446+
// Pause rendering via a control message
426447
let (sender, receiver) = oneshot::channel();
427448
let notify = OneshotNotify::Async(sender);
428449
self.base
429450
.send_control_msg(ControlMessage::Suspend { notify });
430451

431452
// Wait for the render thread to have processed the suspend message.
432453
// The AudioContextState will be updated by the render thread.
454+
log::debug!("Suspending audio graph, waiting for signal..");
433455
receiver.await.unwrap();
434456

435457
// Then ask the audio host to suspend the stream
458+
log::debug!("Suspended audio graph. Suspending audio stream..");
436459
self.backend_manager.lock().unwrap().suspend();
460+
461+
log::debug!("Suspended audio stream");
437462
}
438463

439464
/// Resumes the progression of time in an audio context that has previously been
@@ -445,19 +470,34 @@ impl AudioContext {
445470
///
446471
/// * The audio device is not available
447472
/// * For a `BackendSpecificError`
473+
#[allow(clippy::await_holding_lock)] // false positive due to explicit drop
448474
pub async fn resume(&self) {
449-
// First ask the audio host to resume the stream
450-
self.backend_manager.lock().unwrap().resume();
475+
// Lock the backend manager mutex to avoid concurrent calls
476+
log::debug!("Resume called, locking backend manager");
477+
let backend_manager_guard = self.backend_manager.lock().unwrap();
478+
479+
if self.state() != AudioContextState::Suspended {
480+
log::debug!("Resume no-op - context is not suspended");
481+
return;
482+
}
483+
484+
// Ask the audio host to resume the stream
485+
backend_manager_guard.resume();
451486

452487
// Then, ask to resume rendering via a control message
488+
log::debug!("Resumed audio stream, waking audio graph");
453489
let (sender, receiver) = oneshot::channel();
454490
let notify = OneshotNotify::Async(sender);
455491
self.base
456492
.send_control_msg(ControlMessage::Resume { notify });
457493

494+
// Drop the Mutex guard so we won't hold it across an await
495+
drop(backend_manager_guard);
496+
458497
// Wait for the render thread to have processed the resume message
459498
// The AudioContextState will be updated by the render thread.
460499
receiver.await.unwrap();
500+
log::debug!("Resumed audio graph");
461501
}
462502

463503
/// Closes the `AudioContext`, releasing the system resources being used.
@@ -469,22 +509,37 @@ impl AudioContext {
469509
///
470510
/// Will panic when this function is called multiple times
471511
pub async fn close(&self) {
472-
// First, stop rendering via a control message
473-
let (sender, receiver) = oneshot::channel();
474-
let notify = OneshotNotify::Async(sender);
475-
self.base.send_control_msg(ControlMessage::Close { notify });
512+
// Don't lock the backend manager because we can't hold is across the await point
513+
log::debug!("Close called");
476514

477-
// Wait for the render thread to have processed the suspend message.
478-
// The AudioContextState will be updated by the render thread.
479-
receiver.await.unwrap();
515+
if self.state() == AudioContextState::Closed {
516+
log::debug!("Close no-op - context is already closed");
517+
return;
518+
}
519+
520+
if self.state() == AudioContextState::Running {
521+
// First, stop rendering via a control message
522+
let (sender, receiver) = oneshot::channel();
523+
let notify = OneshotNotify::Async(sender);
524+
self.base.send_control_msg(ControlMessage::Close { notify });
525+
526+
// Wait for the render thread to have processed the suspend message.
527+
// The AudioContextState will be updated by the render thread.
528+
log::debug!("Suspending audio graph, waiting for signal..");
529+
receiver.await.unwrap();
530+
} else {
531+
// if the context is not running, change the state manually
532+
self.base.set_state(AudioContextState::Closed);
533+
}
480534

481535
// Then ask the audio host to close the stream
536+
log::debug!("Suspended audio graph. Closing audio stream..");
482537
self.backend_manager.lock().unwrap().close();
483538

484539
// Stop the AudioRenderCapacity collection thread
485540
self.render_capacity.stop();
486541

487-
// TODO stop the event loop <https://github.com/orottier/web-audio-api-rs/issues/421>
542+
log::debug!("Closed audio stream");
488543
}
489544

490545
/// Suspends the progression of time in the audio context.
@@ -502,18 +557,31 @@ impl AudioContext {
502557
/// * The audio device is not available
503558
/// * For a `BackendSpecificError`
504559
pub fn suspend_sync(&self) {
505-
// First, pause rendering via a control message
560+
// Lock the backend manager mutex to avoid concurrent calls
561+
log::debug!("Suspend_sync called, locking backend manager");
562+
let backend_manager_guard = self.backend_manager.lock().unwrap();
563+
564+
if self.state() != AudioContextState::Running {
565+
log::debug!("Suspend_sync no-op - context is not running");
566+
return;
567+
}
568+
569+
// Pause rendering via a control message
506570
let (sender, receiver) = crossbeam_channel::bounded(0);
507571
let notify = OneshotNotify::Sync(sender);
508572
self.base
509573
.send_control_msg(ControlMessage::Suspend { notify });
510574

511575
// Wait for the render thread to have processed the suspend message.
512576
// The AudioContextState will be updated by the render thread.
577+
log::debug!("Suspending audio graph, waiting for signal..");
513578
receiver.recv().ok();
514579

515580
// Then ask the audio host to suspend the stream
516-
self.backend_manager.lock().unwrap().suspend();
581+
log::debug!("Suspended audio graph. Suspending audio stream..");
582+
backend_manager_guard.suspend();
583+
584+
log::debug!("Suspended audio stream");
517585
}
518586

519587
/// Resumes the progression of time in an audio context that has previously been
@@ -529,10 +597,20 @@ impl AudioContext {
529597
/// * The audio device is not available
530598
/// * For a `BackendSpecificError`
531599
pub fn resume_sync(&self) {
532-
// First ask the audio host to resume the stream
533-
self.backend_manager.lock().unwrap().resume();
600+
// Lock the backend manager mutex to avoid concurrent calls
601+
log::debug!("Resume_sync called, locking backend manager");
602+
let backend_manager_guard = self.backend_manager.lock().unwrap();
603+
604+
if self.state() != AudioContextState::Suspended {
605+
log::debug!("Resume no-op - context is not suspended");
606+
return;
607+
}
608+
609+
// Ask the audio host to resume the stream
610+
backend_manager_guard.resume();
534611

535612
// Then, ask to resume rendering via a control message
613+
log::debug!("Resumed audio stream, waking audio graph");
536614
let (sender, receiver) = crossbeam_channel::bounded(0);
537615
let notify = OneshotNotify::Sync(sender);
538616
self.base
@@ -541,6 +619,7 @@ impl AudioContext {
541619
// Wait for the render thread to have processed the resume message
542620
// The AudioContextState will be updated by the render thread.
543621
receiver.recv().ok();
622+
log::debug!("Resumed audio graph");
544623
}
545624

546625
/// Closes the `AudioContext`, releasing the system resources being used.
@@ -555,22 +634,38 @@ impl AudioContext {
555634
///
556635
/// Will panic when this function is called multiple times
557636
pub fn close_sync(&self) {
558-
// First, stop rendering via a control message
559-
let (sender, receiver) = crossbeam_channel::bounded(0);
560-
let notify = OneshotNotify::Sync(sender);
561-
self.base.send_control_msg(ControlMessage::Close { notify });
637+
// Lock the backend manager mutex to avoid concurrent calls
638+
log::debug!("Close_sync called, locking backend manager");
639+
let backend_manager_guard = self.backend_manager.lock().unwrap();
562640

563-
// Wait for the render thread to have processed the suspend message.
564-
// The AudioContextState will be updated by the render thread.
565-
receiver.recv().ok();
641+
if self.state() == AudioContextState::Closed {
642+
log::debug!("Close no-op - context is already closed");
643+
return;
644+
}
645+
646+
// First, stop rendering via a control message
647+
if self.state() == AudioContextState::Running {
648+
let (sender, receiver) = crossbeam_channel::bounded(0);
649+
let notify = OneshotNotify::Sync(sender);
650+
self.base.send_control_msg(ControlMessage::Close { notify });
651+
652+
// Wait for the render thread to have processed the suspend message.
653+
// The AudioContextState will be updated by the render thread.
654+
log::debug!("Suspending audio graph, waiting for signal..");
655+
receiver.recv().ok();
656+
} else {
657+
// if the context is not running, change the state manually
658+
self.base.set_state(AudioContextState::Closed);
659+
}
566660

567661
// Then ask the audio host to close the stream
568-
self.backend_manager.lock().unwrap().close();
662+
log::debug!("Suspended audio graph. Closing audio stream..");
663+
backend_manager_guard.close();
569664

570665
// Stop the AudioRenderCapacity collection thread
571666
self.render_capacity.stop();
572667

573-
// TODO stop the event loop <https://github.com/orottier/web-audio-api-rs/issues/421>
668+
log::debug!("Closed audio stream");
574669
}
575670

576671
/// Creates a [`MediaStreamAudioSourceNode`](node::MediaStreamAudioSourceNode) from a

tests/online.rs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,79 @@ fn test_closed() {
219219
let context = AudioContext::new(options);
220220
let node = context.create_gain();
221221

222-
// close the context, and drop it as well (otherwise the comms channel is kept alive)
222+
// Close the context
223223
context.close_sync();
224+
assert_eq!(context.state(), AudioContextState::Closed);
225+
226+
// Should not be able to resume
227+
context.resume_sync();
228+
assert_eq!(context.state(), AudioContextState::Closed);
229+
230+
// Drop the context (otherwise the comms channel is kept alive)
224231
drop(context);
225232

226233
// allow some time for the render thread to drop
227234
std::thread::sleep(Duration::from_millis(10));
228235

229236
node.disconnect(); // should not panic
230237
}
238+
239+
#[test]
240+
fn test_double_suspend() {
241+
let options = AudioContextOptions {
242+
sink_id: "none".into(),
243+
..AudioContextOptions::default()
244+
};
245+
let context = AudioContext::new(options);
246+
247+
context.suspend_sync();
248+
assert_eq!(context.state(), AudioContextState::Suspended);
249+
context.suspend_sync();
250+
assert_eq!(context.state(), AudioContextState::Suspended);
251+
context.resume_sync();
252+
assert_eq!(context.state(), AudioContextState::Running);
253+
}
254+
255+
#[test]
256+
fn test_double_resume() {
257+
let options = AudioContextOptions {
258+
sink_id: "none".into(),
259+
..AudioContextOptions::default()
260+
};
261+
let context = AudioContext::new(options);
262+
263+
context.suspend_sync();
264+
assert_eq!(context.state(), AudioContextState::Suspended);
265+
context.resume_sync();
266+
assert_eq!(context.state(), AudioContextState::Running);
267+
context.resume_sync();
268+
assert_eq!(context.state(), AudioContextState::Running);
269+
}
270+
271+
#[test]
272+
fn test_double_close() {
273+
let options = AudioContextOptions {
274+
sink_id: "none".into(),
275+
..AudioContextOptions::default()
276+
};
277+
let context = AudioContext::new(options);
278+
279+
context.close_sync();
280+
assert_eq!(context.state(), AudioContextState::Closed);
281+
context.close_sync();
282+
assert_eq!(context.state(), AudioContextState::Closed);
283+
}
284+
285+
#[test]
286+
fn test_suspend_then_close() {
287+
let options = AudioContextOptions {
288+
sink_id: "none".into(),
289+
..AudioContextOptions::default()
290+
};
291+
let context = AudioContext::new(options);
292+
293+
context.suspend_sync();
294+
assert_eq!(context.state(), AudioContextState::Suspended);
295+
context.close_sync();
296+
assert_eq!(context.state(), AudioContextState::Closed);
297+
}

0 commit comments

Comments
 (0)