Test builds open no audio device (#2388)

Every headless test app built `GamePlugins`, whose `SoundPlugin` opened the default output device unless `$WOW_NOSOUND` or `$WOW_CAPTURE` was set, and no test run sets either. Under nextest that is one CoreAudio client and speaker stream per test process. With a few runs going at once, `coreaudiod` sat near 300% CPU, every sound on the machine crackled, and each test blocked on the audio server's connect.

A test build now takes the existing silent path, so its mixer is `None`, as with no device.

Test: `a_test_client_opens_no_audio_device`. With the change undone it fails on a machine with a speaker, taking 96 s; with it, the test passes in 0.06 s.

Fixes #2380

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit fbc90b9bbf272a784f3703eb0c68ea466fe90951)

# Conflicts:
#	crates/benilla-app/src/game_plugins.rs
#	docs/DEVIATIONS.md

Absorbed into the octo fork. The sound/mod.rs fix applies unchanged. Of the tests the conflict hunk carried, only this commit's own a_test_client_opens_no_audio_device is kept, retargeted from upstream's headless_client_unrun to our headless_client (same contract: every plugin's build runs, no schedule does); resource_writers and the_glue_preview_has_one_feeder come from other upstream commits and need glue_scene and GluePreview, which this fork has not got.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
Sam Sung
2026-10-25 23:29:25 -06:00
committed by grom
co-authored by Claude Opus 5
parent 73f4bf808d
commit 91bcc19ac0
2 changed files with 18 additions and 7 deletions
+9
View File
@@ -554,6 +554,15 @@ pub(crate) mod schedule_tests {
c.systems.values().any(|s| s.name.contains("benilla_app::"))
}
/// A test client opens no audio device: one stream per test process, one process per test,
/// floods the system audio server and crackles every sound on the machine.
#[test]
fn a_test_client_opens_no_audio_device() {
let app = headless_client();
let output = app.world().non_send_resource::<crate::sound::SoundOutput>();
assert!(output.mixer.is_none());
}
/// One row of the class table: the type's name for the message, and its `TypeId`.
type ClassRow = (&'static str, fn() -> TypeId);
+9 -7
View File
@@ -224,8 +224,8 @@ fn feed_music_suppression(
}
}
/// The backend output; `mixer` is `None` with no device or under `$WOW_NOSOUND`. Non-Send: the
/// device stream is not `Send` on every platform, so audio systems run on the main thread.
/// The backend output; `mixer` is `None` with no device, under `$WOW_NOSOUND` or in a test.
/// Non-Send: the device stream is not `Send` on every platform, so audio systems run on the main thread.
pub(crate) struct SoundOutput {
pub(crate) mixer: Option<Mixer>,
/// Live kit channels, pumped by [`kit::pump_channels`].
@@ -351,19 +351,21 @@ impl Plugin for SoundPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.add_observer(on_cvar);
// `$WOW_NOSOUND` (a silent unattended run) and `$WOW_CAPTURE` open no device at all, so
// they never hold the audio hardware against a session someone is listening to.
// `$WOW_NOSOUND` (a silent unattended run), `$WOW_CAPTURE` and a test build open no device
// at all, so they never hold the audio hardware against a session someone is listening to.
let silent = ["WOW_NOSOUND", "WOW_CAPTURE"]
.into_iter()
.find(|v| std::env::var_os(v).is_some());
.find(|v| std::env::var_os(v).is_some())
.map(|v| format!("${v} set"))
.or_else(|| cfg!(test).then(|| "a test build".to_owned()));
// Before the mixer: the probe's taps are main-track effects, fixed when kira builds it.
let probe_dir = if silent.is_some() {
None
} else {
probe::output_dir()
};
let mixer = if let Some(var) = silent {
info!("${var} set — audio disabled");
let mixer = if let Some(why) = silent {
info!("{why} — audio disabled");
None
} else {
match Mixer::new(probe_dir.as_deref()) {