fix(host/pad-audio): retire the freed-string endpoint lookup everywhere, and make provisioning converge

Two loose ends from the pad-audio bring-up.

`wasapi 0.23`'s `DeviceEnumerator::get_device` passes `GetDevice` a pointer
into an `HSTRING` temporary that was already dropped, so it resolves whatever
the allocator left behind and misses ids that are perfectly valid. Only the
pad-audio path had been moved off it; the remaining four callers include
desktop loopback capture and the default-endpoint judgement, where a spurious
miss silently downgrades a capturable default to Unknown. The host now resolves
through `open_wasapi_device` (raw COM, buffer kept alive). `pf-client-core`
cannot share that helper — it pins a different `windows` revision than `wasapi`
does, so the two `IMMDevice` types are incompatible — and instead scans the
active collection by id, which touches only safe crate APIs.

Provisioning also stopped latching a transient. A stamp lands, a check run
immediately afterwards reports all seven keys served, and AudioEndpointBuilder
then reverts the three format keys behind us, leaving 4/7 for good. Since
`needs_aeb_kick` is what makes startup restart AudioEndpointBuilder + Audiosrv,
that transient meant bouncing the machine's whole audio stack on every host
start, forever, chasing stamps a re-pass lands. `ensure` now stamps, lets AEB
settle, and only then checks — repeating up to five times.

Before: fresh provisions landed 4/7 with kick=true on 3 of 4 runs. After: 4 of
4 runs settle 7/7 with kick=false in 2.8s, identity intact (Wireless
Controller / DualSense Wireless Controller / PFDS container), 4ch mask 0x33,
render and loopback capture both opening, and `pad-endpoint tone` clean.

Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a
clean tree. The client-side helper is type-checked against wasapi on Windows in
isolation — pf-client-core itself will not build on .173 (no ffmpeg/SDL3/Vulkan
toolchain there), so its module integration is unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:44:29 +02:00
co-authored by Claude Opus 5
parent 0d0e7e6861
commit 35285afafc
5 changed files with 85 additions and 16 deletions
+31 -1
View File
@@ -97,13 +97,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
/// Settings device pickers via session main), or the OS default. A picked device that's /// Settings device pickers via session main), or the OS default. A picked device that's
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning — /// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
/// audio keeps working, like the PipeWire twin's `target.object` behavior. /// audio keeps working, like the PipeWire twin's `target.object` behavior.
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
///
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
pub(crate) fn device_by_id(
enumerator: &DeviceEnumerator,
direction: &Direction,
id: &str,
) -> Result<wasapi::Device> {
let devices = enumerator
.get_device_collection(direction)
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
let count = devices
.get_nbr_devices()
.map_err(|e| anyhow!("endpoint count: {e}"))?;
for i in 0..count {
let dev = devices
.get_device_at_index(i)
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
if dev.get_id().is_ok_and(|got| got == id) {
return Ok(dev);
}
}
anyhow::bail!("no active {direction:?} endpoint with id {id}")
}
fn pick_device( fn pick_device(
enumerator: &DeviceEnumerator, enumerator: &DeviceEnumerator,
direction: &Direction, direction: &Direction,
var: &str, var: &str,
) -> Result<wasapi::Device> { ) -> Result<wasapi::Device> {
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) { if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
match enumerator.get_device(&id) { match device_by_id(enumerator, direction, &id) {
Ok(d) => { Ok(d) => {
tracing::info!( tracing::info!(
var, var,
+5 -3
View File
@@ -920,9 +920,11 @@ fn pad_render_thread(
let res = (|| -> anyhow::Result<()> { let res = (|| -> anyhow::Result<()> {
const BLOCK_ALIGN: usize = PAD_CHANNELS * 4; // f32 interleaved const BLOCK_ALIGN: usize = PAD_CHANNELS * 4; // f32 interleaved
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?; let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
let device = enumerator // Not `get_device`: that helper resolves through a freed string — see
.get_device(endpoint_id) // [`crate::audio_wasapi::device_by_id`].
.map_err(|e| anyhow!("correlated endpoint not found: {e}"))?; let device =
crate::audio_wasapi::device_by_id(&enumerator, &Direction::Render, endpoint_id)
.map_err(|e| anyhow!("correlated endpoint not found: {e:#}"))?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
// FL|FR|BL|BR: front pair = the pad's speaker, back pair = the voice coils. // FL|FR|BL|BR: front pair = the pad's speaker, back pair = the voice coils.
let desired = WaveFormat::new(32, 32, &SampleType::Float, 48_000, PAD_CHANNELS, Some(0x33)); let desired = WaveFormat::new(32, 32, &SampleType::Float, 48_000, PAD_CHANNELS, Some(0x33));
@@ -304,11 +304,13 @@ pub(crate) fn restore_default_playback() {
} }
/// Open a device by endpoint id, with a name for error context. /// Open a device by endpoint id, with a name for error context.
///
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
/// docs), so it fails at random on ids that are perfectly valid.
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> { pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
wasapi::DeviceEnumerator::new() super::pad_endpoint::open_wasapi_device(&ep.1)
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))? .map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
.get_device(&ep.1)
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
} }
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. --- // --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
@@ -95,6 +95,12 @@ const MMDEV_RENDER_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDe
const ENDPOINT_ID_PREFIX: &str = "{0.0.0.00000000}."; const ENDPOINT_ID_PREFIX: &str = "{0.0.0.00000000}.";
/// How long [`ensure`] waits for the new render endpoint to materialise after driver install. /// How long [`ensure`] waits for the new render endpoint to materialise after driver install.
const ENDPOINT_WAIT: Duration = Duration::from_secs(10); const ENDPOINT_WAIT: Duration = Duration::from_secs(10);
/// How many times [`ensure`] re-stamps before giving up and asking for an AudioEndpointBuilder
/// kick (AEB reverts the format keys behind a fresh endpoint — see the loop in [`ensure`]).
const STAMP_ATTEMPTS: usize = 5;
/// How long to let AudioEndpointBuilder settle after a stamp BEFORE checking whether it held.
/// Checking immediately always reports success, including on the passes that get reverted.
const STAMP_SETTLE: Duration = Duration::from_millis(1200);
/// One provisioned pad-audio endpoint. Persistent by design (endpoints survive host restarts); /// One provisioned pad-audio endpoint. Persistent by design (endpoints survive host restarts);
/// [`remove`] exists for tests + the `pad-endpoint remove` escape hatch only. /// [`remove`] exists for tests + the `pad-endpoint remove` escape hatch only.
@@ -844,7 +850,7 @@ fn open_mmdevice(endpoint_id: &str) -> Result<IMMDevice> {
/// leave behind — a heisenbug whose failure mode is `0x80070002` (ERROR_FILE_NOT_FOUND) for an id /// leave behind — a heisenbug whose failure mode is `0x80070002` (ERROR_FILE_NOT_FOUND) for an id
/// that is perfectly valid. [`open_mmdevice`] keeps its wide buffer alive across the call, so /// that is perfectly valid. [`open_mmdevice`] keeps its wide buffer alive across the call, so
/// resolve there and only borrow the crate's wrapper around the resulting interface. /// resolve there and only borrow the crate's wrapper around the resulting interface.
fn open_wasapi_device(endpoint_id: &str) -> Result<wasapi::Device> { pub(crate) fn open_wasapi_device(endpoint_id: &str) -> Result<wasapi::Device> {
let dev = open_mmdevice(endpoint_id)?; let dev = open_mmdevice(endpoint_id)?;
wasapi::Device::from_immdevice(dev) wasapi::Device::from_immdevice(dev)
.map_err(|e| anyhow!("wrap IMMDevice {endpoint_id} as a wasapi Device: {e}")) .map_err(|e| anyhow!("wrap IMMDevice {endpoint_id} as a wasapi Device: {e}"))
@@ -1162,8 +1168,35 @@ pub fn ensure(pad_index: u8) -> Result<PadEndpoint> {
wait_for_endpoint(&device_instance)? wait_for_endpoint(&device_instance)?
} }
}; };
stamp_endpoint(&endpoint_id, pad_index) // Stamp until it STAYS stamped. On a freshly created endpoint the write lands — a check run
.with_context(|| format!("stamp pad endpoint {endpoint_id}"))?; // straight afterwards reports all seven served — and AudioEndpointBuilder then quietly reverts
// the three format keys behind us, leaving 4/7 for good. So the check has to happen after a
// settle, not immediately: verifying too early is exactly how this looked like "the stamps
// never took" for one debugging session and "the stamps took fine" for the next.
//
// Converging here matters beyond tidiness: `needs_aeb_kick` is what makes
// [`provision_at_startup`] restart AudioEndpointBuilder + Audiosrv, so latching the transient
// means bouncing the whole machine's audio stack on EVERY host start, forever, chasing stamps
// a re-pass would have landed. Once the endpoint has finished settling a re-stamp sticks
// permanently (measured stable over 30 s), and `stamp_endpoint` skips already-served keys, so
// the extra passes cost nothing once it has taken.
let mut served = false;
for attempt in 0..STAMP_ATTEMPTS {
stamp_endpoint(&endpoint_id, pad_index)
.with_context(|| format!("stamp pad endpoint {endpoint_id}"))?;
thread::sleep(STAMP_SETTLE);
served = all_served(&endpoint_id, pad_index);
if served {
if attempt > 0 {
tracing::debug!(
pad = pad_index,
attempt = attempt + 1,
"pad endpoint stamps held after a re-pass"
);
}
break;
}
}
// Default-device guard: a freshly registered render endpoint can grab the default. A pad // Default-device guard: a freshly registered render endpoint can grab the default. A pad
// "speaker" as default playback would swallow ALL desktop audio — put the previous default // "speaker" as default playback would swallow ALL desktop audio — put the previous default
// back via the IPolicyConfig machinery audio_control already owns. // back via the IPolicyConfig machinery audio_control already owns.
@@ -1181,7 +1214,6 @@ pub fn ensure(pad_index: u8) -> Result<PadEndpoint> {
"default playback moved to the new pad endpoint and no previous default is known"), "default playback moved to the new pad endpoint and no previous default is known"),
} }
} }
let served = all_served(&endpoint_id, pad_index);
Ok(PadEndpoint { Ok(PadEndpoint {
endpoint_id, endpoint_id,
device_instance, device_instance,
@@ -349,7 +349,7 @@ fn capture_once(
if assert_plan { if assert_plan {
if let Some(d) = seen_default.as_deref() { if let Some(d) = seen_default.as_deref() {
if d != dev_id { if d != dev_id {
match judge_default(&en, &wiring, d) { match judge_default(&wiring, d) {
DefaultKind::Capturable(name) => { DefaultKind::Capturable(name) => {
tracing::info!(default = %name, planned = %dev_name, tracing::info!(default = %name, planned = %dev_name,
"could not park the default playback on the planned endpoint — \ "could not park the default playback on the planned endpoint — \
@@ -428,7 +428,7 @@ fn capture_once(
); );
return Ok(Next::Reopen(TargetMode::Follow)); return Ok(Next::Reopen(TargetMode::Follow));
} }
return Ok(match judge_default(&en, &wiring, &nid) { return Ok(match judge_default(&wiring, &nid) {
DefaultKind::Capturable(name) => { DefaultKind::Capturable(name) => {
tracing::info!(device = %name, tracing::info!(device = %name,
"operator changed the output device mid-stream — following \ "operator changed the output device mid-stream — following \
@@ -461,8 +461,11 @@ enum DefaultKind {
Unknown, Unknown,
} }
fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind { /// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
let Ok(dev) = en.get_device(id) else { /// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`.
fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else {
return DefaultKind::Unknown; return DefaultKind::Unknown;
}; };
let name = dev.get_friendlyname().unwrap_or_default(); let name = dev.get_friendlyname().unwrap_or_default();