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 15:49:17 +00:00
committed by copyberry
parent ac192cd793
commit 9daf7d22ca
14 changed files with 1132 additions and 342 deletions

23
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

286
codex-rs/Cargo.lock generated
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"

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)

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 }

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;
}

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))
}
}
}

View File

@@ -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]);
}

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)]

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,

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;

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);
}

View File

@@ -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(())
}

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
}

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",