mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 10:34:24 +08:00
fix: 修正 UAC 链接顺序及原生 ALSA 启动恢复
This commit is contained in:
@@ -9,9 +9,11 @@ use tracing::{info, warn};
|
|||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
const RETRY_BACKOFF: Duration = Duration::from_secs(1);
|
const RETRY_BACKOFF: Duration = Duration::from_secs(1);
|
||||||
const PERIOD_FRAMES: Frames = 960;
|
const PERIOD_FRAMES: Frames = 1_024;
|
||||||
const BUFFER_FRAMES: Frames = 4_800;
|
// Request the same compatibility buffer as the known-working ALSA player.
|
||||||
const START_THRESHOLD_PERIODS: Frames = 4;
|
// The gadget driver may negotiate a smaller buffer; always use its result.
|
||||||
|
const BUFFER_FRAMES: Frames = 32_768;
|
||||||
|
const IDLE_REOPEN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const SINK_STALL_TIMEOUT: Duration = Duration::from_millis(200);
|
const SINK_STALL_TIMEOUT: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -58,9 +60,17 @@ struct PlaybackInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SessionSink {
|
enum SessionSink {
|
||||||
Closed { retry_at: Option<Instant> },
|
Closed {
|
||||||
Probing { pcm: PCM, stalled: bool },
|
retry_at: Option<Instant>,
|
||||||
Active { pcm: PCM, last_progress: Instant },
|
},
|
||||||
|
Probing {
|
||||||
|
pcm: PlaybackPcm,
|
||||||
|
stalled: bool,
|
||||||
|
},
|
||||||
|
Active {
|
||||||
|
pcm: PlaybackPcm,
|
||||||
|
last_progress: Instant,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionSink {
|
impl SessionSink {
|
||||||
@@ -77,12 +87,14 @@ impl SessionSink {
|
|||||||
|
|
||||||
struct SessionRuntime {
|
struct SessionRuntime {
|
||||||
sink: SessionSink,
|
sink: SessionSink,
|
||||||
|
last_frame: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionRuntime {
|
impl SessionRuntime {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
sink: SessionSink::Closed { retry_at: None },
|
sink: SessionSink::Closed { retry_at: None },
|
||||||
|
last_frame: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +104,22 @@ impl SessionRuntime {
|
|||||||
|
|
||||||
fn close(&mut self) {
|
fn close(&mut self) {
|
||||||
self.sink = SessionSink::Closed { retry_at: None };
|
self.sink = SessionSink::Closed { retry_at: None };
|
||||||
|
self.last_frame = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance playback only when a WebSocket frame arrives. All ALSA handles
|
/// Advance playback only when a WebSocket frame arrives. All ALSA handles
|
||||||
/// are non-blocking, so a slow or absent USB host drops the current frame
|
/// are non-blocking, so a slow or absent USB host drops the current frame
|
||||||
/// instead of occupying a worker thread or accumulating stale speech.
|
/// instead of occupying a worker thread or accumulating stale speech.
|
||||||
fn write(&mut self, config: &UacPlaybackConfig, samples: &[i16]) -> bool {
|
fn write(&mut self, config: &UacPlaybackConfig, samples: &[i16]) -> bool {
|
||||||
|
// Reopen on resume without an idle timer thread. stop()/session drop
|
||||||
|
// still close the PCM synchronously, even when no frames arrive.
|
||||||
|
if self
|
||||||
|
.last_frame
|
||||||
|
.is_some_and(|last| last.elapsed() >= IDLE_REOPEN_TIMEOUT)
|
||||||
|
{
|
||||||
|
self.close();
|
||||||
|
}
|
||||||
|
self.last_frame = Some(Instant::now());
|
||||||
let sink = std::mem::replace(&mut self.sink, SessionSink::Closed { retry_at: None });
|
let sink = std::mem::replace(&mut self.sink, SessionSink::Closed { retry_at: None });
|
||||||
let (next_sink, accepted) = drive_sink(sink, config, samples);
|
let (next_sink, accepted) = drive_sink(sink, config, samples);
|
||||||
self.sink = next_sink;
|
self.sink = next_sink;
|
||||||
@@ -222,8 +244,8 @@ fn drive_sink(
|
|||||||
return (SessionSink::Closed { retry_at }, false);
|
return (SessionSink::Closed { retry_at }, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
match open_pcm(config).and_then(|pcm| {
|
match open_pcm(config).and_then(|mut pcm| {
|
||||||
prime_pcm_with_silence(&pcm, config.channels as usize)?;
|
pcm.prime_with_silence(config.channels as usize)?;
|
||||||
Ok(pcm)
|
Ok(pcm)
|
||||||
}) {
|
}) {
|
||||||
Ok(pcm) => drive_probe(pcm, false, config, samples),
|
Ok(pcm) => drive_probe(pcm, false, config, samples),
|
||||||
@@ -246,64 +268,71 @@ fn drive_sink(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drive_probe(
|
fn drive_probe(
|
||||||
pcm: PCM,
|
mut pcm: PlaybackPcm,
|
||||||
stalled: bool,
|
stalled: bool,
|
||||||
config: &UacPlaybackConfig,
|
config: &UacPlaybackConfig,
|
||||||
samples: &[i16],
|
samples: &[i16],
|
||||||
) -> (SessionSink, bool) {
|
) -> (SessionSink, bool) {
|
||||||
match sink_is_consuming(&pcm) {
|
match pcm.consumption_progress() {
|
||||||
Ok(false) => (SessionSink::Probing { pcm, stalled }, false),
|
Ok(false) => (SessionSink::Probing { pcm, stalled }, false),
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
if let Err(error) = reset_pcm_buffer(&pcm) {
|
// Keep the stream that has just started consuming. Dropping and
|
||||||
warn!("Failed to activate UAC playback; retrying later: {error}");
|
// preparing it here creates another startup/underrun window.
|
||||||
return retry_later();
|
|
||||||
}
|
|
||||||
info!("UAC target started consuming microphone audio");
|
info!("UAC target started consuming microphone audio");
|
||||||
drive_active(pcm, Instant::now(), config, samples)
|
drive_active(pcm, Instant::now(), config, samples)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => recover_sink(pcm, config, error),
|
||||||
warn!("Failed to probe UAC playback; retrying later: {error}");
|
|
||||||
retry_later()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drive_active(
|
fn drive_active(
|
||||||
pcm: PCM,
|
mut pcm: PlaybackPcm,
|
||||||
last_progress: Instant,
|
last_progress: Instant,
|
||||||
config: &UacPlaybackConfig,
|
config: &UacPlaybackConfig,
|
||||||
samples: &[i16],
|
samples: &[i16],
|
||||||
) -> (SessionSink, bool) {
|
) -> (SessionSink, bool) {
|
||||||
match write_pcm_nonblocking(&pcm, samples, config.channels as usize) {
|
let last_progress = match pcm.consumption_progress() {
|
||||||
Ok(WriteOutcome::Progress) => (
|
Ok(true) => Instant::now(),
|
||||||
SessionSink::Active {
|
Ok(false) => last_progress,
|
||||||
pcm,
|
Err(error) => return recover_sink(pcm, config, error),
|
||||||
last_progress: Instant::now(),
|
};
|
||||||
},
|
if last_progress.elapsed() >= SINK_STALL_TIMEOUT {
|
||||||
true,
|
// Discard queued speech before probing an unavailable host again.
|
||||||
),
|
if let Err(error) = pcm.reset_and_prime(config.channels as usize) {
|
||||||
Ok(WriteOutcome::Recovered) => (
|
|
||||||
SessionSink::Active {
|
|
||||||
pcm,
|
|
||||||
last_progress: Instant::now(),
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
Ok(WriteOutcome::Blocked) if last_progress.elapsed() < SINK_STALL_TIMEOUT => {
|
|
||||||
(SessionSink::Active { pcm, last_progress }, false)
|
|
||||||
}
|
|
||||||
Ok(WriteOutcome::Blocked) => {
|
|
||||||
if let Err(error) = reset_pcm_buffer(&pcm)
|
|
||||||
.and_then(|_| prime_pcm_with_silence(&pcm, config.channels as usize))
|
|
||||||
{
|
|
||||||
warn!("Failed to reset stalled UAC playback: {error}");
|
warn!("Failed to reset stalled UAC playback: {error}");
|
||||||
return retry_later();
|
return retry_later();
|
||||||
}
|
}
|
||||||
info!("UAC target stopped consuming audio; waiting for playback activity");
|
info!("UAC target stopped consuming audio; waiting for playback activity");
|
||||||
(SessionSink::Probing { pcm, stalled: true }, false)
|
return (SessionSink::Probing { pcm, stalled: true }, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match pcm.write_samples(samples, config.channels as usize) {
|
||||||
|
Ok(accepted) => (SessionSink::Active { pcm, last_progress }, accepted),
|
||||||
|
Err(error) => recover_sink(pcm, config, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_sink(
|
||||||
|
mut pcm: PlaybackPcm,
|
||||||
|
config: &UacPlaybackConfig,
|
||||||
|
error: alsa::Error,
|
||||||
|
) -> (SessionSink, bool) {
|
||||||
|
match error.errno() {
|
||||||
|
libc::EAGAIN | libc::EINTR => (SessionSink::Probing { pcm, stalled: true }, false),
|
||||||
|
libc::EPIPE | libc::ESTRPIPE => {
|
||||||
|
// prepare restarts after XRUN/suspend without snd_pcm_recover's
|
||||||
|
// potentially unbounded resume loop. Start again with silence,
|
||||||
|
// and require fresh consumption before reporting Active.
|
||||||
|
match pcm.reset_and_prime(config.channels as usize) {
|
||||||
|
Ok(()) => (SessionSink::Probing { pcm, stalled: true }, false),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
warn!("UAC playback write failed; retrying later: {error}");
|
warn!("Failed to recover UAC playback: {error}");
|
||||||
|
retry_later()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("UAC playback failed; reopening later: {error}");
|
||||||
retry_later()
|
retry_later()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +347,7 @@ fn retry_later() -> (SessionSink, bool) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_pcm(config: &UacPlaybackConfig) -> Result<PCM> {
|
fn open_pcm(config: &UacPlaybackConfig) -> Result<PlaybackPcm> {
|
||||||
let pcm = PCM::new(&config.device_name, Direction::Playback, true).map_err(|error| {
|
let pcm = PCM::new(&config.device_name, Direction::Playback, true).map_err(|error| {
|
||||||
AppError::AudioError(format!(
|
AppError::AudioError(format!(
|
||||||
"Failed to open UAC device {}: {error}",
|
"Failed to open UAC device {}: {error}",
|
||||||
@@ -348,10 +377,9 @@ fn open_pcm(config: &UacPlaybackConfig) -> Result<PCM> {
|
|||||||
let params = pcm.sw_params_current().map_err(|error| {
|
let params = pcm.sw_params_current().map_err(|error| {
|
||||||
AppError::AudioError(format!("Failed to read UAC SwParams: {error}"))
|
AppError::AudioError(format!("Failed to read UAC SwParams: {error}"))
|
||||||
})?;
|
})?;
|
||||||
let start_threshold =
|
|
||||||
(period_frames as Frames * START_THRESHOLD_PERIODS).min(buffer_frames as Frames);
|
|
||||||
params
|
params
|
||||||
.set_start_threshold(start_threshold)
|
.set_start_threshold(buffer_frames as Frames)
|
||||||
|
.and_then(|_| params.set_stop_threshold(buffer_frames as Frames))
|
||||||
.and_then(|_| params.set_avail_min(period_frames as Frames))
|
.and_then(|_| params.set_avail_min(period_frames as Frames))
|
||||||
.and_then(|_| pcm.sw_params(¶ms))
|
.and_then(|_| pcm.sw_params(¶ms))
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -365,101 +393,219 @@ fn open_pcm(config: &UacPlaybackConfig) -> Result<PCM> {
|
|||||||
"UAC playback opened on {} (buffer={} frames, period={} frames)",
|
"UAC playback opened on {} (buffer={} frames, period={} frames)",
|
||||||
config.device_name, buffer_frames, period_frames
|
config.device_name, buffer_frames, period_frames
|
||||||
);
|
);
|
||||||
Ok(pcm)
|
Ok(PlaybackPcm {
|
||||||
|
pcm,
|
||||||
|
buffer_frames: buffer_frames as Frames,
|
||||||
|
period_frames: period_frames as Frames,
|
||||||
|
submitted_frames: 0,
|
||||||
|
consumed_frames: 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
struct PlaybackPcm {
|
||||||
enum WriteOutcome {
|
pcm: PCM,
|
||||||
Progress,
|
buffer_frames: Frames,
|
||||||
Blocked,
|
period_frames: Frames,
|
||||||
Recovered,
|
submitted_frames: u64,
|
||||||
|
consumed_frames: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_pcm_nonblocking(pcm: &PCM, samples: &[i16], channels: usize) -> Result<WriteOutcome> {
|
impl PlaybackPcm {
|
||||||
let total_frames = samples.len() / channels;
|
fn consumption_progress(&mut self) -> std::result::Result<bool, alsa::Error> {
|
||||||
match pcm.avail() {
|
// avail synchronizes the hardware pointer. Successful writes alone
|
||||||
Ok(available) if available < total_frames as Frames => return Ok(WriteOutcome::Blocked),
|
// only show that the ring buffer has room, not that USB is consuming.
|
||||||
Ok(_) => {}
|
let available = self.pcm.avail()?;
|
||||||
Err(error) => {
|
match self.pcm.state() {
|
||||||
recover_pcm(pcm, error)?;
|
State::XRun => return Err(alsa::Error::new("UAC PCM state", libc::EPIPE)),
|
||||||
return Ok(WriteOutcome::Recovered);
|
State::Suspended => return Err(alsa::Error::new("UAC PCM state", libc::ESTRPIPE)),
|
||||||
|
State::Disconnected => return Err(alsa::Error::new("UAC PCM state", libc::ENODEV)),
|
||||||
|
State::Running => {}
|
||||||
|
_ => return Ok(false),
|
||||||
}
|
}
|
||||||
|
let consumed = consumed_frames(self.submitted_frames, self.buffer_frames, available);
|
||||||
|
let progressed = consumed > self.consumed_frames;
|
||||||
|
self.consumed_frames = consumed;
|
||||||
|
Ok(progressed)
|
||||||
}
|
}
|
||||||
|
|
||||||
let io = pcm
|
fn write_samples(
|
||||||
.io_i16()
|
&mut self,
|
||||||
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
|
samples: &[i16],
|
||||||
match io.writei(samples) {
|
channels: usize,
|
||||||
Ok(0) => Ok(WriteOutcome::Blocked),
|
) -> std::result::Result<bool, alsa::Error> {
|
||||||
Ok(_) => Ok(WriteOutcome::Progress),
|
let io = self.pcm.io_i16()?;
|
||||||
Err(error) if error.errno() == libc::EAGAIN => Ok(WriteOutcome::Blocked),
|
let written = write_frames(samples, channels, self.period_frames as usize, |chunk| {
|
||||||
Err(error) => {
|
let written = io.writei(chunk)?;
|
||||||
recover_pcm(pcm, error)?;
|
self.submitted_frames += written as u64;
|
||||||
Ok(WriteOutcome::Recovered)
|
Ok(written)
|
||||||
}
|
})?;
|
||||||
}
|
Ok(written == samples.len() / channels)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Once a full playback buffer gains at least one period of free space, the
|
fn prime_with_silence(&mut self, channels: usize) -> Result<()> {
|
||||||
/// USB host has enabled the UAC streaming interface and is consuming samples.
|
// Use the negotiated capacity, not BUFFER_FRAMES. A near request is
|
||||||
fn sink_is_consuming(pcm: &PCM) -> Result<bool> {
|
// often clamped by u_audio's DMA buffer limit.
|
||||||
if pcm.state() == State::XRun {
|
let silence = vec![0i16; self.buffer_frames as usize * channels];
|
||||||
return Ok(true);
|
let complete = self
|
||||||
}
|
.write_samples(&silence, channels)
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to prime UAC PCM: {error}")))?;
|
||||||
match pcm.avail() {
|
if !complete {
|
||||||
Ok(available) => Ok(available >= PERIOD_FRAMES),
|
return Err(AppError::AudioError(
|
||||||
Err(error) if error.errno() == libc::EPIPE => Ok(true),
|
"UAC PCM priming was interrupted".into(),
|
||||||
Err(error) => Err(AppError::AudioError(format!(
|
));
|
||||||
"Failed to query UAC playback availability: {error}"
|
}
|
||||||
))),
|
// Most hardware starts automatically at the threshold. Some PCM
|
||||||
}
|
// plugins remain Prepared despite accepting the complete prefill.
|
||||||
}
|
// Start explicitly only after priming, and never restart a running PCM.
|
||||||
|
if self.pcm.state() == State::Prepared {
|
||||||
fn recover_pcm(pcm: &PCM, error: alsa::Error) -> Result<()> {
|
self.pcm.start().map_err(|error| {
|
||||||
let errno = error.errno();
|
AppError::AudioError(format!("Failed to start primed UAC PCM: {error}"))
|
||||||
pcm.try_recover(error, true).map_err(|recover_error| {
|
|
||||||
AppError::AudioError(format!("Failed to recover UAC playback: {recover_error}"))
|
|
||||||
})?;
|
})?;
|
||||||
if matches!(errno, libc::EPIPE | libc::ESTRPIPE) {
|
|
||||||
warn!("Recovered UAC playback after ALSA error {errno}");
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_and_prime(&mut self, channels: usize) -> Result<()> {
|
||||||
|
self.pcm
|
||||||
|
.drop()
|
||||||
|
.and_then(|_| self.pcm.prepare())
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to reset UAC PCM: {error}")))?;
|
||||||
|
self.submitted_frames = 0;
|
||||||
|
self.consumed_frames = 0;
|
||||||
|
self.prime_with_silence(channels)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset_pcm_buffer(pcm: &PCM) -> Result<()> {
|
fn consumed_frames(submitted: u64, buffer: Frames, available: Frames) -> u64 {
|
||||||
pcm.drop()
|
let queued = (buffer - available.clamp(0, buffer)) as u64;
|
||||||
.and_then(|_| pcm.prepare())
|
submitted.saturating_sub(queued)
|
||||||
.map_err(|error| AppError::AudioError(format!("Failed to reset UAC PCM: {error}")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prime the non-blocking ALSA buffer with silence. Subsequent WebSocket
|
/// Bound each write to one negotiated period and advance by actual frames,
|
||||||
/// frames inspect buffer progress to detect when the USB host starts reading.
|
/// including short writes. Never wait for space or retain stale audio.
|
||||||
fn prime_pcm_with_silence(pcm: &PCM, channels: usize) -> Result<()> {
|
fn write_frames(
|
||||||
let silence = vec![0i16; BUFFER_FRAMES as usize * channels];
|
samples: &[i16],
|
||||||
let io = pcm
|
channels: usize,
|
||||||
.io_i16()
|
period_frames: usize,
|
||||||
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
|
mut write: impl FnMut(&[i16]) -> std::result::Result<usize, alsa::Error>,
|
||||||
let mut frame_offset = 0usize;
|
) -> std::result::Result<usize, alsa::Error> {
|
||||||
while frame_offset < BUFFER_FRAMES as usize {
|
let total_frames = samples.len() / channels;
|
||||||
match io.writei(&silence[frame_offset * channels..]) {
|
let mut offset = 0;
|
||||||
|
while offset < total_frames {
|
||||||
|
let end = (offset + period_frames).min(total_frames);
|
||||||
|
match write(&samples[offset * channels..end * channels]) {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(written) => frame_offset += written,
|
Ok(written) => offset += written,
|
||||||
Err(error) if error.errno() == libc::EAGAIN => break,
|
Err(error) if matches!(error.errno(), libc::EAGAIN | libc::EINTR) => break,
|
||||||
Err(error) => {
|
Err(error) => return Err(error),
|
||||||
return Err(AppError::AudioError(format!(
|
|
||||||
"Failed to prime UAC PCM with silence: {error}"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Ok(offset)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_short_frames_without_skipping_stereo_samples() {
|
||||||
|
let samples: Vec<i16> = (0..24).collect();
|
||||||
|
let mut received = Vec::new();
|
||||||
|
let written = write_frames(&samples, 2, 4, |chunk| {
|
||||||
|
assert!(chunk.len() <= 8);
|
||||||
|
// Simulate a device accepting only one frame per write.
|
||||||
|
received.extend_from_slice(&chunk[..2]);
|
||||||
|
Ok(1)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(written, 12);
|
||||||
|
assert_eq!(received, samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_device_stops_writing_without_waiting_or_claiming_whole_packet() {
|
||||||
|
let mut calls = 0;
|
||||||
|
let written = write_frames(&[0; 24], 2, 4, |_| {
|
||||||
|
calls += 1;
|
||||||
|
if calls == 1 {
|
||||||
|
Ok(2)
|
||||||
|
} else {
|
||||||
|
Err(alsa::Error::new("test write", libc::EAGAIN))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(written, 2);
|
||||||
|
assert_eq!(calls, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writing_into_free_space_is_not_host_consumption() {
|
||||||
|
// A partially filled or full buffer can exist without any USB I/O.
|
||||||
|
assert_eq!(consumed_frames(1024, 4096, 3072), 0);
|
||||||
|
assert_eq!(consumed_frames(4096, 4096, 0), 0);
|
||||||
|
// Consuming a period, followed by filling it again, preserves progress.
|
||||||
|
assert_eq!(consumed_frames(4096, 4096, 1024), 1024);
|
||||||
|
assert_eq!(consumed_frames(5120, 4096, 0), 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn null_config() -> UacPlaybackConfig {
|
||||||
|
UacPlaybackConfig {
|
||||||
|
device_name: "null".into(),
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_pcm_uses_negotiated_start_threshold_and_recovers_to_probing() {
|
||||||
|
// ALSA's null plugin exercises real libasound configuration and I/O
|
||||||
|
// without requiring a USB controller. It cannot verify DWC3 behavior.
|
||||||
|
let config = null_config();
|
||||||
|
let mut pcm = open_pcm(&config).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pcm.pcm
|
||||||
|
.sw_params_current()
|
||||||
|
.unwrap()
|
||||||
|
.get_start_threshold()
|
||||||
|
.unwrap(),
|
||||||
|
pcm.buffer_frames
|
||||||
|
);
|
||||||
|
pcm.prime_with_silence(2).unwrap();
|
||||||
|
assert!(pcm.consumption_progress().unwrap());
|
||||||
|
let (sink, accepted) =
|
||||||
|
recover_sink(pcm, &config, alsa::Error::new("test xrun", libc::EPIPE));
|
||||||
|
assert!(!accepted);
|
||||||
|
assert!(matches!(sink, SessionSink::Probing { stalled: true, .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_closes_an_open_native_pcm_before_returning() {
|
||||||
|
let playback = UacPlayback::start(null_config()).unwrap();
|
||||||
|
let session = playback.acquire_session().unwrap();
|
||||||
|
session.try_write(&[0; 2048]).unwrap();
|
||||||
|
assert!(!matches!(
|
||||||
|
session.runtime.lock().unwrap().sink,
|
||||||
|
SessionSink::Closed { .. }
|
||||||
|
));
|
||||||
|
playback.stop();
|
||||||
|
assert!(matches!(
|
||||||
|
session.runtime.lock().unwrap().sink,
|
||||||
|
SessionSink::Closed { retry_at: None }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_resume_reopens_instead_of_reusing_previous_sink() {
|
||||||
|
let config = null_config();
|
||||||
|
let mut runtime = SessionRuntime::new();
|
||||||
|
runtime.sink = SessionSink::Closed {
|
||||||
|
retry_at: Some(Instant::now() + Duration::from_secs(60)),
|
||||||
|
};
|
||||||
|
runtime.last_frame = Some(Instant::now() - IDLE_REOPEN_TIMEOUT);
|
||||||
|
runtime.write(&config, &[0; 2048]);
|
||||||
|
assert!(!matches!(runtime.sink, SessionSink::Closed { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn permits_only_one_microphone_session() {
|
fn permits_only_one_microphone_session() {
|
||||||
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
|
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use std::path::PathBuf;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use super::configfs::{
|
use super::configfs::{
|
||||||
configfs_path, create_dir, create_symlink, find_udc, is_configfs_available, remove_dir,
|
configfs_path, create_dir, find_udc, is_configfs_available, remove_dir, write_file,
|
||||||
remove_file, write_file, write_file_if_exists, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE,
|
write_file_if_exists, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
|
||||||
DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
|
DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
|
||||||
};
|
};
|
||||||
use super::function::GadgetFunction;
|
use super::function::GadgetFunction;
|
||||||
use super::hid::HidFunction;
|
use super::hid::HidFunction;
|
||||||
@@ -221,9 +221,7 @@ impl OtgGadgetManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn bind(&mut self, udc: &str) -> Result<()> {
|
pub fn bind(&mut self, udc: &str) -> Result<()> {
|
||||||
if let Err(e) = self.recreate_config_links() {
|
self.recreate_config_links()?;
|
||||||
warn!("Failed to recreate gadget config links before bind: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Binding gadget to UDC: {}", udc);
|
debug!("Binding gadget to UDC: {}", udc);
|
||||||
write_file(&self.gadget_path.join("UDC"), &udc)?;
|
write_file(&self.gadget_path.join("UDC"), &udc)?;
|
||||||
@@ -385,39 +383,22 @@ impl OtgGadgetManager {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let entries = std::fs::read_dir(&functions_path).map_err(|e| {
|
// ConfigFS binds functions in link insertion order. Preserve the
|
||||||
|
// setup order (UAC before HID), including on rebind: directory
|
||||||
|
// iteration order is unspecified and can change endpoint allocation.
|
||||||
|
for func in &self.functions {
|
||||||
|
let dest = self.config_path.join(func.name());
|
||||||
|
if dest.symlink_metadata().is_ok() {
|
||||||
|
fs::remove_file(&dest).map_err(|error| {
|
||||||
AppError::Internal(format!(
|
AppError::Internal(format!(
|
||||||
"Failed to read functions directory {}: {}",
|
"Failed to remove config link {}: {error}",
|
||||||
functions_path.display(),
|
dest.display()
|
||||||
e
|
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let name = entry.file_name();
|
|
||||||
let name = match name.to_str() {
|
|
||||||
Some(n) => n,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
if !name.contains(".usb") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let src = functions_path.join(name);
|
|
||||||
let dest = self.config_path.join(name);
|
|
||||||
|
|
||||||
if dest.exists() {
|
|
||||||
if let Err(e) = remove_file(&dest) {
|
|
||||||
warn!(
|
|
||||||
"Failed to remove existing config link {}: {}",
|
|
||||||
dest.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for func in &self.functions {
|
||||||
create_symlink(&src, &dest)?;
|
func.link(&self.config_path, &self.gadget_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -470,6 +451,81 @@ pub async fn wait_for_hid_devices(device_paths: &[PathBuf], timeout_ms: u64) ->
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
struct RecordedFunction {
|
||||||
|
name: &'static str,
|
||||||
|
links: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GadgetFunction for RecordedFunction {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.name
|
||||||
|
}
|
||||||
|
fn create(&self, _: &Path) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn link(&self, config: &Path, gadget: &Path) -> Result<()> {
|
||||||
|
super::super::configfs::create_symlink(
|
||||||
|
&gadget.join("functions").join(self.name),
|
||||||
|
&config.join(self.name),
|
||||||
|
)?;
|
||||||
|
self.links.lock().unwrap().push(self.name.into());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn unlink(&self, _: &Path) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn cleanup(&self, _: &Path) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebind_links_uac_before_hid_in_registration_order() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut manager = OtgGadgetManager::new();
|
||||||
|
manager.gadget_path = temp.path().to_path_buf();
|
||||||
|
manager.config_path = temp.path().join("configs/c.1");
|
||||||
|
fs::create_dir_all(&manager.config_path).unwrap();
|
||||||
|
let links = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
for name in ["uac1.usb0", "hid.usb0", "mass_storage.usb0"] {
|
||||||
|
fs::create_dir_all(temp.path().join("functions").join(name)).unwrap();
|
||||||
|
// Include a dangling pre-existing link, as well as testing rebind.
|
||||||
|
std::os::unix::fs::symlink("/nonexistent-uac-test", manager.config_path.join(name))
|
||||||
|
.unwrap();
|
||||||
|
manager
|
||||||
|
.add_function(Box::new(RecordedFunction {
|
||||||
|
name,
|
||||||
|
links: Arc::clone(&links),
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for _ in 0..2 {
|
||||||
|
links.lock().unwrap().clear();
|
||||||
|
manager.recreate_config_links().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
*links.lock().unwrap(),
|
||||||
|
["uac1.usb0", "hid.usb0", "mass_storage.usb0"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_failure_prevents_udc_binding() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut manager = OtgGadgetManager::new();
|
||||||
|
manager.gadget_path = temp.path().to_path_buf();
|
||||||
|
manager.config_path = temp.path().join("configs/c.1");
|
||||||
|
fs::create_dir_all(temp.path().join("functions/hid.usb0")).unwrap();
|
||||||
|
// A directory occupying a config link cannot be removed as a file.
|
||||||
|
fs::create_dir_all(manager.config_path.join("hid.usb0")).unwrap();
|
||||||
|
fs::write(temp.path().join("UDC"), "").unwrap();
|
||||||
|
manager.add_keyboard(false).unwrap();
|
||||||
|
assert!(manager.bind("test-udc").is_err());
|
||||||
|
assert_eq!(fs::read_to_string(temp.path().join("UDC")).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_manager_creation() {
|
fn test_manager_creation() {
|
||||||
|
|||||||
Reference in New Issue
Block a user