mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
## Why Voice failure metrics lacked cause and lifecycle phase, and session durations were recorded before the final outcome was known. A failed control send could also overwrite a more specific WebRTC error. ## What changed - Tag `codex.voice.session.failure` with bounded `cause` and `phase` values, recording at most one failure per session. - Capture duration when stopping and emit `codex.voice.session.duration` at cleanup with a `success` or `failure` outcome. - Carry typed WebRTC errors into the TUI, preserve existing errors when control sends fail, and avoid duplicate errors after startup failure. - Record app-server disconnect failures for background voice sessions as well as the visible session. ## Testing Add coverage for terminal outcome and failure metric attributes, preservation of specific startup and audio-session errors, and startup failures without duplicate control-channel errors. GitOrigin-RevId: 5aeda6e766093e3fce7206560aa83b6f7770dddb
205 lines
7.1 KiB
Rust
205 lines
7.1 KiB
Rust
//! Exercises the public session actor against a separate framed-IPC helper, without native audio.
|
|
|
|
mod common;
|
|
|
|
use anyhow::Result;
|
|
use codex_realtime_webrtc::AudioControls;
|
|
use codex_realtime_webrtc::AudioDeviceKind;
|
|
use codex_realtime_webrtc::AudioDeviceSelection;
|
|
use codex_realtime_webrtc::RealtimeWebrtcSession;
|
|
use futures::future::AbortHandle;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
use std::thread;
|
|
|
|
#[test]
|
|
fn startup_controls_meters_and_helper_loss() -> Result<()> {
|
|
let Some(root) = common::package("startup_controls_meters_and_helper_loss")? else {
|
|
return Ok(());
|
|
};
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
assert!(!RealtimeWebrtcSession::is_supported());
|
|
let libraries = root.join("codex-resources/voice/lib");
|
|
fs::create_dir_all(&libraries)?;
|
|
fs::write(libraries.join("libgstreamer-1.0.so.0"), b"fixture runtime")?;
|
|
assert!(RealtimeWebrtcSession::is_supported());
|
|
}
|
|
let (_abort, registration) = AbortHandle::new_pair();
|
|
let started = RealtimeWebrtcSession::start(
|
|
registration,
|
|
AudioDeviceSelection {
|
|
microphone: Some("Zen Go Synergy Core Playback".into()),
|
|
speaker: Some("Headphones".into()),
|
|
channel: Some(vec![
|
|
std::num::NonZeroU16::new(/*n*/ 1).unwrap(),
|
|
std::num::NonZeroU16::new(/*n*/ 2).unwrap(),
|
|
]),
|
|
},
|
|
)?;
|
|
assert_eq!(started.offer_sdp, "synthetic-offer");
|
|
let handle = started.handle;
|
|
handle.set_microphone_muted(/*muted*/ true)?;
|
|
let answer_handle = handle.clone();
|
|
let answer = thread::spawn(move || answer_handle.apply_answer_sdp("synthetic-answer".into()));
|
|
common::wait_for(|| root.join("answer").exists())?;
|
|
handle.set_speaker_suppressed(/*suppressed*/ true);
|
|
fs::write(root.join("release"), [])?;
|
|
answer.join().expect("answer thread")?;
|
|
assert_eq!(
|
|
serde_json::from_slice::<serde_json::Value>(&fs::read(root.join("input-selection"))?)?,
|
|
serde_json::json!({"microphone": "Zen Go Synergy Core Playback", "speaker": "Headphones", "channel": [1, 2]})
|
|
);
|
|
let initial: Vec<AudioControls> = serde_json::from_slice(&fs::read(root.join("controls"))?)?;
|
|
assert_eq!(
|
|
initial,
|
|
vec![AudioControls {
|
|
microphone_muted: true,
|
|
speaker_suppressed: true
|
|
}]
|
|
);
|
|
handle.set_microphone_muted(/*muted*/ false)?;
|
|
handle.set_speaker_suppressed(/*suppressed*/ false);
|
|
let expected = serde_json::to_vec(&vec![
|
|
AudioControls {
|
|
microphone_muted: true,
|
|
speaker_suppressed: true,
|
|
},
|
|
AudioControls {
|
|
microphone_muted: false,
|
|
speaker_suppressed: true,
|
|
},
|
|
AudioControls {
|
|
microphone_muted: false,
|
|
speaker_suppressed: false,
|
|
},
|
|
])?;
|
|
common::wait_for(|| fs::read(root.join("controls")).is_ok_and(|data| data == expected))?;
|
|
common::wait_for(|| handle.take_microphone_peak() == 123)?;
|
|
common::wait_for(|| handle.take_speaker_peak() == 456)?;
|
|
fs::write(root.join("exit"), [])?;
|
|
let mut error = None;
|
|
common::wait_for(|| {
|
|
error = handle.take_error();
|
|
error.is_some()
|
|
})?;
|
|
assert_eq!(
|
|
error,
|
|
Some((
|
|
codex_realtime_webrtc::ConnectionError::AudioSession,
|
|
"voice audio session stopped unexpectedly".into()
|
|
))
|
|
);
|
|
assert_eq!(handle.take_error(), None);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn external_cancellation_interrupts_startup() -> Result<()> {
|
|
let Some(root) = common::package("external_cancellation_interrupts_startup")? else {
|
|
return Ok(());
|
|
};
|
|
fs::write(root.join("hold-initialization"), [])?;
|
|
let (abort, registration) = AbortHandle::new_pair();
|
|
let (result, received) = std::sync::mpsc::sync_channel(/*bound*/ 1);
|
|
let startup = thread::spawn(move || {
|
|
let _ = result.send(RealtimeWebrtcSession::start(
|
|
registration,
|
|
AudioDeviceSelection::default(),
|
|
));
|
|
});
|
|
common::wait_for(|| root.join("initializing").exists())?;
|
|
abort.abort();
|
|
assert!(
|
|
received
|
|
.recv_timeout(std::time::Duration::from_secs(/*secs*/ 2))?
|
|
.is_err()
|
|
);
|
|
startup.join().expect("startup thread");
|
|
#[cfg(unix)]
|
|
common::wait_for_helper_reaped(&root)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn last_owner_drop_reaps_helper() -> Result<()> {
|
|
let Some(root) = common::package("last_owner_drop_reaps_helper")? else {
|
|
return Ok(());
|
|
};
|
|
let (_abort, registration) = AbortHandle::new_pair();
|
|
let started = RealtimeWebrtcSession::start(registration, AudioDeviceSelection::default())?;
|
|
drop(started);
|
|
common::wait_for_helper_reaped(&root)
|
|
}
|
|
|
|
#[test]
|
|
fn device_failure_reaches_startup_completion_without_duplicate_error() -> Result<()> {
|
|
let Some(root) =
|
|
common::package("device_failure_reaches_startup_completion_without_duplicate_error")?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
fs::write(root.join("fail-devices"), [])?;
|
|
fs::write(root.join("release"), [])?;
|
|
let (_abort, registration) = AbortHandle::new_pair();
|
|
let started = RealtimeWebrtcSession::start(registration, AudioDeviceSelection::default())?;
|
|
assert_eq!(
|
|
started.handle.apply_answer_sdp("synthetic-answer".into()),
|
|
Err(codex_realtime_webrtc::ConnectionError::AudioDevices)
|
|
);
|
|
started.handle.set_microphone_muted(/*muted*/ true)?;
|
|
assert_eq!(started.handle.take_error(), None);
|
|
started.handle.close();
|
|
#[cfg(unix)]
|
|
common::wait_for_helper_reaped(&root)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_failure_reaches_offer_caller_without_native_error_text() -> Result<()> {
|
|
let Some(root) =
|
|
common::package("runtime_failure_reaches_offer_caller_without_native_error_text")?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
fs::write(root.join("fail-initialization"), [])?;
|
|
let (_abort, registration) = AbortHandle::new_pair();
|
|
let error =
|
|
RealtimeWebrtcSession::start(registration, AudioDeviceSelection::default()).unwrap_err();
|
|
assert_eq!(
|
|
error.downcast_ref::<codex_realtime_webrtc::ConnectionError>(),
|
|
Some(&codex_realtime_webrtc::ConnectionError::RuntimeInitialization)
|
|
);
|
|
assert_eq!(
|
|
format!("{error:#}"),
|
|
"voice audio runtime could not initialize"
|
|
);
|
|
#[cfg(unix)]
|
|
common::wait_for_helper_reaped(&root)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn device_discovery_does_not_start_capture() -> Result<()> {
|
|
let Some(root) = common::package("device_discovery_does_not_start_capture")? else {
|
|
return Ok(());
|
|
};
|
|
let package = codex_install_context::InstallContext::current()
|
|
.package_layout
|
|
.as_ref()
|
|
.unwrap();
|
|
let mut host = codex_realtime_webrtc::VoiceHost::connect(package, common::BUILD_COMMIT).await?;
|
|
assert_eq!(
|
|
host.list_devices(AudioDeviceKind::Input).await?,
|
|
vec![codex_realtime_webrtc::AudioDevice {
|
|
name: "Interface".into(),
|
|
channels: 16,
|
|
is_default: true,
|
|
}]
|
|
);
|
|
host.close().await?;
|
|
assert!(!root.join("initializing").exists());
|
|
Ok(())
|
|
}
|