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:
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user