Add bounded GStreamer playback components to the voice host (#43244)

## What changed

- Add a `GstAudioSink` subclass backed by a bounded mono `F32LE` playback writer, with partial writes, cancellation on speaker epoch changes, and delay accounting for queued samples and pending device output.
- Recreate the CPAL output stream and discard buffered audio when speaker controls change the epoch, preventing stale audio from surviving suppression.
- Add GStreamer dependencies, adjust Windows Bazel native-tool selection, and allow the helper handshake time to load startup-linked libraries.
- Separate installed-client tests from protocol lifecycle tests and supply native startup libraries in test packages.

## Testing

Add unit coverage for partial writes, sample accounting, suppression cancellation, stale writers, device delay, invalid samples, and stalled consumption.

GitOrigin-RevId: c48c48d2fee00659cf6f8a4039a6745385cb77cd
This commit is contained in:
Benjamin Carlsson
2026-09-06 16:02:27 +00:00
committed by copyberry
parent ac192cd793
commit 9daf7d22ca
14 changed files with 1132 additions and 342 deletions
+22 -1
View File
File diff suppressed because one or more lines are too long
+272 -14
View File
@@ -844,6 +844,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atomic_refcell"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4"
[[package]]
name = "audio-codec-algorithms"
version = "0.8.1"
@@ -1794,6 +1800,16 @@ dependencies = [
"nom 7.1.3",
]
[[package]]
name = "cfg-expr"
version = "0.20.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -5033,6 +5049,8 @@ dependencies = [
"cpal",
"crossbeam-queue",
"futures",
"gstreamer",
"gstreamer-audio",
"libloading",
"opus",
"pretty_assertions",
@@ -7241,6 +7259,19 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "gio-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
]
[[package]]
name = "gix"
version = "0.81.0"
@@ -7303,7 +7334,7 @@ dependencies = [
"bstr",
"gix-date",
"gix-error",
"winnow",
"winnow 0.7.14",
]
[[package]]
@@ -7418,7 +7449,7 @@ dependencies = [
"smallvec",
"thiserror 2.0.18",
"unicode-bom",
"winnow",
"winnow 0.7.14",
]
[[package]]
@@ -7712,7 +7743,7 @@ dependencies = [
"itoa",
"smallvec",
"thiserror 2.0.18",
"winnow",
"winnow 0.7.14",
]
[[package]]
@@ -7810,7 +7841,7 @@ dependencies = [
"maybe-async",
"nonempty",
"thiserror 2.0.18",
"winnow",
"winnow 0.7.14",
]
[[package]]
@@ -7842,7 +7873,7 @@ dependencies = [
"gix-validate",
"memmap2",
"thiserror 2.0.18",
"winnow",
"winnow 0.7.14",
]
[[package]]
@@ -8096,6 +8127,49 @@ dependencies = [
"parking_lot",
]
[[package]]
name = "glib"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100"
dependencies = [
"bitflags 2.13.1",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "glib-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
dependencies = [
"libc",
"system-deps",
]
[[package]]
name = "glob"
version = "0.3.3"
@@ -8115,6 +8189,17 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "gobject-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -8126,6 +8211,100 @@ dependencies = [
"subtle",
]
[[package]]
name = "gstreamer"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab4527e1b9bae8d29ce137bde5b8eec8ae8f78f13ad00fc6e70cbe227d6ad027"
dependencies = [
"cfg-if",
"futures-channel",
"futures-core",
"futures-util",
"glib",
"gstreamer-sys",
"itertools 0.15.0",
"kstring",
"libc",
"muldiv",
"num-integer",
"num-rational",
"option-operations",
"pastey",
"pin-project-lite",
"smallvec",
"thiserror 2.0.18",
]
[[package]]
name = "gstreamer-audio"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f97532c3e21287a6e94563a328cce89367324acf8a4489291b40ede2e39163a0"
dependencies = [
"cfg-if",
"glib",
"gstreamer",
"gstreamer-audio-sys",
"gstreamer-base",
"libc",
"smallvec",
]
[[package]]
name = "gstreamer-audio-sys"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2abfb81a073c5c8fb115a3639f47b6430f669ee9fa29123bb0116c9ef59d7d16"
dependencies = [
"glib-sys",
"gobject-sys",
"gstreamer-base-sys",
"gstreamer-sys",
"libc",
"system-deps",
]
[[package]]
name = "gstreamer-base"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91c94a4d3047d05dd6e1f6d91c74f61f56384c7ea1c9d0c1051572eeeb0138d"
dependencies = [
"atomic_refcell",
"cfg-if",
"glib",
"gstreamer",
"gstreamer-base-sys",
"libc",
]
[[package]]
name = "gstreamer-base-sys"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fbbc623dc066908ba10c43d629c21096508dea04796592a206c4edd864e37"
dependencies = [
"glib-sys",
"gobject-sys",
"gstreamer-sys",
"libc",
"system-deps",
]
[[package]]
name = "gstreamer-sys"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533fa8d28fc830eafccbcfcfddb390563ea5d3a351af2c3aab99e197e5f5b1ba"
dependencies = [
"cfg-if",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "gzip-header"
version = "1.0.0"
@@ -9179,6 +9358,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -9907,6 +10095,12 @@ dependencies = [
"pxfm",
]
[[package]]
name = "muldiv"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0"
[[package]]
name = "multimap"
version = "0.10.1"
@@ -10762,6 +10956,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "option-operations"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aca39cf52b03268400c16eeb9b56382ea3c3353409309b63f5c8f0b1faf42754"
dependencies = [
"pastey",
]
[[package]]
name = "opus"
version = "0.4.0"
@@ -14783,6 +14986,19 @@ dependencies = [
"libc",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml 1.0.6+spec-1.1.0",
"version-compare",
]
[[package]]
name = "tagptr"
version = "0.2.0"
@@ -14805,6 +15021,12 @@ dependencies = [
"libc",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -15357,10 +15579,25 @@ dependencies = [
"indexmap 2.14.0",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow",
"winnow 0.7.14",
]
[[package]]
name = "toml"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
dependencies = [
"indexmap 2.14.0",
"serde_core",
"serde_spanned",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.14",
]
[[package]]
@@ -15372,6 +15609,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
@@ -15379,9 +15625,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap 2.14.0",
"toml_datetime",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"winnow",
"winnow 0.7.14",
]
[[package]]
@@ -15391,19 +15637,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e"
dependencies = [
"indexmap 2.14.0",
"toml_datetime",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow",
"winnow 0.7.14",
]
[[package]]
name = "toml_parser"
version = "1.0.6+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
"winnow 1.0.4",
]
[[package]]
@@ -16097,6 +16343,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -17073,6 +17325,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "winreg"
version = "0.10.1"
+2 -1
View File
@@ -151,7 +151,8 @@ impl VoiceHost {
build_commit: build_commit.to_owned(),
},
Message::Ready {},
DEADLINE,
// Startup-linked native libraries load before the helper can acknowledge Hello.
RUNTIME_INITIALIZATION_DEADLINE,
)
.await?;
Ok(host)
+9
View File
@@ -8,6 +8,13 @@ license.workspace = true
name = "codex-voice-host"
path = "src/main.rs"
# These tests need a correctly linked helper and a matching prepared runtime.
# Bazel supplies declared runtime inputs and runs this target automatically.
# Cargo runs it explicitly with --test installed_client.
[[test]]
name = "installed_client"
test = false
[lints]
workspace = true
@@ -24,6 +31,8 @@ webrtc = "=0.20.3"
[target.'cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"), all(windows, target_env = "msvc")))'.dependencies]
cpal = "=0.18.2"
crossbeam-queue = "0.3.12"
gstreamer = { version = "=0.25.3", features = ["v1_28"] }
gstreamer-audio = { version = "=0.25.3", features = ["v1_28"] }
opus = "=0.4.0"
rubato = { version = "=5.0.0", default-features = false }
sonora = { version = "=0.2.0", default-features = false }
+110
View File
@@ -0,0 +1,110 @@
//! Stock GstAudioSink scheduling with a bounded, epoch-owned CPAL writer.
//! Native resets outside an owner-initiated teardown fail the session closed.
use super::playback::PlaybackWriter;
use audio::subclass::prelude::*;
use gst::glib;
use gstreamer as gst;
use gstreamer_audio as audio;
use std::sync::OnceLock;
mod imp {
use super::*;
#[derive(Default)]
pub struct Sink {
pub(super) writer: OnceLock<PlaybackWriter>,
}
#[glib::object_subclass]
impl ObjectSubclass for Sink {
const NAME: &'static str = "CodexPrivateAudioSink";
type Type = super::Sink;
type ParentType = audio::AudioSink;
}
impl ObjectImpl for Sink {}
impl GstObjectImpl for Sink {}
impl ElementImpl for Sink {
fn metadata() -> Option<&'static gst::subclass::ElementMetadata> {
static META: OnceLock<gst::subclass::ElementMetadata> = OnceLock::new();
Some(META.get_or_init(|| {
gst::subclass::ElementMetadata::new(
"Private audio",
"Sink/Audio",
"Owned speaker output",
"OpenAI",
)
}))
}
fn pad_templates() -> &'static [gst::PadTemplate] {
static PADS: OnceLock<Vec<gst::PadTemplate>> = OnceLock::new();
PADS.get_or_init(|| {
gst::PadTemplate::new(
"sink",
gst::PadDirection::Sink,
gst::PadPresence::Always,
&gst::Caps::builder("audio/x-raw")
.field("format", "F32LE")
.field("layout", "interleaved")
.field("channels", 1i32)
.field(
"rate",
gst::IntRange::<i32>::new(/*min*/ 8000, /*max*/ 384000),
)
.build(),
)
.into_iter()
.collect()
})
}
}
impl BaseSinkImpl for Sink {}
impl AudioBaseSinkImpl for Sink {}
impl AudioSinkImpl for Sink {
fn prepare(&self, spec: &mut audio::AudioRingBufferSpec) -> Result<(), gst::LoggableError> {
let writer = self
.writer
.get()
.ok_or_else(|| gst::loggable_error!(gst::CAT_RUST, "speaker not bound"))?;
let info = spec.audio_info();
if info.rate() != writer.rate()
|| info.channels() != 1
|| info.format() != audio::AudioFormat::F32le
|| info.layout() != audio::AudioLayout::Interleaved
{
return Err(gst::loggable_error!(
gst::CAT_RUST,
"unsupported speaker format"
));
}
spec.set_segsize((writer.rate() / 100 * 4) as i32);
spec.set_segtotal(4);
spec.set_latency_time(10_000);
spec.set_buffer_time(40_000);
Ok(())
}
fn unprepare(&self) -> Result<(), gst::LoggableError> {
Ok(())
}
fn write(&self, bytes: &[u8]) -> Result<i32, gst::LoggableError> {
let writer = self
.writer
.get()
.ok_or_else(|| gst::loggable_error!(gst::CAT_RUST, "speaker not bound"))?;
writer.write(bytes).map(|n| n as i32).map_err(|_| {
writer.fail_if_current();
gst::loggable_error!(gst::CAT_RUST, "speaker write failed")
})
}
fn delay(&self) -> u32 {
self.writer.get().map_or(0, PlaybackWriter::delay)
}
fn reset(&self) {
if let Some(writer) = self.writer.get() {
writer.fail_if_current();
}
}
}
}
glib::wrapper! {
pub struct Sink(ObjectSubclass<imp::Sink>) @extends audio::AudioSink, audio::AudioBaseSink, audio::gst_base::BaseSink, gst::Element, gst::Object;
}
+28 -4
View File
@@ -5,6 +5,7 @@
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
use std::sync::atomic::AtomicU32;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::time::Duration;
@@ -96,6 +97,9 @@ pub(super) struct Buffers {
pub(super) failed: AtomicBool,
pub(super) microphone_peak: AtomicU16,
pub(super) speaker_peak: AtomicU16,
pub(super) queued: AtomicU32,
pub(super) last_dac_ns: AtomicU64,
pub(super) clock: Instant,
}
impl Buffers {
@@ -129,9 +133,20 @@ impl Buffers {
failed: AtomicBool::new(false),
microphone_peak: AtomicU16::new(/*v*/ 0),
speaker_peak: AtomicU16::new(/*v*/ 0),
queued: AtomicU32::new(/*v*/ 0),
last_dac_ns: AtomicU64::new(/*v*/ 0),
clock: Instant::now(),
}
}
pub(super) fn push_playback(&self, frame: Frame) -> Result<(), ()> {
let len = frame.len as u32;
self.queued.fetch_add(len, Ordering::AcqRel);
self.playback.push(frame).map_err(|_| {
self.queued.fetch_sub(len, Ordering::AcqRel);
})
}
// One control worker writes each epoch. Odd epochs are disabled; every
// transition advances the epoch so disable/re-enable cannot replay old audio.
pub(super) fn set_disabled(epoch: &AtomicU64, disabled: bool) -> std::io::Result<()> {
@@ -190,13 +205,19 @@ pub(super) struct Playback {
}
impl Playback {
pub(super) fn next(&mut self, buffers: &Buffers) -> f32 {
pub(super) fn next(&mut self, buffers: &Buffers) -> Option<f32> {
let epoch = buffers.speaker.load(Ordering::Acquire);
if self
.frame
.as_ref()
.is_some_and(|frame| frame.generation != epoch || self.offset == frame.len)
{
if let Some(frame) = &self.frame {
buffers.queued.fetch_sub(
frame.len.saturating_sub(self.offset) as u32,
Ordering::AcqRel,
);
}
self.frame = None;
}
// Bound stale-frame work even if a producer keeps writing during a mute.
@@ -214,17 +235,20 @@ impl Playback {
{
self.frame = Some(frame);
self.offset = 0;
} else {
buffers.queued.fetch_sub(frame.len as u32, Ordering::AcqRel);
}
}
let Some(frame) = &self.frame else {
return 0.0;
return None;
};
let sample = frame.samples[self.offset];
self.offset += 1;
buffers.queued.fetch_sub(/*val*/ 1, Ordering::AcqRel);
if epoch % 2 == 1 || !sample.is_finite() {
0.0
Some(0.0)
} else {
sample.clamp(-1.0, 1.0)
Some(sample.clamp(-1.0, 1.0))
}
}
}
@@ -48,8 +48,7 @@ fn suppression_discards_partial_and_queued_previous_generations() {
for _ in 0..2 {
assert!(
buffers
.playback
.push(Frame {
.push_playback(Frame {
samples: [0.5; BLOCK],
len: BLOCK,
at: Instant::now(),
@@ -58,11 +57,11 @@ fn suppression_discards_partial_and_queued_previous_generations() {
.is_ok()
);
}
assert_eq!(playback.next(&buffers), 0.5);
assert_eq!(playback.next(&buffers).unwrap_or(0.0), 0.5);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap();
assert_eq!(playback.next(&buffers), 0.0);
assert_eq!(playback.next(&buffers).unwrap_or(0.0), 0.0);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
assert_eq!(playback.next(&buffers), 0.0);
assert_eq!(playback.next(&buffers).unwrap_or(0.0), 0.0);
assert!(buffers.playback.is_empty());
}
@@ -75,8 +74,7 @@ fn output_is_finite_and_bounded_and_underflow_is_silence() {
samples[..4].copy_from_slice(&[f32::NAN, f32::INFINITY, -2.0, 2.0]);
assert!(
buffers
.playback
.push(Frame {
.push_playback(Frame {
samples,
len: 4,
at: Instant::now(),
@@ -85,7 +83,9 @@ fn output_is_finite_and_bounded_and_underflow_is_silence() {
.is_ok()
);
let mut playback = Playback::default();
let output: Vec<_> = (0..5).map(|_| playback.next(&buffers)).collect();
let output: Vec<_> = (0..5)
.map(|_| playback.next(&buffers).unwrap_or(0.0))
.collect();
assert_eq!(output, [0.0, 0.0, -1.0, 1.0, 0.0]);
}
+78 -7
View File
@@ -3,10 +3,14 @@
//! Capture and actual rendered output carry device timing.
//! References start with worker service; unmute rejects earlier device capture buffers.
#[path = "audio_sink.rs"]
mod audio_sink;
#[path = "device_buffers.rs"]
mod buffers;
#[path = "capture_worker.rs"]
mod capture_worker;
#[path = "playback.rs"]
mod playback;
#[path = "processing.rs"]
mod processing;
@@ -33,12 +37,17 @@ use buffers::FramePacker;
use buffers::MAX_CALLBACK_FRAMES;
use buffers::Playback;
use buffers::QUEUE_CAPACITY;
use playback::PlaybackPort;
const MAX_CAPTURE_AGE: Duration = Duration::from_secs(/*secs*/ 1);
pub(super) struct Devices {
_input: cpal::Stream,
_output: cpal::Stream,
output: Option<cpal::Stream>,
output_device: cpal::Device,
output_config: cpal::SupportedStreamConfig,
output_stream_config: cpal::StreamConfig,
playback: PlaybackPort,
worker: capture_worker::CaptureWorker,
}
@@ -93,6 +102,7 @@ impl Devices {
input_config.sample_rate(),
output_config.sample_rate(),
));
let playback = PlaybackPort::new(buffers.clone(), output_config.sample_rate());
let processor =
processing::Processor::new(input_config.sample_rate(), output_config.sample_rate())
.map_err(io::Error::other)?;
@@ -113,7 +123,7 @@ impl Devices {
buffers.clone()
)
.map_err(|_| io::Error::other("failed to open microphone"))?;
let output = stream!(
let output_stream = stream!(
output_config.sample_format(),
build_output,
&output,
@@ -121,7 +131,7 @@ impl Devices {
buffers.clone()
)
.map_err(|_| io::Error::other("failed to open speaker"))?;
output
output_stream
.play()
.map_err(|_| io::Error::other("failed to start speaker"))?;
input
@@ -129,7 +139,11 @@ impl Devices {
.map_err(|_| io::Error::other("failed to start microphone"))?;
Ok(Self {
_input: input,
_output: output,
output: Some(output_stream),
output_device: output,
output_config,
output_stream_config,
playback,
worker: capture_worker::CaptureWorker {
buffers,
processor,
@@ -142,7 +156,37 @@ impl Devices {
&mut self,
controls: codex_realtime_webrtc::AudioControls,
) -> io::Result<()> {
self.worker.set_controls(controls)
let buffers = self.worker.buffers.clone();
let previous = buffers.speaker.load(Ordering::Acquire);
self.worker.set_controls(controls)?;
if previous != buffers.speaker.load(Ordering::Acquire) {
// Invalidate writers first, then wait off the callback for any in-flight write.
let _producer = self
.playback
.0
.producer
.lock()
.map_err(|_| io::Error::other("speaker writer failed"))?;
// Dropping the stream stops its callbacks and discards device-owned audio.
drop(self.output.take());
while buffers.playback.pop().is_some() {}
while buffers.rendered.pop().is_some() {}
buffers.queued.store(/*val*/ 0, Ordering::Release);
buffers.last_dac_ns.store(/*val*/ 0, Ordering::Release);
let output = stream!(
self.output_config.sample_format(),
build_output,
&self.output_device,
&self.output_stream_config,
buffers
)
.map_err(|_| io::Error::other("failed to reset speaker"))?;
output
.play()
.map_err(|_| io::Error::other("failed to restart speaker"))?;
self.output = Some(output);
}
Ok(())
}
pub(super) async fn service(
@@ -153,6 +197,14 @@ impl Devices {
}
}
impl Drop for Devices {
fn drop(&mut self) {
self.worker.buffers.failed.store(true, Ordering::Release);
let _producer = self.playback.0.producer.lock().ok();
drop(self.output.take());
}
}
fn bounded_stream_config(
supported: &cpal::SupportedStreamConfig,
) -> io::Result<cpal::StreamConfig> {
@@ -322,6 +374,7 @@ fn render_output<T>(
data.fill(T::from_sample(0.0));
return;
}
let mut delivered_until = None;
for (index, chunk) in data.chunks_mut(BLOCK * channels).enumerate() {
let mut reference = Frame {
samples: [0.0; BLOCK],
@@ -329,8 +382,18 @@ fn render_output<T>(
at: start + Duration::from_secs_f64((index * BLOCK) as f64 / rate),
generation: buffers.speaker.load(Ordering::Acquire),
};
for (frame, sample) in chunk.chunks_mut(channels).zip(&mut reference.samples) {
let rendered = T::from_sample(output.playback.next(buffers));
for (offset, (frame, sample)) in chunk
.chunks_mut(channels)
.zip(&mut reference.samples)
.enumerate()
{
let next = output.playback.next(buffers);
if next.is_some() {
delivered_until = Some(
start + Duration::from_secs_f64((index * BLOCK + offset + 1) as f64 / rate),
);
}
let rendered = T::from_sample(next.unwrap_or(0.0));
frame.fill(rendered);
*sample = f32::from_sample(rendered);
record_peak(&buffers.speaker_peak, *sample);
@@ -339,6 +402,14 @@ fn render_output<T>(
buffers.failed.store(true, Ordering::Release);
}
}
if let Some(end) = delivered_until {
buffers.last_dac_ns.store(
end.saturating_duration_since(buffers.clock)
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
Ordering::Release,
);
}
}
#[cfg(test)]
+1 -2
View File
@@ -235,8 +235,7 @@ fn tiny_output_callbacks_pack_references_without_delaying_rendering() {
for _ in 0..8 {
assert!(
buffers
.playback
.push(Frame {
.push_playback(Frame {
samples: [0.5; BLOCK],
len: BLOCK,
at: start,
+114
View File
@@ -0,0 +1,114 @@
//! Bounded mono F32LE writes for the native sink, never called from a device callback.
//! Each writer belongs to one speaker epoch. Reset cancels it before locking the producer.
use super::buffers::BLOCK;
use super::buffers::Buffers;
use super::buffers::Frame;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
pub(super) struct PlaybackState {
pub(super) producer: Mutex<()>,
buffers: Arc<Buffers>,
rate: u32,
}
pub(super) struct PlaybackPort(pub(super) Arc<PlaybackState>);
pub(crate) struct PlaybackWriter {
state: Arc<PlaybackState>,
epoch: u64,
}
impl PlaybackPort {
pub(super) fn new(buffers: Arc<Buffers>, rate: u32) -> Self {
Self(Arc::new(PlaybackState {
producer: Mutex::new(()),
buffers,
rate,
}))
}
}
impl PlaybackWriter {
pub(crate) fn fail_if_current(&self) {
if self.state.buffers.speaker.load(Ordering::Acquire) == self.epoch {
self.state.buffers.failed.store(true, Ordering::Release);
}
}
pub(crate) fn rate(&self) -> u32 {
self.state.rate
}
pub(crate) fn write(&self, bytes: &[u8]) -> Result<usize, &'static str> {
if bytes.is_empty() || !bytes.len().is_multiple_of(4) {
return Err("invalid speaker buffer");
}
let mut frame = Frame {
samples: [0.0; BLOCK],
len: (bytes.len() / 4).min(BLOCK),
at: Instant::now(),
generation: self.epoch,
};
for (sample, bytes) in frame.samples.iter_mut().zip(bytes.chunks_exact(4)) {
*sample = f32::from_le_bytes(bytes.try_into().map_err(|_| "invalid speaker buffer")?);
if !sample.is_finite() {
return Err("invalid speaker sample");
}
}
let _producer = self
.state
.producer
.lock()
.map_err(|_| "speaker writer failed")?;
let deadline = Instant::now() + Duration::from_millis(/*millis*/ 100);
let buffers = &self.state.buffers;
let limit = (self.state.rate / 25).min((BLOCK * buffers.playback.capacity()) as u32);
loop {
if self.epoch % 2 == 1
|| buffers.speaker.load(Ordering::Acquire) != self.epoch
|| buffers.failed.load(Ordering::Acquire)
{
return Err("speaker writer cancelled");
}
if buffers.queued.load(Ordering::Acquire) + frame.len as u32 <= limit
&& !buffers.playback.is_full()
{
let bytes = frame.len * 4;
buffers
.push_playback(frame)
.map_err(|_| "speaker queue failed")?;
return Ok(bytes);
}
if Instant::now() >= deadline {
return Err("speaker fell behind");
}
std::thread::park_timeout(Duration::from_millis(/*millis*/ 1));
}
}
pub(crate) fn delay(&self) -> u32 {
let buffers = &self.state.buffers;
if buffers.speaker.load(Ordering::Acquire) != self.epoch || self.epoch % 2 == 1 {
return 0;
}
let remaining = buffers
.last_dac_ns
.load(Ordering::Acquire)
.saturating_sub(buffers.clock.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64);
let hardware =
(u128::from(remaining) * u128::from(self.state.rate)).div_ceil(1_000_000_000);
buffers
.queued
.load(Ordering::Acquire)
.saturating_add(hardware.min(u128::from(u32::MAX)) as u32)
}
}
#[cfg(test)]
#[path = "playback_tests.rs"]
mod tests;
+88
View File
@@ -0,0 +1,88 @@
use super::*;
use crate::devices::buffers::Playback;
use pretty_assertions::assert_eq;
impl PlaybackPort {
pub(super) fn writer(&self) -> PlaybackWriter {
PlaybackWriter {
state: self.0.clone(),
epoch: self.0.buffers.speaker.load(Ordering::Acquire),
}
}
}
fn active(rate: u32) -> (Arc<Buffers>, PlaybackPort, PlaybackWriter) {
let buffers = Arc::new(Buffers::new(rate, rate));
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
let port = PlaybackPort::new(buffers.clone(), rate);
let writer = port.writer();
(buffers, port, writer)
}
#[test]
fn partial_writes_account_for_samples_until_the_device_consumes_them() {
let (buffers, _, writer) = active(/*rate*/ 48000);
let samples: Vec<_> = (0..BLOCK + 20).map(|i| i as f32 / 512.0).collect();
let bytes: Vec<_> = samples.iter().flat_map(|v| v.to_le_bytes()).collect();
let first = writer.write(&bytes).unwrap();
assert_eq!(first, BLOCK * 4);
assert_eq!(writer.write(&bytes[first..]).unwrap(), 20 * 4);
assert_eq!(
(writer.rate(), writer.delay()),
(48000, (BLOCK + 20) as u32)
);
let mut playback = Playback::default();
let actual: Vec<_> = (0..samples.len())
.map(|_| playback.next(&buffers).unwrap())
.collect();
assert_eq!(actual, samples);
assert_eq!(writer.delay(), 0);
assert_eq!(playback.next(&buffers), None);
}
#[test]
fn suppression_cancels_a_full_writer_and_old_writers_cannot_resume() {
let (buffers, port, writer) = active(/*rate*/ 8000);
let bytes = vec![0; BLOCK * 4];
writer.write(&bytes).unwrap();
let waiting = std::thread::spawn(move || writer.write(&bytes));
Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap();
assert_eq!(waiting.join().unwrap(), Err("speaker writer cancelled"));
let stale = port.writer();
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
assert_eq!(stale.write(&[0; 4]), Err("speaker writer cancelled"));
let mut playback = Playback::default();
assert_eq!(playback.next(&buffers), None);
assert_eq!(buffers.queued.load(Ordering::Acquire), 0);
assert_eq!(port.writer().write(&[0; 4]), Ok(4));
}
#[test]
fn delay_includes_pending_dac_time_but_not_another_generation() {
let (buffers, _, writer) = active(/*rate*/ 48000);
writer.write(&[0; 40]).unwrap();
let end = buffers.clock.elapsed() + Duration::from_millis(/*millis*/ 100);
buffers
.last_dac_ns
.store(end.as_nanos() as u64, Ordering::Release);
let delay = writer.delay();
assert!((10..=4810).contains(&delay));
assert!(delay > 10);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap();
assert_eq!(writer.delay(), 0);
}
#[test]
fn invalid_samples_and_stalled_consumption_fail_without_growing_the_queue() {
let (buffers, _, writer) = active(/*rate*/ 8000);
assert_eq!(
writer.write(&f32::NAN.to_le_bytes()),
Err("invalid speaker sample")
);
writer.write(&vec![0; BLOCK * 4]).unwrap();
assert_eq!(
writer.write(&vec![0; BLOCK * 4]),
Err("speaker fell behind")
);
assert_eq!(buffers.queued.load(Ordering::Acquire), BLOCK as u32);
}
@@ -0,0 +1,363 @@
//! Exercise the production client against copied helper packages and startup libraries.
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use codex_install_context::CodexPackageLayout;
use codex_install_context::InstallContext;
use codex_realtime_webrtc::SessionDescription;
use codex_realtime_webrtc::VoiceHost;
use codex_utils_cargo_bin::cargo_bin;
use futures::future::BoxFuture;
use pretty_assertions::assert_eq;
use tokio::process::Command;
use tokio::sync::Notify;
use tokio::sync::mpsc;
use tokio::time::timeout;
use webrtc::data_channel::DataChannel;
use webrtc::peer_connection::MediaEngine;
use webrtc::peer_connection::PeerConnection;
use webrtc::peer_connection::PeerConnectionBuilder;
use webrtc::peer_connection::PeerConnectionEventHandler;
use webrtc::peer_connection::RTCIceConnectionState;
use webrtc::peer_connection::RTCIceGatheringState;
use webrtc::peer_connection::RTCPeerConnectionState;
use webrtc::peer_connection::RTCSessionDescription;
use webrtc::peer_connection::SettingEngine;
const DEADLINE: Duration = Duration::from_secs(/*secs*/ 10);
async fn build_commit() -> Result<String> {
let output = timeout(
DEADLINE,
Command::new(cargo_bin("codex-voice-host")?)
.arg("--build-commit")
.kill_on_drop(true)
.output(),
)
.await??;
assert!(output.status.success());
Ok(String::from_utf8(output.stdout)?.trim().to_owned())
}
fn install_startup_libraries(runtime: &Path) -> Result<()> {
if !cfg!(any(
target_os = "macos",
all(target_os = "linux", target_env = "gnu"),
all(windows, target_env = "msvc")
)) {
return Ok(());
}
let source = if codex_utils_cargo_bin::runfiles_available() {
let resource = format!(
"../../third_party/voice/native_link_{}_{}/runtime.json",
std::env::consts::OS,
std::env::consts::ARCH
);
codex_utils_cargo_bin::find_resource!(resource)?
.parent()
.context("runtime parent")?
.to_owned()
} else {
std::env::var_os("CODEX_TEST_VOICE_RUNTIME")
.map(PathBuf::from)
.context("prepared runtime required for installed helper tests")?
};
let libraries = if cfg!(windows) { "bin" } else { "lib" };
let destination = runtime.join(libraries);
fs::create_dir_all(&destination)?;
for entry in fs::read_dir(source.join(libraries))? {
let entry = entry?;
if entry.path().is_file() {
fs::copy(entry.path(), destination.join(entry.file_name()))?;
}
}
Ok(())
}
fn install_helper(root: &Path) -> Result<CodexPackageLayout> {
let bin = root.join("bin");
let helper_dir = root.join("codex-resources/voice/bin");
fs::create_dir_all(&bin)?;
fs::create_dir_all(&helper_dir)?;
fs::write(root.join("codex-package.json"), "{}")?;
let app = bin.join(if cfg!(windows) { "codex.exe" } else { "codex" });
fs::write(&app, [])?;
let source = cargo_bin("codex-voice-host")?;
let helper = helper_dir.join(source.file_name().context("helper binary file name")?);
fs::copy(&source, &helper)?;
install_startup_libraries(helper_dir.parent().context("helper runtime directory")?)?;
InstallContext::from_exe(
/*is_macos*/ cfg!(target_os = "macos"),
Some(&app),
/*method_override*/ None,
)
.package_layout
.context("package layout")
}
#[tokio::test]
async fn installed_client_rejects_mixed_builds_and_missing_helper() -> Result<()> {
let directory = tempfile::Builder::new()
.prefix("voice package ")
.tempdir()?;
let package = install_helper(directory.path())?;
let bin = directory.path().join("bin");
let source = cargo_bin("codex-voice-host")?;
let helper = directory
.path()
.join("codex-resources/voice/bin")
.join(source.file_name().context("helper binary file name")?);
VoiceHost::connect(&package, &build_commit().await?)
.await?
.close()
.await?;
assert!(VoiceHost::connect(&package, "wrong-build").await.is_err());
// Startup libraries permit handshakes; the missing runtime receipt prevents readiness.
let host = VoiceHost::connect(&package, &build_commit().await?).await?;
assert!(host.initialize_runtime().await.is_err());
// The same executable elsewhere in the package must not become a fallback.
fs::rename(&helper, bin.join(source.file_name().unwrap()))?;
assert!(
VoiceHost::connect(&package, &build_commit().await?)
.await
.is_err()
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&source, &helper)?;
assert!(
VoiceHost::connect(&package, &build_commit().await?)
.await
.is_err()
);
}
Ok(())
}
// Linux permits raw-byte filenames; macOS filesystems reject this name themselves.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn installed_client_accepts_non_utf8_package_path() -> Result<()> {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let directory = tempfile::tempdir()?;
let root = directory
.path()
.join(OsString::from_vec(b"voice-\xff".to_vec()));
let bin = root.join("bin");
let helper_dir = root.join("codex-resources/voice/bin");
fs::create_dir_all(&bin)?;
fs::create_dir_all(&helper_dir)?;
fs::write(root.join("codex-package.json"), "{}")?;
let app = bin.join("codex");
fs::write(&app, [])?;
let source = cargo_bin("codex-voice-host")?;
fs::copy(&source, helper_dir.join(source.file_name().unwrap()))?;
install_startup_libraries(helper_dir.parent().unwrap())?;
let package = InstallContext::from_exe(
/*is_macos*/ cfg!(target_os = "macos"),
Some(&app),
/*method_override*/ None,
)
.package_layout
.context("package layout")?;
VoiceHost::connect(&package, &build_commit().await?)
.await?
.close()
.await
}
enum RemoteState {
Ice(RTCIceConnectionState),
Peer(RTCPeerConnectionState),
Channel,
}
struct RemoteEvents {
channels: mpsc::Sender<Arc<dyn DataChannel>>,
gathered: Arc<Notify>,
diagnostics: mpsc::Sender<(Duration, RemoteState)>,
started: Instant,
}
impl RemoteEvents {
fn record(&self, state: RemoteState) {
// Keep only the first 32 events; diagnostics must never wait on the connection driver.
let _ = self.diagnostics.try_send((self.started.elapsed(), state));
}
}
impl PeerConnectionEventHandler for RemoteEvents {
fn on_ice_connection_state_change<'a, 'async_trait>(
&'a self,
state: RTCIceConnectionState,
) -> BoxFuture<'async_trait, ()>
where
'a: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move { self.record(RemoteState::Ice(state)) })
}
fn on_connection_state_change<'a, 'async_trait>(
&'a self,
state: RTCPeerConnectionState,
) -> BoxFuture<'async_trait, ()>
where
'a: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move { self.record(RemoteState::Peer(state)) })
}
fn on_data_channel<'a, 'async_trait>(
&'a self,
channel: Arc<dyn DataChannel>,
) -> BoxFuture<'async_trait, ()>
where
'a: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
self.record(RemoteState::Channel);
assert!(
self.channels.try_send(channel).is_ok(),
"receiver must accept the event channel"
);
})
}
fn on_ice_gathering_state_change<'a, 'async_trait>(
&'a self,
state: RTCIceGatheringState,
) -> BoxFuture<'async_trait, ()>
where
'a: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
if state == RTCIceGatheringState::Complete {
self.gathered.notify_one();
}
})
}
}
#[tokio::test]
async fn installed_client_negotiates_and_closes_over_udp_and_tcp() -> Result<()> {
let directory = tempfile::Builder::new()
.prefix("voice package ")
.tempdir()?;
let package = install_helper(directory.path())?;
let commit = build_commit().await?;
for tcp in [false, true] {
let started = Instant::now();
let (diagnostics, mut states) = mpsc::channel(/*buffer*/ 32);
let negotiation = timeout(Duration::from_secs(/*secs*/ 30), async {
let (sender, mut channels) = mpsc::channel(/*buffer*/ 1);
let gathered = Arc::new(Notify::new());
let mut settings = SettingEngine::default();
settings.set_lite(/*lite*/ true);
let mut media = MediaEngine::default();
media.register_default_codecs()?;
let builder = PeerConnectionBuilder::new()
.with_media_engine(media)
.with_setting_engine(settings)
.with_handler(Arc::new(RemoteEvents {
channels: sender,
gathered: gathered.clone(),
diagnostics,
started,
}));
let remote = if tcp {
builder.with_tcp_addrs(vec!["0.0.0.0:0"])
} else {
builder.with_udp_addrs(vec!["0.0.0.0:0"])
}
.build()
.await?;
let result: Result<()> = async {
let host = VoiceHost::connect(&package, &commit).await?;
let (host, offer) = host.start_transport().await?;
remote
.set_remote_description(RTCSessionDescription::offer(offer.into_sdp())?)
.await?;
let answer = remote.create_answer(/*options*/ None).await?;
remote.set_local_description(answer).await?;
gathered.notified().await;
let answer = remote.local_description().await.context("local answer")?;
let host = host
.apply_answer(
SessionDescription::try_from(answer.sdp).map_err(anyhow::Error::msg)?,
)
.await
.with_context(|| format!("tcp={tcp}: apply voice answer"))?;
let channel = channels.recv().await.context("event channel")?;
assert_eq!(
(channel.label().await?, channel.ordered().await?),
("oai-events".into(), true)
);
// This checks the Closed acknowledgement and successful child exit as well.
host.close().await
}
.await;
remote.close().await?;
result
});
let mut heartbeat = tokio::time::interval(Duration::from_millis(/*millis*/ 100));
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut ticks = 0_u32;
let mut last_poll = started;
let mut max_gap = Duration::ZERO;
let mut max_gap_at = Duration::ZERO;
let result = {
tokio::pin!(negotiation);
loop {
let outcome = tokio::select! {
result = &mut negotiation => Some(result),
_ = heartbeat.tick() => {
ticks += 1;
None
}
};
// Include the final polling gap even when negotiation or its timeout wins.
let now = Instant::now();
let gap = now.duration_since(last_poll);
if gap > max_gap {
max_gap = gap;
max_gap_at = now.duration_since(started);
}
last_poll = now;
if let Some(result) = outcome {
break result;
}
}
};
result
.with_context(|| format!("tcp={tcp}: helper negotiation timed out"))
.and_then(std::convert::identity)
.with_context(|| {
let events: Vec<_> = std::iter::from_fn(|| states.try_recv().ok())
.map(|(elapsed, state)| match state {
RemoteState::Ice(state) => format!("{elapsed:?}: ICE {state}"),
RemoteState::Peer(state) => format!("{elapsed:?}: peer {state}"),
RemoteState::Channel => format!("{elapsed:?}: channel arrived"),
})
.collect();
format!(
"tcp={tcp}: remote connection events (first 32): {events:?}; \
test runtime heartbeat: ticks={ticks}, max polling gap={max_gap:?} \
observed at {max_gap_at:?}"
)
})?;
}
Ok(())
}
+2 -302
View File
@@ -1,41 +1,20 @@
//! Exercise the real helper through installed paths and bounded process I/O.
//! Exercise the real helper protocol and bounded process I/O.
use std::fs;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use codex_install_context::CodexPackageLayout;
use codex_install_context::InstallContext;
use codex_realtime_webrtc::Message;
use codex_realtime_webrtc::SessionDescription;
use codex_realtime_webrtc::VoiceHost;
use codex_realtime_webrtc::decode_frame;
use codex_realtime_webrtc::encode_frame;
use codex_utils_cargo_bin::cargo_bin;
use futures::future::BoxFuture;
use pretty_assertions::assert_eq;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::process::Child;
use tokio::process::Command;
use tokio::sync::Notify;
use tokio::sync::mpsc;
use tokio::time::timeout;
use webrtc::data_channel::DataChannel;
use webrtc::peer_connection::MediaEngine;
use webrtc::peer_connection::PeerConnection;
use webrtc::peer_connection::PeerConnectionBuilder;
use webrtc::peer_connection::PeerConnectionEventHandler;
use webrtc::peer_connection::RTCIceConnectionState;
use webrtc::peer_connection::RTCIceGatheringState;
use webrtc::peer_connection::RTCPeerConnectionState;
use webrtc::peer_connection::RTCSessionDescription;
use webrtc::peer_connection::SettingEngine;
const DEADLINE: Duration = Duration::from_secs(/*secs*/ 10);
@@ -116,8 +95,7 @@ async fn closes_after_acknowledgement_and_on_parent_pipe_loss() -> Result<()> {
}
#[tokio::test]
async fn exits_after_parent_pipe_loss_following_transport_start_request() -> Result<()> {
// The binary unit test separately forces startup to block and checks watchdog termination.
async fn parent_pipe_loss_cancels_transport_startup() -> Result<()> {
let mut child = spawn()?;
handshake(&mut child).await?;
child
@@ -158,281 +136,3 @@ async fn rejects_invalid_input_without_echoing_it() -> Result<()> {
}
Ok(())
}
fn install_helper(root: &Path) -> Result<CodexPackageLayout> {
let bin = root.join("bin");
let helper_dir = root.join("codex-resources/voice/bin");
fs::create_dir_all(&bin)?;
fs::create_dir_all(&helper_dir)?;
fs::write(root.join("codex-package.json"), "{}")?;
let app = bin.join(if cfg!(windows) { "codex.exe" } else { "codex" });
fs::write(&app, [])?;
let source = cargo_bin("codex-voice-host")?;
let helper = helper_dir.join(source.file_name().context("helper binary file name")?);
fs::copy(&source, &helper)?;
InstallContext::from_exe(
/*is_macos*/ cfg!(target_os = "macos"),
Some(&app),
/*method_override*/ None,
)
.package_layout
.context("package layout")
}
#[tokio::test]
async fn installed_client_rejects_mixed_builds_and_missing_helper() -> Result<()> {
let directory = tempfile::Builder::new()
.prefix("voice package ")
.tempdir()?;
let package = install_helper(directory.path())?;
let bin = directory.path().join("bin");
let source = cargo_bin("codex-voice-host")?;
let helper = directory
.path()
.join("codex-resources/voice/bin")
.join(source.file_name().unwrap());
VoiceHost::connect(&package, &build_commit().await?)
.await?
.close()
.await?;
assert!(VoiceHost::connect(&package, "wrong-build").await.is_err());
// Helper-only installations still handshake, but cannot claim native readiness.
let host = VoiceHost::connect(&package, &build_commit().await?).await?;
assert!(host.initialize_runtime().await.is_err());
// The same executable elsewhere in the package must not become a fallback.
fs::rename(&helper, bin.join(source.file_name().unwrap()))?;
assert!(
VoiceHost::connect(&package, &build_commit().await?)
.await
.is_err()
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&source, &helper)?;
assert!(
VoiceHost::connect(&package, &build_commit().await?)
.await
.is_err()
);
}
Ok(())
}
enum RemoteState {
Ice(RTCIceConnectionState),
Peer(RTCPeerConnectionState),
Channel,
}
struct RemoteEvents {
channels: mpsc::Sender<Arc<dyn DataChannel>>,
gathered: Arc<Notify>,
diagnostics: mpsc::Sender<(Duration, RemoteState)>,
started: Instant,
}
impl RemoteEvents {
fn record(&self, state: RemoteState) {
// Keep only the first 32 events; diagnostics must never wait on the connection driver.
let _ = self.diagnostics.try_send((self.started.elapsed(), state));
}
}
impl PeerConnectionEventHandler for RemoteEvents {
fn on_ice_connection_state_change<'a, 'async_trait>(
&'a self,
state: RTCIceConnectionState,
) -> BoxFuture<'async_trait, ()>
where
'a: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move { self.record(RemoteState::Ice(state)) })
}
fn on_connection_state_change<'a, 'async_trait>(
&'a self,
state: RTCPeerConnectionState,
) -> BoxFuture<'async_trait, ()>
where
'a: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move { self.record(RemoteState::Peer(state)) })
}
fn on_data_channel<'a, 'async_trait>(
&'a self,
channel: Arc<dyn DataChannel>,
) -> BoxFuture<'async_trait, ()>
where
'a: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
self.record(RemoteState::Channel);
assert!(
self.channels.try_send(channel).is_ok(),
"receiver must accept the event channel"
);
})
}
fn on_ice_gathering_state_change<'a, 'async_trait>(
&'a self,
state: RTCIceGatheringState,
) -> BoxFuture<'async_trait, ()>
where
'a: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
if state == RTCIceGatheringState::Complete {
self.gathered.notify_one();
}
})
}
}
#[tokio::test]
async fn installed_client_negotiates_and_closes_over_udp_and_tcp() -> Result<()> {
let directory = tempfile::Builder::new()
.prefix("voice package ")
.tempdir()?;
let package = install_helper(directory.path())?;
let commit = build_commit().await?;
for tcp in [false, true] {
let started = Instant::now();
let (diagnostics, mut states) = mpsc::channel(/*buffer*/ 32);
let negotiation = timeout(Duration::from_secs(/*secs*/ 30), async {
let (sender, mut channels) = mpsc::channel(/*buffer*/ 1);
let gathered = Arc::new(Notify::new());
let mut settings = SettingEngine::default();
settings.set_lite(/*lite*/ true);
let mut media = MediaEngine::default();
media.register_default_codecs()?;
let builder = PeerConnectionBuilder::new()
.with_media_engine(media)
.with_setting_engine(settings)
.with_handler(Arc::new(RemoteEvents {
channels: sender,
gathered: gathered.clone(),
diagnostics,
started,
}));
let remote = if tcp {
builder.with_tcp_addrs(vec!["0.0.0.0:0"])
} else {
builder.with_udp_addrs(vec!["0.0.0.0:0"])
}
.build()
.await?;
let result: Result<()> = async {
let host = VoiceHost::connect(&package, &commit).await?;
let (host, offer) = host.start_transport().await?;
remote
.set_remote_description(RTCSessionDescription::offer(offer.into_sdp())?)
.await?;
let answer = remote.create_answer(/*options*/ None).await?;
remote.set_local_description(answer).await?;
gathered.notified().await;
let answer = remote.local_description().await.context("local answer")?;
let host = host
.apply_answer(
SessionDescription::try_from(answer.sdp).map_err(anyhow::Error::msg)?,
)
.await
.with_context(|| format!("tcp={tcp}: apply voice answer"))?;
let channel = channels.recv().await.context("event channel")?;
assert_eq!(
(channel.label().await?, channel.ordered().await?),
("oai-events".into(), true)
);
// This checks the Closed acknowledgement and successful child exit as well.
host.close().await
}
.await;
remote.close().await?;
result
});
let mut heartbeat = tokio::time::interval(Duration::from_millis(/*millis*/ 100));
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut ticks = 0_u32;
let mut last_poll = started;
let mut max_gap = Duration::ZERO;
let mut max_gap_at = Duration::ZERO;
let result = {
tokio::pin!(negotiation);
loop {
let outcome = tokio::select! {
result = &mut negotiation => Some(result),
_ = heartbeat.tick() => {
ticks += 1;
None
}
};
// Include the final polling gap even when negotiation or its timeout wins.
let now = Instant::now();
let gap = now.duration_since(last_poll);
if gap > max_gap {
max_gap = gap;
max_gap_at = now.duration_since(started);
}
last_poll = now;
if let Some(result) = outcome {
break result;
}
}
};
result
.with_context(|| format!("tcp={tcp}: helper negotiation timed out"))
.and_then(std::convert::identity)
.with_context(|| {
let events: Vec<_> = std::iter::from_fn(|| states.try_recv().ok())
.map(|(elapsed, state)| match state {
RemoteState::Ice(state) => format!("{elapsed:?}: ICE {state}"),
RemoteState::Peer(state) => format!("{elapsed:?}: peer {state}"),
RemoteState::Channel => format!("{elapsed:?}: channel arrived"),
})
.collect();
format!(
"tcp={tcp}: remote connection events (first 32): {events:?}; \
test runtime heartbeat: ticks={ticks}, max polling gap={max_gap:?} \
observed at {max_gap_at:?}"
)
})?;
}
Ok(())
}
// Linux permits raw-byte filenames; macOS filesystems reject this name themselves.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn installed_client_accepts_non_utf8_package_path() -> Result<()> {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let directory = tempfile::tempdir()?;
let root = directory
.path()
.join(OsString::from_vec(b"voice-\xff".to_vec()));
let bin = root.join("bin");
let helper_dir = root.join("codex-resources/voice/bin");
fs::create_dir_all(&bin)?;
fs::create_dir_all(&helper_dir)?;
fs::write(root.join("codex-package.json"), "{}")?;
let app = bin.join("codex");
fs::write(&app, [])?;
let source = cargo_bin("codex-voice-host")?;
fs::copy(&source, helper_dir.join(source.file_name().unwrap()))?;
let package = InstallContext::from_exe(
/*is_macos*/ cfg!(target_os = "macos"),
Some(&app),
/*method_override*/ None,
)
.package_layout
.context("package layout")?;
VoiceHost::connect(&package, &build_commit().await?)
.await?
.close()
.await
}
+35 -3
View File
@@ -75,7 +75,7 @@ alias(
# Keep the pinned pkg-config sources, but explicitly use LLVM archive tools.
# The upstream bootstrap otherwise selects host ar/ranlib on macOS.
pkg_config(
name = "pkg_config",
name = "pkg_config_unix",
build_data = [
":pkg_config_linker",
"@llvm//tools:llvm-ar",
@@ -116,9 +116,21 @@ pkg_config(
native_tool_toolchain(
name = "pkg_config_tool",
path = "$(execpath :pkg_config)",
path = "$(execpath :pkg_config_unix)",
tags = ["manual"],
target = ":pkg_config",
target = ":pkg_config_unix",
)
# Build-script tools select their execution platform, independently of target ABI.
alias(
name = "pkg_config",
actual = select({
":windows_x86_64": ":windows_tools_x86_64",
":windows_aarch64": ":windows_tools_aarch64",
"//conditions:default": ":pkg_config_unix",
}),
tags = ["manual"],
visibility = ["//visibility:public"],
)
filegroup(
@@ -181,6 +193,9 @@ alias(
actual = select({
":" + os + "_" + cpu: ":native_prefix_" + os + "_" + cpu
for os, cpu, _ in _NATIVE_PLATFORMS
} | {
":windows_" + cpu + "_msvc": ":native_prefix_windows_" + cpu
for cpu in ("x86_64", "aarch64")
}),
tags = ["manual"],
visibility = ["//visibility:public"],
@@ -209,6 +224,9 @@ alias(
actual = select({
":" + os + "_" + cpu: ":native_runtime_" + os + "_" + cpu
for os, cpu, _ in _NATIVE_PLATFORMS
} | {
":windows_" + cpu + "_msvc": ":native_runtime_windows_" + cpu
for cpu in ("x86_64", "aarch64")
}),
tags = ["manual"],
visibility = ["//visibility:public"],
@@ -241,6 +259,9 @@ alias(
actual = select({
":" + os + "_" + cpu: ":native_link_" + os + "_" + cpu
for os, cpu, _ in _NATIVE_PLATFORMS
} | {
":windows_" + cpu + "_msvc": ":native_link_windows_" + cpu
for cpu in ("x86_64", "aarch64")
}),
tags = ["manual"],
visibility = ["//visibility:public"],
@@ -259,6 +280,17 @@ label_flag(
visibility = ["//visibility:public"],
)
[
config_setting(
name = "windows_" + cpu,
constraint_values = [
"@platforms//os:windows",
"@platforms//cpu:" + cpu,
],
)
for cpu in ("x86_64", "aarch64")
]
[
config_setting(
name = "windows_" + cpu + "_msvc",