Skip to content

Commit 9a49c1b

Browse files
authored
fix(sound): suppress ALSA stderr errors on headless systems (#116)
Suppress ALSA stderr errors on headless Linux systems by temporarily redirecting stderr to /dev/null during audio initialization. Fixes include: - Check dup2 return value during stderr restoration and log errors - Document the thread safety trade-off of process-wide stderr redirection
1 parent ebabc51 commit 9a49c1b

File tree

1 file changed

+124
-8
lines changed

1 file changed

+124
-8
lines changed

src/cortex-tui/src/sound.rs

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
//!
1010
//! On platforms without audio support (e.g., musl builds), falls back to
1111
//! terminal bell notifications.
12+
//!
13+
//! On Linux, ALSA error messages (e.g., "cannot find card 0") are suppressed
14+
//! during audio initialization to avoid noisy output on headless systems.
1215
1316
#[cfg(feature = "audio")]
1417
use std::io::Cursor;
@@ -44,6 +47,125 @@ const COMPLETE_WAV: &[u8] = include_bytes!("sounds/complete.wav");
4447
#[cfg(feature = "audio")]
4548
const APPROVAL_WAV: &[u8] = include_bytes!("sounds/approval.wav");
4649

50+
/// Try to create audio output stream, suppressing ALSA errors on Linux.
51+
///
52+
/// On Linux, ALSA prints error messages directly to stderr when no audio
53+
/// hardware is available (e.g., "ALSA lib confmisc.c: cannot find card 0").
54+
/// This function suppresses those messages by temporarily redirecting stderr
55+
/// to /dev/null during initialization.
56+
///
57+
/// # Thread Safety Note
58+
///
59+
/// This function temporarily redirects the process-wide stderr file descriptor (fd 2)
60+
/// to /dev/null. This is a global operation that affects all threads. Any concurrent
61+
/// stderr writes from other threads or libraries will be silently dropped during the
62+
/// brief window when ALSA initialization occurs.
63+
///
64+
/// This trade-off is acceptable because:
65+
/// - The redirection window is very short (only during `OutputStream::try_default()`)
66+
/// - This function is called once at startup from a dedicated audio thread
67+
/// - ALSA error messages are noisy and unhelpful on headless systems
68+
/// - The alternative (letting ALSA spam stderr) degrades user experience significantly
69+
///
70+
/// If stricter isolation is needed, consider calling `init()` before spawning other
71+
/// threads that may write to stderr.
72+
#[cfg(all(feature = "audio", target_os = "linux"))]
73+
fn try_create_output_stream() -> Option<(rodio::OutputStream, rodio::OutputStreamHandle)> {
74+
use std::os::unix::io::AsRawFd;
75+
76+
// Open /dev/null for redirecting stderr
77+
let dev_null = match std::fs::File::open("/dev/null") {
78+
Ok(f) => f,
79+
Err(_) => {
80+
// Can't open /dev/null, try without suppression
81+
return match rodio::OutputStream::try_default() {
82+
Ok((stream, handle)) => Some((stream, handle)),
83+
Err(e) => {
84+
tracing::debug!("Failed to initialize audio output: {}", e);
85+
None
86+
}
87+
};
88+
}
89+
};
90+
91+
// Save the original stderr file descriptor
92+
// SAFETY: dup is safe to call with a valid file descriptor (2 = stderr)
93+
let original_stderr = unsafe { libc::dup(2) };
94+
if original_stderr == -1 {
95+
// dup failed, try without suppression
96+
return match rodio::OutputStream::try_default() {
97+
Ok((stream, handle)) => Some((stream, handle)),
98+
Err(e) => {
99+
tracing::debug!("Failed to initialize audio output: {}", e);
100+
None
101+
}
102+
};
103+
}
104+
105+
// Redirect stderr to /dev/null
106+
// SAFETY: dup2 is safe with valid file descriptors
107+
let redirect_result = unsafe { libc::dup2(dev_null.as_raw_fd(), 2) };
108+
drop(dev_null); // Close our handle to /dev/null
109+
110+
if redirect_result == -1 {
111+
// dup2 failed, restore and try without suppression
112+
// SAFETY: close is safe with a valid file descriptor
113+
unsafe { libc::close(original_stderr) };
114+
return match rodio::OutputStream::try_default() {
115+
Ok((stream, handle)) => Some((stream, handle)),
116+
Err(e) => {
117+
tracing::debug!("Failed to initialize audio output: {}", e);
118+
None
119+
}
120+
};
121+
}
122+
123+
// Try to create the audio output stream (ALSA errors will go to /dev/null)
124+
let result = rodio::OutputStream::try_default();
125+
126+
// Restore the original stderr
127+
// SAFETY: dup2 and close are safe with valid file descriptors
128+
unsafe {
129+
let restore_result = libc::dup2(original_stderr, 2);
130+
if restore_result == -1 {
131+
// dup2 failed to restore stderr - this is a critical issue as stderr
132+
// will remain redirected to /dev/null for the rest of the process.
133+
// Log the error (which ironically may not be visible if stderr is broken).
134+
// Keep original_stderr open in case we can retry later or for debugging.
135+
tracing::error!(
136+
"Failed to restore stderr after ALSA initialization (dup2 returned -1). \
137+
Stderr may remain redirected to /dev/null."
138+
);
139+
} else {
140+
// Only close original_stderr if dup2 succeeded, as we may still need
141+
// it for retry attempts if restoration failed.
142+
libc::close(original_stderr);
143+
}
144+
}
145+
146+
match result {
147+
Ok((stream, handle)) => Some((stream, handle)),
148+
Err(e) => {
149+
tracing::debug!("Failed to initialize audio output: {}", e);
150+
None
151+
}
152+
}
153+
}
154+
155+
/// Try to create audio output stream (non-Linux platforms).
156+
///
157+
/// On non-Linux platforms, ALSA is not used, so no stderr suppression is needed.
158+
#[cfg(all(feature = "audio", not(target_os = "linux")))]
159+
fn try_create_output_stream() -> Option<(rodio::OutputStream, rodio::OutputStreamHandle)> {
160+
match rodio::OutputStream::try_default() {
161+
Ok((stream, handle)) => Some((stream, handle)),
162+
Err(e) => {
163+
tracing::debug!("Failed to initialize audio output: {}", e);
164+
None
165+
}
166+
}
167+
}
168+
47169
/// Initialize the global sound system.
48170
/// Spawns a dedicated audio thread that owns the OutputStream.
49171
/// Should be called once at application startup.
@@ -67,14 +189,8 @@ pub fn init() {
67189
thread::Builder::new()
68190
.name("cortex-audio".to_string())
69191
.spawn(move || {
70-
// Try to create audio output
71-
let output = match rodio::OutputStream::try_default() {
72-
Ok((stream, handle)) => Some((stream, handle)),
73-
Err(e) => {
74-
tracing::debug!("Failed to initialize audio output: {}", e);
75-
None
76-
}
77-
};
192+
// Try to create audio output (with ALSA error suppression on Linux)
193+
let output = try_create_output_stream();
78194

79195
// Process sound requests
80196
while let Ok(sound_type) = rx.recv() {

0 commit comments

Comments
 (0)